├── doc ├── previewed │ ├── image1.jpeg │ ├── image2.jpeg │ ├── image3.jpeg │ ├── image4.jpeg │ └── image5.jpeg ├── screenshots │ ├── picture_1.png │ ├── picture_2.png │ ├── picture_3.png │ ├── picture_4.png │ ├── picture_5.png │ ├── picture_6.png │ ├── picture_7.png │ ├── picture_8.png │ ├── picture_9.png │ ├── picture_10.png │ ├── picture_11.png │ ├── picture_12.png │ ├── picture_13.png │ ├── picture_14.png │ ├── picture_15.png │ ├── picture_16.png │ ├── picture_17.png │ ├── picture_18.png │ ├── picture_19.png │ └── picture_20.png └── threads_swiftui_logo.webp ├── Threads ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── app_logo.imageset │ │ └── Contents.json │ ├── main_background.imageset │ │ └── Contents.json │ └── AppColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Mapper │ ├── Mapper.swift │ └── Impl │ │ ├── CreateThreadMapper.swift │ │ ├── UserMapper.swift │ │ ├── NotificationMapper.swift │ │ └── ThreadMapper.swift ├── Model │ ├── CreateThreadBO.swift │ ├── CreateUserBO.swift │ ├── UpdateUserBO.swift │ ├── ThreadBO.swift │ ├── UserBO.swift │ └── NotificationBO.swift ├── DataSources │ ├── DTO │ │ ├── CreateUserDTO.swift │ │ ├── CreateThreadDTO.swift │ │ ├── UpdateUserDTO.swift │ │ ├── ThreadDTO.swift │ │ ├── UserDTO.swift │ │ └── NotificationDTO.swift │ ├── Extensions │ │ ├── CreateThreadDTO+Dictionary.swift │ │ ├── CreateUserDTO+Dictionary.swift │ │ └── UpdateUserDTO+Dictionary.swift │ ├── StorageFilesDataSource.swift │ ├── Impl │ │ ├── FirestoreStorageFilesDataSourceImpl.swift │ │ ├── FirebaseAuthenticationDataSourceImpl.swift │ │ └── FirestoreNotificationsDataSourceImpl.swift │ ├── NotificationsDataSource.swift │ ├── AuthenticationDataSource.swift │ ├── ThreadsDataSource.swift │ └── UserDataSource.swift ├── ViewModel │ ├── HomeViewModel.swift │ ├── Core │ │ ├── EventBus.swift │ │ ├── BaseUserViewModel.swift │ │ ├── BaseViewModel.swift │ │ └── BaseThreadsActionsViewModel.swift │ ├── FeedViewModel.swift │ ├── CreateThreadViewModel.swift │ ├── ForgotPasswordViewModel.swift │ ├── SignInViewModel.swift │ ├── UserContentListViewModel.swift │ ├── SignUpViewModel.swift │ ├── ActivityViewModel.swift │ ├── MainViewModel.swift │ ├── ConnectionsViewModel.swift │ ├── EditProfileViewModel.swift │ ├── ProfileViewModel.swift │ └── ExploreViewModel.swift ├── Core │ ├── ThreadCreation │ │ └── View │ │ │ └── ThreadCreation.swift │ └── Profile │ │ └── ViewModel │ │ └── ProfileViewModel.swift ├── View │ ├── Core │ │ ├── Components │ │ │ ├── DeveloperCreditView.swift │ │ │ ├── BackgroundImage.swift │ │ │ ├── LoadingView.swift │ │ │ ├── SnackbarView.swift │ │ │ ├── ShareActivityView.swift │ │ │ ├── CircularProfileImageView.swift │ │ │ ├── UserCell.swift │ │ │ ├── NotificationCell.swift │ │ │ └── ThreadCell.swift │ │ └── ViewModifiers │ │ │ ├── ThreadsTextFieldModifier.swift │ │ │ └── LoadingAndErrorOverlayModifier.swift │ ├── UserProfile │ │ ├── ProfileThreadFilter.swift │ │ ├── Components │ │ │ ├── ProfileHeaderView.swift │ │ │ └── UserContentListView.swift │ │ └── ProfileView.swift │ ├── Main │ │ └── MainView.swift │ ├── Home │ │ └── HomeView.swift │ ├── Activity │ │ └── ActivityView.swift │ ├── Feed │ │ └── FeedView.swift │ ├── Explore │ │ └── ExploreView.swift │ ├── ThreadCreation │ │ └── CreateThreadView.swift │ ├── Connections │ │ └── ConnectionsView.swift │ ├── ForgotPassword │ │ └── ForgotPasswordView.swift │ ├── EditProfile │ │ └── EditProfileView.swift │ ├── SignUp │ │ └── SignUpView.swift │ └── SignIn │ │ └── SignInView.swift ├── UseCases │ ├── SignOutUseCase.swift │ ├── GetSuggestionsUseCase.swift │ ├── GetCurrentUserUseCase.swift │ ├── FetchThreadsUseCase.swift │ ├── VerifySessionUseCase.swift │ ├── DeleteNotificationUseCase.swift │ ├── FetchOwnThreadsUseCase.swift │ ├── FetchNotificationsUseCase.swift │ ├── FetchThreadsByUserUseCase.swift │ ├── SearchUsersUseCase.swift │ ├── LikeThreadUseCase.swift │ ├── UpdateUserUseCase.swift │ ├── ForgotPasswordUseCase.swift │ ├── FetchUserConnectionsUseCase.swift │ ├── SignInUseCase.swift │ ├── CreateThreadUseCase.swift │ ├── FollowUserUseCase.swift │ └── SignUpUseCase.swift ├── App │ └── ThreadsApp.swift ├── Extensions │ ├── Timestamp.swift │ └── PreviewProvider.swift ├── Repositories │ ├── NotificationsRepository.swift │ ├── ThreadsRepository.swift │ ├── AuthenticationRepository.swift │ ├── Impl │ │ └── AuthenticationRepositoryImpl.swift │ └── UserProfileRepository.swift └── Launch Screen.storyboard ├── LICENSE └── .gitignore /doc/previewed/image1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/previewed/image1.jpeg -------------------------------------------------------------------------------- /doc/previewed/image2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/previewed/image2.jpeg -------------------------------------------------------------------------------- /doc/previewed/image3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/previewed/image3.jpeg -------------------------------------------------------------------------------- /doc/previewed/image4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/previewed/image4.jpeg -------------------------------------------------------------------------------- /doc/previewed/image5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/previewed/image5.jpeg -------------------------------------------------------------------------------- /doc/screenshots/picture_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_1.png -------------------------------------------------------------------------------- /doc/screenshots/picture_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_2.png -------------------------------------------------------------------------------- /doc/screenshots/picture_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_3.png -------------------------------------------------------------------------------- /doc/screenshots/picture_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_4.png -------------------------------------------------------------------------------- /doc/screenshots/picture_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_5.png -------------------------------------------------------------------------------- /doc/screenshots/picture_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_6.png -------------------------------------------------------------------------------- /doc/screenshots/picture_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_7.png -------------------------------------------------------------------------------- /doc/screenshots/picture_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_8.png -------------------------------------------------------------------------------- /doc/screenshots/picture_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_9.png -------------------------------------------------------------------------------- /doc/threads_swiftui_logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/threads_swiftui_logo.webp -------------------------------------------------------------------------------- /doc/screenshots/picture_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_10.png -------------------------------------------------------------------------------- /doc/screenshots/picture_11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_11.png -------------------------------------------------------------------------------- /doc/screenshots/picture_12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_12.png -------------------------------------------------------------------------------- /doc/screenshots/picture_13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_13.png -------------------------------------------------------------------------------- /doc/screenshots/picture_14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_14.png -------------------------------------------------------------------------------- /doc/screenshots/picture_15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_15.png -------------------------------------------------------------------------------- /doc/screenshots/picture_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_16.png -------------------------------------------------------------------------------- /doc/screenshots/picture_17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_17.png -------------------------------------------------------------------------------- /doc/screenshots/picture_18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_18.png -------------------------------------------------------------------------------- /doc/screenshots/picture_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_19.png -------------------------------------------------------------------------------- /doc/screenshots/picture_20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio11/threads_swiftui/HEAD/doc/screenshots/picture_20.png -------------------------------------------------------------------------------- /Threads/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Threads/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Threads/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Threads/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Threads/Mapper/Mapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mapper.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Mapper { 11 | associatedtype Input 12 | associatedtype Output 13 | 14 | func map(_ input: Input) -> Output 15 | } 16 | -------------------------------------------------------------------------------- /Threads/Model/CreateThreadBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadBO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CreateThreadBO: Codable { 11 | let threadId: String 12 | let ownerUid: String 13 | let caption: String 14 | } 15 | -------------------------------------------------------------------------------- /Threads/Model/CreateUserBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateUserBO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 19/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CreateUserBO: Codable { 11 | let userId: String 12 | let fullname: String 13 | let username: String 14 | let email: String 15 | } 16 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/CreateUserDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateUserDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct CreateUserDTO: Decodable { 11 | var userId: String 12 | var email: String 13 | var fullname: String 14 | var username: String 15 | } 16 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/CreateThreadDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Data Transfer Object for creating a new thread. 11 | internal struct CreateThreadDTO: Decodable { 12 | var threadId: String 13 | var ownerUid: String 14 | var caption: String 15 | } 16 | -------------------------------------------------------------------------------- /Threads/Model/UpdateUserBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUserBO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 19/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UpdateUserBO: Codable { 11 | let userId: String 12 | let fullname: String 13 | let bio: String? 14 | let link: String? 15 | let selectedImage: Data? 16 | let isPrivateProfile: Bool 17 | } 18 | -------------------------------------------------------------------------------- /Threads/ViewModel/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class HomeViewModel: BaseViewModel { 14 | 15 | @Published var selectedTab = 0 16 | @Published var showCreateThreadView = false 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/UpdateUserDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUserDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct UpdateUserDTO: Decodable { 11 | var userId: String 12 | var fullname: String 13 | var link: String? 14 | var isPrivateProfile: Bool 15 | var bio: String? 16 | var profileImageUrl: String? 17 | } 18 | -------------------------------------------------------------------------------- /Threads/Assets.xcassets/app_logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app_logo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/ThreadDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Data Transfer Object for representing a thread. 11 | internal struct ThreadDTO: Decodable { 12 | let threadId: String 13 | let ownerUid: String 14 | let caption: String 15 | let timestamp: Date 16 | let likedBy: [String] 17 | let likes: Int 18 | } 19 | -------------------------------------------------------------------------------- /Threads/Assets.xcassets/main_background.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "main_background.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Threads/Model/ThreadBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Thread.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 1/8/24. 6 | // 7 | import Foundation 8 | 9 | struct ThreadBO: Identifiable, Codable { 10 | var id: String { 11 | return threadId 12 | } 13 | let threadId: String 14 | let ownerUid: String 15 | let caption: String 16 | let timestamp: Date 17 | var likes: Int 18 | var isLikedByAuthUser: Bool = false 19 | let user: UserBO? 20 | } 21 | -------------------------------------------------------------------------------- /Threads/Core/ThreadCreation/View/ThreadCreation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadCreation.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThreadCreation: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | 16 | struct ThreadCreation_Previews: PreviewProvider { 17 | static var previews: some View { 18 | ThreadCreation() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/DeveloperCreditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeveloperCreditView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DeveloperCreditView: View { 11 | 12 | var body: some View { 13 | Text("Build with passion by dreamsoftware. Sergio Sánchez Sánchez © 2024") 14 | .font(.footnote) 15 | .foregroundColor(.white) 16 | .multilineTextAlignment(.center) 17 | .padding(.bottom, 10) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Threads/View/UserProfile/ProfileThreadFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileThreadFilter.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ProfileThreadFilter: Int, CaseIterable, Identifiable { 11 | case threads 12 | case replies 13 | 14 | var title: String { 15 | switch self { 16 | case .threads: return "Threads" 17 | case .replies: return "Replies" 18 | } 19 | } 20 | 21 | var id: Int { return self.rawValue } 22 | } 23 | -------------------------------------------------------------------------------- /Threads/UseCases/SignOutUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An entity responsible for handling user sign-out operations. 11 | struct SignOutUseCase { 12 | let repository: AuthenticationRepository 13 | 14 | /// Executes the sign-out operation asynchronously. 15 | /// - Throws: An error if the sign-out operation fails. 16 | func execute() async throws { 17 | try await repository.signOut() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Threads/Model/UserBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UserBO: Identifiable, Codable, Hashable { 11 | let id: String 12 | let fullname: String 13 | let email: String 14 | let username: String 15 | var profileImageUrl: String? 16 | var bio: String? 17 | var link: String? 18 | var followers: [String] = [] 19 | var following: [String] = [] 20 | var isPrivateProfile: Bool 21 | var isFollowedByAuthUser: Bool 22 | } 23 | -------------------------------------------------------------------------------- /Threads/ViewModel/Core/EventBus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventBus.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | class EventBus { 12 | private var subject = PassthroughSubject() 13 | 14 | func subscribe() -> AnyPublisher { 15 | return subject.eraseToAnyPublisher() 16 | } 17 | 18 | func publish(event: Event) { 19 | subject.send(event) 20 | } 21 | } 22 | 23 | enum AppEvent { 24 | case loggedIn 25 | case loggedOut 26 | } 27 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/UserDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct UserDTO: Decodable, Hashable { 11 | var userId: String 12 | var email: String 13 | var fullname: String 14 | var username: String 15 | var bio: String? 16 | var link: String? 17 | var profileImageUrl: String? 18 | var followersCount: Int 19 | var followingCount: Int 20 | var followers: [String] 21 | var following: [String] 22 | var isPrivateProfile: Bool 23 | } 24 | -------------------------------------------------------------------------------- /Threads/DataSources/Extensions/CreateThreadDTO+Dictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadDTO+Dictionary.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | import Firebase 10 | 11 | internal extension CreateThreadDTO { 12 | func asDictionary() -> [String: Any] { 13 | return [ 14 | "threadId": threadId, 15 | "ownerUid": ownerUid, 16 | "caption": caption, 17 | "timestamp": Timestamp(date: Date()), 18 | "likes": 0, 19 | "likedBy": [String]() 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Threads/DataSources/Extensions/CreateUserDTO+Dictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateUserDTO+Dictionary.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension CreateUserDTO { 11 | func asDictionary() -> [String: Any] { 12 | return [ 13 | "userId": userId, 14 | "username": username, 15 | "email": email, 16 | "fullname": fullname, 17 | "followers": [String](), 18 | "following": [String](), 19 | "isPrivateProfile": false 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Threads/View/Core/ViewModifiers/ThreadsTextFieldModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadsTextFieldModifier.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThreadsTextFieldModifier: ViewModifier { 11 | func body(content: Content) -> some View { 12 | content 13 | .autocapitalization(.none) 14 | .font(.subheadline) 15 | .padding(12) 16 | .background(Color(.systemGray6)) 17 | .cornerRadius(10) 18 | .frame(maxWidth: .infinity) 19 | .padding(.horizontal, 24) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Threads/App/ThreadsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadsApp.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 16/7/24. 6 | // 7 | 8 | import SwiftUI 9 | import FirebaseCore 10 | 11 | 12 | class AppDelegate: NSObject, UIApplicationDelegate { 13 | func application(_ application: UIApplication, 14 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 15 | FirebaseApp.configure() 16 | 17 | return true 18 | } 19 | } 20 | 21 | @main 22 | struct ThreadsApp: App { 23 | 24 | @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate 25 | 26 | var body: some Scene { 27 | WindowGroup { 28 | MainView() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Threads/Core/Profile/ViewModel/ProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | class ProfileViewModel: ObservableObject { 12 | 13 | @Published var currentUser: User? 14 | 15 | private var cancellables = Set() 16 | 17 | init() { 18 | setupSubscribers() 19 | } 20 | 21 | private func setupSubscribers() { 22 | UserService.shared.$currentUser.sink{[weak self] user in 23 | self?.currentUser = user 24 | print("DEBUG: User in view model from combine is \(user)") 25 | }.store(in: &cancellables) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/BackgroundImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundImage.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundImage: View { 11 | let imageName: String 12 | 13 | var body: some View { 14 | GeometryReader { reader in 15 | ZStack { 16 | Image(imageName) 17 | .resizable() 18 | .scaledToFill() 19 | .edgesIgnoringSafeArea(.all) 20 | Color.black 21 | .opacity(0.7) 22 | .edgesIgnoringSafeArea(.all) 23 | }.frame(width: reader.size.width, height: reader.size.height, alignment: .center) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Threads/DataSources/Extensions/UpdateUserDTO+Dictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUserDTO+Dictionary.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal extension UpdateUserDTO { 11 | func asDictionary() -> [String: Any] { 12 | var dictionary: [String: Any] = [ 13 | "userId": userId, 14 | "fullname": fullname, 15 | "isPrivateProfile": isPrivateProfile 16 | ] 17 | if let bio = bio { 18 | dictionary["bio"] = bio 19 | } 20 | if let link = link { 21 | dictionary["link"] = link 22 | } 23 | if let profileImageUrl = profileImageUrl { 24 | dictionary["profileImageUrl"] = profileImageUrl 25 | } 26 | return dictionary 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Threads/View/Core/ViewModifiers/LoadingAndErrorOverlayModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingAndErrorOverlayModifier.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingAndErrorOverlayModifier: ViewModifier { 11 | @Binding var isLoading: Bool 12 | @Binding var errorMessage: String? 13 | 14 | var duration: Double = 3.0 // Duration before hiding the snackbar 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .overlay { 19 | ZStack { 20 | LoadingView() 21 | .opacity(isLoading ? 1 : 0) 22 | 23 | SnackbarView(message: $errorMessage, duration: duration) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Threads/Assets.xcassets/AppColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "161", 9 | "green" : "86", 10 | "red" : "207" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "161", 27 | "green" : "86", 28 | "red" : "207" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Threads/Mapper/Impl/CreateThreadMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadMapper.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A mapper responsible for converting `CreateThreadBO` business objects into `CreateThreadDTO` data transfer objects. 11 | struct CreateThreadMapper { 12 | 13 | /// Maps a `CreateThreadBO` object to a `CreateThreadDTO` object. 14 | /// 15 | /// - Parameter data: A `CreateThreadBO` instance containing the business logic representation of the thread. 16 | /// - Returns: A `CreateThreadDTO` instance containing the data transfer representation of the thread. 17 | func map(_ data: CreateThreadBO) -> CreateThreadDTO { 18 | return CreateThreadDTO( 19 | threadId: data.threadId, 20 | ownerUid: data.ownerUid, 21 | caption: data.caption 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Threads/ViewModel/FeedViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 1/8/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | @MainActor 13 | class FeedViewModel: BaseThreadsActionsViewModel { 14 | 15 | @Injected(\.fetchThreadsUseCase) private var fetchThreadsUseCase: FetchThreadsUseCase 16 | 17 | func fetchThreads() { 18 | executeAsyncTask({ 19 | return try await self.fetchThreadsUseCase.execute() 20 | }) { [weak self] (result: Result<[ThreadBO], Error>) in 21 | guard let self = self else { return } 22 | if case .success(let threads) = result { 23 | self.onFetchThreadsCompleted(threads: threads) 24 | } 25 | } 26 | } 27 | 28 | private func onFetchThreadsCompleted(threads: [ThreadBO]) { 29 | self.threads = threads 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Threads/View/Main/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainView: View { 11 | 12 | @StateObject var viewModel = MainViewModel() 13 | 14 | var body: some View { 15 | Group { 16 | NavigationView { 17 | if viewModel.isLoading { 18 | LoadingView() 19 | } else { 20 | if viewModel.hasSession { 21 | HomeView() 22 | } else { 23 | SignInView() 24 | } 25 | } 26 | } 27 | } 28 | .onAppear { 29 | viewModel.verifySession() 30 | } 31 | 32 | } 33 | } 34 | 35 | struct MainView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | MainView() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Threads/DataSources/DTO/NotificationDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationDTO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Data Transfer Object (DTO) for notifications. 11 | struct NotificationDTO: Decodable, Hashable { 12 | /// Unique identifier for the notification. 13 | let id: String 14 | 15 | /// Title of the notification 16 | let title: String 17 | 18 | /// Detailed message about the notification event. 19 | let message: String 20 | 21 | /// The ID of the user who owns this notification. 22 | let ownerUserId: String 23 | 24 | /// The ID of the user who triggered the notification event. 25 | let byUserId: String 26 | 27 | /// The type of the notification 28 | let type: String 29 | 30 | /// Timestamp when the notification was created. 31 | let timestamp: Date 32 | 33 | /// Boolean flag indicating if the notification has been read. 34 | var isRead: Bool 35 | } 36 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingView: View { 11 | 12 | var message: String? 13 | 14 | var body: some View { 15 | VStack { 16 | ProgressView() 17 | .progressViewStyle(CircularProgressViewStyle(tint: .white)) 18 | .scaleEffect(2) 19 | if let message = message { 20 | Text(message) 21 | .fontWeight(.heavy) 22 | .font(.system(size: 16)) 23 | .foregroundColor(.white) 24 | .padding(.top, 15) 25 | } 26 | } 27 | .frame(maxWidth: .infinity, maxHeight: .infinity) 28 | .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) 29 | } 30 | } 31 | struct LoadingView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | LoadingView() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Threads/ViewModel/CreateThreadViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 1/8/24. 6 | // 7 | 8 | import Factory 9 | import Foundation 10 | import Combine 11 | 12 | @MainActor 13 | class CreateThreadViewModel: BaseUserViewModel { 14 | 15 | @Injected(\.createThreadUseCase) private var createThreadUseCase: CreateThreadUseCase 16 | 17 | @Published var caption = "" 18 | @Published var threadUploaded = false 19 | 20 | func uploadThread() { 21 | executeAsyncTask({ 22 | return try await self.createThreadUseCase.execute(params: CreateThreadParams(caption: self.caption)) 23 | }) { [weak self] (result: Result) in 24 | guard let self = self else { return } 25 | if case .success(_) = result { 26 | self.onCreateThreadCompleted() 27 | } 28 | } 29 | } 30 | 31 | private func onCreateThreadCompleted() { 32 | self.threadUploaded = true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Threads/ViewModel/ForgotPasswordViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForgotPasswordViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class ForgotPasswordViewModel: BaseViewModel { 14 | 15 | @Injected(\.forgotPasswordUseCase) private var forgotPasswordUseCase: ForgotPasswordUseCase 16 | 17 | @Published var resetLinkSent: Bool = false 18 | @Published var email = "" 19 | 20 | func sendResetLink() { 21 | executeAsyncTask({ 22 | return try await self.forgotPasswordUseCase.execute(params: ForgotPasswordParams(email: self.email)) 23 | }) { [weak self] (result: Result) in 24 | guard let self = self else { return } 25 | if case .success(_) = result { 26 | self.onSendResetLinkCompleted() 27 | } 28 | } 29 | } 30 | 31 | private func onSendResetLinkCompleted() { 32 | self.resetLinkSent = true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Threads/ViewModel/SignInViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class SignInViewModel: BaseViewModel { 14 | 15 | @Injected(\.signInUseCase) private var signInUseCase: SignInUseCase 16 | @Injected(\.eventBus) internal var appEventBus: EventBus 17 | 18 | @Published var email = "" 19 | @Published var password = "" 20 | 21 | func signIn() { 22 | executeAsyncTask({ 23 | return try await self.signInUseCase.execute(params: SignInParams(email: self.email, password: self.password)) 24 | }) { [weak self] (result: Result) in 25 | guard let self = self else { return } 26 | if case .success(_) = result { 27 | self.onSignInSuccess() 28 | } 29 | } 30 | } 31 | 32 | private func onSignInSuccess() { 33 | self.appEventBus.publish(event: .loggedIn) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Threads/UseCases/GetSuggestionsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetSuggestionsUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum GetSuggestionsError: Error { 11 | case fetchFailed 12 | } 13 | 14 | /// An entity responsible for fetching user suggestions. 15 | struct GetSuggestionsUseCase { 16 | let userRepository: UserProfileRepository 17 | let authRepository: AuthenticationRepository 18 | 19 | /// Executes the process of fetching user suggestions asynchronously. 20 | /// - Returns: An array of `User` objects representing the fetched user suggestions. 21 | /// - Throws: An error if the user suggestions fetching operation fails. 22 | func execute() async throws -> [UserBO] { 23 | guard let userId = try await authRepository.getCurrentUserId() else { 24 | throw GetSuggestionsError.fetchFailed 25 | } 26 | do { 27 | return try await userRepository.getSuggestions(authUserId: userId) 28 | } catch { 29 | throw GetSuggestionsError.fetchFailed 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sergio Sánchez Sánchez 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 | -------------------------------------------------------------------------------- /Threads/UseCases/GetCurrentUserUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetCurrentUserUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum GetCurrentUserError: Error { 11 | case userNotFound 12 | } 13 | 14 | /// An entity responsible for retrieving the current user's information. 15 | struct GetCurrentUserUseCase { 16 | 17 | let authRepository: AuthenticationRepository 18 | let userRepository: UserProfileRepository 19 | 20 | /// Executes the process of retrieving the information of the current user asynchronously. 21 | /// - Returns: The information of the current user. 22 | /// - Throws: An error if the user information retrieval operation fails, including `userNotFound` if the current user is not found. 23 | func execute() async throws -> UserBO { 24 | guard let userId = try await authRepository.getCurrentUserId() else { 25 | throw GetCurrentUserError.userNotFound 26 | } 27 | do { 28 | return try await userRepository.getUser(userId: userId) 29 | } catch { 30 | throw GetCurrentUserError.userNotFound 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Threads/DataSources/StorageFilesDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageFilesDataSource.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An enumeration representing possible errors that may occur during storage file operations. 11 | enum StorageFilesError: Error { 12 | /// Error indicating that the upload operation failed. 13 | case uploadFailed(message: String) 14 | } 15 | 16 | /// An enumeration representing the type of upload operation. 17 | enum UploadType { 18 | /// Upload operation for profile images. 19 | case profile 20 | /// Upload operation for post images. 21 | case post 22 | } 23 | 24 | /// A protocol defining storage file operations. 25 | protocol StorageFilesDataSource { 26 | /// Uploads the provided image data asynchronously. 27 | /// - Parameters: 28 | /// - imageData: The data of the image to upload. 29 | /// - type: The type of upload operation, either `.profile` or `.post`. 30 | /// - Returns: A string representing the URL of the uploaded image. 31 | /// - Throws: An error in case of failure. 32 | func uploadImage(imageData: Data, type: UploadType) async throws -> String 33 | } 34 | -------------------------------------------------------------------------------- /Threads/UseCases/FetchThreadsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchThreadsUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FetchThreadsError: Error { 11 | case fetchFailed 12 | } 13 | 14 | /// An entity responsible for fetching threads for the home screen. 15 | struct FetchThreadsUseCase { 16 | let threadsRepository: ThreadsRepository 17 | let authRepository: AuthenticationRepository 18 | 19 | /// Executes the fetch operation for threads to display on the home screen asynchronously. 20 | /// - Returns: A list of `ThreadBO` objects representing the threads for the home screen. 21 | /// - Throws: An error if the fetch operation fails. 22 | func execute() async throws -> [ThreadBO] { 23 | // Get the current user ID from the authentication repository. 24 | guard let userId = try await authRepository.getCurrentUserId() else { 25 | throw FetchThreadsError.fetchFailed 26 | } 27 | do { 28 | // Fetch all threads from the threads repository. 29 | return try await threadsRepository.fetchThreads() 30 | } catch { 31 | throw FetchThreadsError.fetchFailed 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Threads/ViewModel/Core/BaseUserViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseUserViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | class BaseUserViewModel: BaseViewModel { 12 | 13 | @Injected(\.getCurrentUserUseCase) private var getCurrentUserUseCase: GetCurrentUserUseCase 14 | 15 | @Published var authUserId: String = "" 16 | @Published var authUserFullName: String = "" 17 | @Published var authUserUsername: String = "" 18 | @Published var authUserProfileImageUrl: String = "" 19 | 20 | func loadCurrentUser() { 21 | executeAsyncTask { 22 | return try await self.getCurrentUserUseCase.execute() 23 | } completion: { [weak self] result in 24 | guard let self = self else { return } 25 | if case .success(let user) = result { 26 | self.onCurrentUserLoaded(user: user) 27 | } 28 | } 29 | } 30 | 31 | internal func onCurrentUserLoaded(user: UserBO) { 32 | self.authUserId = user.id 33 | self.authUserFullName = user.fullname 34 | self.authUserUsername = user.username 35 | self.authUserProfileImageUrl = user.profileImageUrl ?? "" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Threads/UseCases/VerifySessionUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerifySessionUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum VerifySessionError: Error { 11 | case invalidSession 12 | } 13 | 14 | /// An entity responsible for verifying the current user session. 15 | struct VerifySessionUseCase { 16 | let authRepository: AuthenticationRepository 17 | let userProfileRepository: UserProfileRepository 18 | 19 | /// Executes the session verification asynchronously. 20 | /// - Returns: A boolean indicating whether the session is valid. 21 | /// - Throws: An error if the session is invalid or if an error occurs during the verification process. 22 | func execute() async throws -> Bool { 23 | guard let userId = try await authRepository.getCurrentUserId() else { 24 | throw VerifySessionError.invalidSession 25 | } 26 | var userData: UserBO? = nil 27 | do { 28 | userData = try await userProfileRepository.getUser(userId: userId) 29 | } catch { 30 | try await authRepository.signOut() 31 | throw VerifySessionError.invalidSession 32 | } 33 | return userData != nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Threads/UseCases/DeleteNotificationUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteNotificationUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum DeleteNotificationError: Error { 11 | case deleteFailed 12 | } 13 | 14 | struct DeleteNotificationParams { 15 | var notificationId: String 16 | } 17 | 18 | /// An entity responsible for deleting a notification. 19 | struct DeleteNotificationUseCase { 20 | let notificationsRepository: NotificationsRepository 21 | 22 | /// Executes the delete operation for a notification asynchronously. 23 | /// - Parameter params: include the ID of the notification to be deleted. 24 | /// - Returns: A boolean indicating whether the delete operation was successful. 25 | /// - Throws: An error if the delete operation fails. 26 | func execute(params: DeleteNotificationParams) async throws -> Bool { 27 | do { 28 | let success = try await notificationsRepository.deleteNotification(notificationId: params.notificationId) 29 | if !success { 30 | throw DeleteNotificationError.deleteFailed 31 | } 32 | return success 33 | } catch { 34 | throw DeleteNotificationError.deleteFailed 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Threads/ViewModel/UserContentListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserContentListViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 6/8/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class UserContentListViewModel: BaseThreadsActionsViewModel { 14 | 15 | @Injected(\.fetchThreadsByUserUseCase) private var fetchThreadsByUserUseCase: FetchThreadsByUserUseCase 16 | 17 | @Published var selectedFilter: ProfileThreadFilter = .threads 18 | 19 | private var user: UserBO? = nil 20 | 21 | func loadUser(user: UserBO) { 22 | self.user = user 23 | } 24 | 25 | func fetchUserThreads() { 26 | if let userId = user?.id { 27 | executeAsyncTask({ 28 | return try await self.fetchThreadsByUserUseCase.execute(params: FetchThreadsByUserParams(userId: userId)) 29 | }) { [weak self] (result: Result<[ThreadBO], Error>) in 30 | guard let self = self else { return } 31 | if case .success(let threads) = result { 32 | self.onFetchThreadsByUserCompleted(threads: threads) 33 | } 34 | } 35 | } 36 | } 37 | 38 | private func onFetchThreadsByUserCompleted(threads: [ThreadBO]) { 39 | self.threads = threads 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Threads/ViewModel/SignUpViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignUpViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class SignUpViewModel: BaseViewModel { 14 | 15 | @Injected(\.signUpUseCase) private var signUpUseCase: SignUpUseCase 16 | @Injected(\.eventBus) internal var appEventBus: EventBus 17 | 18 | @Published var email = "" 19 | @Published var password = "" 20 | @Published var repeatPassword = "" 21 | @Published var fullname = "" 22 | @Published var username = "" 23 | 24 | func signUp() { 25 | executeAsyncTask({ 26 | return try await self.signUpUseCase.execute(params: SignUpParams( 27 | username: self.username, 28 | email: self.email, 29 | password: self.password, 30 | repeatPassword: self.repeatPassword, 31 | fullname: self.fullname 32 | )) 33 | }) { [weak self] (result: Result) in 34 | guard let self = self else { return } 35 | if case .success(_) = result { 36 | self.onSignUpSuccess() 37 | } 38 | } 39 | } 40 | 41 | private func onSignUpSuccess() { 42 | self.appEventBus.publish(event: .loggedIn) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Threads/UseCases/FetchOwnThreadsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchOwnThreadsUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur when fetching threads for the authenticated user. 11 | enum FetchOwnThreadsError: Error { 12 | case fetchFailed 13 | } 14 | 15 | /// An entity responsible for fetching threads owned by the current user. 16 | struct FetchOwnThreadsUseCase { 17 | let threadsRepository: ThreadsRepository 18 | let authRepository: AuthenticationRepository 19 | 20 | /// Executes the process of fetching threads owned by the current user asynchronously. 21 | /// - Returns: An array of `ThreadBO` objects representing the threads owned by the authenticated user. 22 | /// - Throws: An error if the thread fetching operation fails, including `fetchFailed` if the operation fails. 23 | func execute() async throws -> [ThreadBO] { 24 | guard let userId = try await authRepository.getCurrentUserId() else { 25 | throw FetchOwnThreadsError.fetchFailed 26 | } 27 | do { 28 | // Fetch threads owned by the authenticated user from the repository. 29 | let ownThreads = try await threadsRepository.fetchUserThreads(userId: userId) 30 | return ownThreads 31 | } catch { 32 | throw FetchOwnThreadsError.fetchFailed 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Threads/Extensions/Timestamp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Timestamp.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 6/8/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | func timestampString() -> String { 12 | let formatter = DateComponentsFormatter() 13 | formatter.allowedUnits = [.second, .minute, .hour, .day, .weekOfMonth] 14 | formatter.unitsStyle = .abbreviated 15 | return formatter.string(from: self, to: Date()) ?? "" 16 | } 17 | 18 | func timeAgoDisplay() -> String { 19 | let calendar = Calendar.current 20 | let now = Date() 21 | let components = calendar.dateComponents([.minute, .hour, .day, .weekOfYear, .month, .year], from: self, to: now) 22 | 23 | if let year = components.year, year >= 1 { 24 | return "\(year)y ago" 25 | } 26 | if let month = components.month, month >= 1 { 27 | return "\(month)m ago" 28 | } 29 | if let week = components.weekOfYear, week >= 1 { 30 | return "\(week)w ago" 31 | } 32 | if let day = components.day, day >= 1 { 33 | return "\(day)d ago" 34 | } 35 | if let hour = components.hour, hour >= 1 { 36 | return "\(hour)h ago" 37 | } 38 | if let minute = components.minute, minute >= 1 { 39 | return "\(minute) minute\(minute > 1 ? "s" : "") ago" 40 | } 41 | return "Just now" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/SnackbarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnackbarView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SnackbarView: View { 11 | @Binding var message: String? 12 | var duration: Double = 5.0 // Duration before hiding the snackbar 13 | 14 | var body: some View { 15 | if let message = message, !message.isEmpty { 16 | VStack { 17 | Spacer() 18 | Text(message) 19 | .font(.subheadline) 20 | .fontWeight(.semibold) 21 | .foregroundColor(.white) 22 | .padding() 23 | .frame(maxWidth: .infinity) 24 | .background(Color.red) 25 | .cornerRadius(8) 26 | .shadow(color: Color.black.opacity(0.4), radius: 8, x: 0, y: 4) 27 | .transition(.move(edge: .bottom)) 28 | .onAppear { 29 | // Hide the snackbar after the given duration 30 | DispatchQueue.main.asyncAfter(deadline: .now() + duration) { 31 | withAnimation { 32 | self.message = nil 33 | } 34 | } 35 | } 36 | } 37 | .padding(.horizontal, 30) 38 | .padding(.bottom, 20) 39 | .animation(.easeInOut, value: message) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Threads/Extensions/PreviewProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewProvider.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension PreviewProvider { 11 | 12 | static var dev: DeveloperPreview { 13 | return DeveloperPreview.shared 14 | } 15 | 16 | } 17 | 18 | 19 | class DeveloperPreview { 20 | static let shared = DeveloperPreview() 21 | 22 | let user = UserBO(id: NSUUID().uuidString, fullname: "Sergio Sánchez", email: "dreamsoftware92@gmail.com", username: "ssanchez", isPrivateProfile: false, isFollowedByAuthUser: false) 23 | 24 | let thread = ThreadBO(threadId: "123456567", ownerUid: "123", caption: "This is a test thread", timestamp: Date(), likes: 0, user: UserBO(id: NSUUID().uuidString, fullname: "Sergio Sánchez", email: "dreamsoftware92@gmail.com", username: "ssanchez", isPrivateProfile: false, isFollowedByAuthUser: false)) 25 | 26 | let notification = NotificationBO( 27 | id: "notif123", 28 | title: "New Follower", 29 | ownerUser: UserBO(id: NSUUID().uuidString, fullname: "Sergio Sánchez", email: "dreamsoftware92@gmail.com", username: "ssanchez", isPrivateProfile: false, isFollowedByAuthUser: false), 30 | byUser: UserBO(id: NSUUID().uuidString, fullname: "Sergio Sánchez", email: "dreamsoftware92@gmail.com", username: "ssanchez", isPrivateProfile: false, isFollowedByAuthUser: false), 31 | type: .follow, 32 | message: "Jane Smith started following you.", 33 | timestamp: Date(), 34 | isRead: false 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/ShareActivityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareActivityView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 21/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | 11 | /// A custom view that wraps `UIActivityViewController` from UIKit to present a share sheet. 12 | /// It is used to share content such as text, images, URLs, etc. 13 | /// - `activityItems`: A list of items to be shared (e.g., strings, images, URLs). 14 | /// This view conforms to `UIViewControllerRepresentable` to integrate UIKit components in SwiftUI. 15 | struct ShareActivityView: UIViewControllerRepresentable { 16 | // The items to share, for example, the thread's caption or other data. 17 | var activityItems: [Any] 18 | 19 | // Creates and returns the `UIActivityViewController` when the view is created. 20 | func makeUIViewController(context: Context) -> UIActivityViewController { 21 | // Initialize the share sheet (UIActivityViewController) with the items to share. 22 | let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) 23 | return activityViewController 24 | } 25 | 26 | // Update the UIActivityViewController when SwiftUI's view updates. 27 | // This is required by the `UIViewControllerRepresentable` protocol, but we do not need to do anything here for this case. 28 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { 29 | // Nothing to update in this case. The activity view controller will automatically update when new data is passed. 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Threads/UseCases/FetchNotificationsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchNotificationsUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FetchNotificationsError: Error { 11 | case fetchFailed 12 | case markAsReadFailed 13 | } 14 | 15 | /// An entity responsible for fetching notifications and marking them as read. 16 | struct FetchNotificationsUseCase { 17 | let notificationsRepository: NotificationsRepository 18 | let authRepository: AuthenticationRepository 19 | 20 | /// Executes the fetch operation for notifications and marks them as read asynchronously. 21 | /// - Returns: A list of `NotificationBO` objects representing the notifications for the current user. 22 | /// - Throws: An error if the fetch or mark as read operations fail. 23 | func execute() async throws -> [NotificationBO] { 24 | guard let userId = try await authRepository.getCurrentUserId() else { 25 | throw FetchNotificationsError.fetchFailed 26 | } 27 | 28 | do { 29 | let notifications = try await notificationsRepository.fetchUserNotifications(userId: userId) 30 | for notification in notifications { 31 | let success = try await notificationsRepository.markNotificationAsRead(notificationId: notification.id) 32 | if !success { 33 | throw FetchNotificationsError.markAsReadFailed 34 | } 35 | } 36 | return notifications 37 | } catch { 38 | throw FetchNotificationsError.fetchFailed 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/CircularProfileImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularProfileImageView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | import Kingfisher 10 | 11 | enum ProfileImageSize { 12 | case xxSmall 13 | case xSmall 14 | case small 15 | case medium 16 | case large 17 | case xLarge 18 | 19 | var dimension: CGFloat { 20 | switch self { 21 | case .xxSmall: return 28 22 | case .xSmall: return 32 23 | case .small: return 40 24 | case .medium: return 48 25 | case .large: return 64 26 | case .xLarge: return 80 27 | } 28 | } 29 | } 30 | 31 | 32 | struct CircularProfileImageView: View { 33 | var profileImageUrl: String? 34 | let size: ProfileImageSize 35 | 36 | var body: some View { 37 | if let imageUrl = profileImageUrl, !imageUrl.isEmpty { 38 | KFImage(URL(string: imageUrl)) 39 | .resizable() 40 | .scaledToFill() 41 | .frame(width: size.dimension, height: size.dimension) 42 | .clipShape(Circle()) 43 | } else { 44 | Image(systemName: "person.circle.fill") 45 | .resizable() 46 | .scaledToFill() 47 | .frame(width: size.dimension, height: size.dimension) 48 | .clipShape(Circle()) 49 | .foregroundColor(Color(.systemGray4)) 50 | } 51 | } 52 | } 53 | 54 | struct CircularProfileImageView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | CircularProfileImageView(profileImageUrl: dev.user.profileImageUrl, size: .medium) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Threads/ViewModel/Core/BaseViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class BaseViewModel: ObservableObject { 11 | 12 | @Published var isLoading: Bool = false 13 | @Published var errorMessage: String? = nil 14 | 15 | internal func onLoading() { 16 | updateUI { vm in 17 | vm.isLoading = true 18 | } 19 | } 20 | 21 | internal func onIddle() { 22 | updateUI { vm in 23 | vm.isLoading = false 24 | } 25 | } 26 | 27 | internal func handleError(error: Error) { 28 | print(error.localizedDescription) 29 | updateUI { vm in 30 | vm.isLoading = false 31 | vm.errorMessage = error.localizedDescription 32 | } 33 | } 34 | 35 | 36 | internal func updateUI(with updates: @escaping (ViewModelType) -> Void) { 37 | DispatchQueue.main.async { [weak self] in 38 | guard let self = self else { return } 39 | if let viewModel = self as? ViewModelType { 40 | updates(viewModel) 41 | } 42 | } 43 | } 44 | 45 | internal func executeAsyncTask(_ task: @escaping () async throws -> T, completion: @escaping (Result) -> Void) { 46 | Task { 47 | onLoading() 48 | do { 49 | let result = try await task() 50 | DispatchQueue.main.async { 51 | completion(.success(result)) 52 | } 53 | } catch { 54 | handleError(error: error) 55 | DispatchQueue.main.async { 56 | completion(.failure(error)) 57 | } 58 | } 59 | onIddle() 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Threads/ViewModel/ActivityViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 23/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | import Foundation 11 | import Factory 12 | import Combine 13 | 14 | @MainActor 15 | class ActivityViewModel: BaseViewModel { 16 | 17 | @Injected(\.fetchNotificationsUseCase) private var fetchNotificationsUseCase: FetchNotificationsUseCase 18 | @Injected(\.deleteNotificationUseCase) private var deleteNotificationUseCase: DeleteNotificationUseCase 19 | 20 | @Published var notifications: [NotificationBO] = [] 21 | 22 | func fetchData() { 23 | executeAsyncTask({ 24 | return try await self.fetchNotificationsUseCase.execute() 25 | }) { [weak self] (result: Result<[NotificationBO], Error>) in 26 | guard let self = self else { return } 27 | if case .success(let notifications) = result { 28 | self.onFetchNotificationsCompleted(notifications: notifications) 29 | } 30 | } 31 | } 32 | 33 | func deleteNotification(id: String) { 34 | executeAsyncTask({ 35 | return try await self.deleteNotificationUseCase.execute(params: DeleteNotificationParams(notificationId: id)) 36 | }) { [weak self] (result: Result) in 37 | guard let self = self else { return } 38 | if case .success(_) = result { 39 | self.onDeleteNotificationCompleted(notificationId: id) 40 | } 41 | } 42 | } 43 | 44 | private func onDeleteNotificationCompleted(notificationId: String) { 45 | self.notifications = self.notifications.filter { $0.id != notificationId } 46 | } 47 | 48 | private func onFetchNotificationsCompleted(notifications: [NotificationBO]) { 49 | self.notifications = notifications 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Threads/DataSources/Impl/FirestoreStorageFilesDataSourceImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirestoreStorageFilesDataSourceImpl.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | import FirebaseStorage 10 | 11 | /// A data source responsible for managing file uploads to Firestore Storage. 12 | internal class FirestoreStorageFilesDataSourceImpl: StorageFilesDataSource { 13 | 14 | /// The path for storing profile images in Firestore Storage. 15 | private let profileImagesPath = "threads_profile_images" 16 | /// The path for storing post images in Firestore Storage. 17 | private let postImagesPath = "threads_post_images" 18 | 19 | /// Uploads an image to Firestore Storage asynchronously. 20 | /// 21 | /// - Parameters: 22 | /// - imageData: The data of the image to be uploaded. 23 | /// - type: The type of upload (profile image or post image). 24 | /// - Returns: A string representing the URL of the uploaded image. 25 | /// - Throws: An error if the upload operation fails. 26 | func uploadImage(imageData: Data, type: UploadType) async throws -> String { 27 | let filename = NSUUID().uuidString 28 | let folderPath: String 29 | switch type { 30 | case .profile: 31 | folderPath = profileImagesPath 32 | case .post: 33 | folderPath = postImagesPath 34 | } 35 | do { 36 | let ref = Storage.storage().reference(withPath: "/\(folderPath)/\(filename)") 37 | let _ = try await ref.putDataAsync(imageData) 38 | let url = try await ref.downloadURL().absoluteString 39 | return url 40 | } catch { 41 | print(error) 42 | throw StorageFilesError.uploadFailed(message: "Failed to upload image: \(error.localizedDescription)") 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Threads/DataSources/NotificationsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsDataSource.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // Enum representing errors that can occur in the `NotificationsDataSource`. 11 | enum NotificationsDataSourceError: Error { 12 | /// Error indicating that the notification was not found. 13 | case notificationNotFound 14 | /// Error indicating that fetching notifications for a specific user failed. 15 | case fetchUserNotificationsFailed 16 | /// Error indicating that the notification mark-as-read operation failed. 17 | case markAsReadFailed 18 | /// Error indicating that deleting a notification failed. 19 | case deleteNotificationFailed 20 | } 21 | 22 | // Protocol defining data source operations for notifications. 23 | protocol NotificationsDataSource { 24 | 25 | /// Fetches notifications for a specific user. 26 | /// - Parameter uid: The user ID whose notifications are to be fetched. 27 | /// - Returns: An array of `NotificationDTO` objects. 28 | /// - Throws: An error if fetching fails. 29 | func fetchUserNotifications(uid: String) async throws -> [NotificationDTO] 30 | 31 | /// Marks a specific notification as read. 32 | /// - Parameter notificationId: The ID of the notification to mark as read. 33 | /// - Throws: An error if the operation fails. 34 | /// - Returns: A boolean indicating if the operation was successful. 35 | func markNotificationAsRead(notificationId: String) async throws -> Bool 36 | 37 | /// Deletes a specific notification. 38 | /// - Parameter notificationId: The ID of the notification to be deleted. 39 | /// - Throws: An error if the operation fails. 40 | /// - Returns: A boolean indicating if the operation was successful. 41 | func deleteNotification(notificationId: String) async throws -> Bool 42 | } 43 | 44 | -------------------------------------------------------------------------------- /Threads/ViewModel/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | class MainViewModel: BaseViewModel { 13 | 14 | @Published var hasSession = false 15 | 16 | @Injected(\.verifySessionUseCase) private var verifySessionUseCase: VerifySessionUseCase 17 | @Injected(\.eventBus) internal var appEventBus: EventBus 18 | 19 | private var cancellables = Set() 20 | 21 | override init() { 22 | super.init() 23 | setupSubscriptions() 24 | } 25 | 26 | func verifySession() { 27 | executeAsyncTask({ 28 | return try await self.verifySessionUseCase.execute() 29 | }) { [weak self] (result: Result) in 30 | guard let self = self else { return } 31 | switch result { 32 | case .success(let hasSession): 33 | if hasSession { 34 | self.onActiveSessionFound() 35 | } else { 36 | self.onNotActiveSessionFound() 37 | } 38 | case .failure: 39 | self.onNotActiveSessionFound() 40 | } 41 | } 42 | } 43 | 44 | private func onNotActiveSessionFound() { 45 | self.hasSession = false 46 | } 47 | 48 | private func onActiveSessionFound() { 49 | self.hasSession = true 50 | } 51 | 52 | private func setupSubscriptions() { 53 | appEventBus.subscribe() 54 | .sink { [weak self] event in 55 | if event == .loggedOut || event == .loggedIn { 56 | self?.verifySession() 57 | } 58 | } 59 | .store(in: &cancellables) 60 | } 61 | 62 | deinit { 63 | cancellables.removeAll() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Threads/Repositories/NotificationsRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsRepository.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur in the `NotificationsRepository`. 11 | enum NotificationsRepositoryError: Error { 12 | case userNotificationsFetchFailed(message: String) // Error when fetching a user's notifications fails 13 | case markAsReadFailed(message: String) // Error when marking a notification as read fails 14 | case deleteNotificationFailed(message: String) // Error when deleting a notification fails 15 | case unknown(message: String) // Generic error for other unspecified failures 16 | } 17 | 18 | /// Protocol defining operations for managing notifications. 19 | protocol NotificationsRepository { 20 | 21 | /// Fetches notifications for a specific user asynchronously. 22 | /// - Parameter userId: The ID of the user whose notifications to fetch. 23 | /// - Returns: An array of `NotificationBO` objects. 24 | /// - Throws: An error if the operation fails. 25 | func fetchUserNotifications(userId: String) async throws -> [NotificationBO] 26 | 27 | /// Marks a specific notification as read asynchronously. 28 | /// - Parameter notificationId: The ID of the notification to mark as read. 29 | /// - Returns: A boolean indicating if the operation was successful. 30 | /// - Throws: An error if the operation fails. 31 | func markNotificationAsRead(notificationId: String) async throws -> Bool 32 | 33 | /// Deletes a specific notification asynchronously. 34 | /// - Parameter notificationId: The ID of the notification to be deleted. 35 | /// - Returns: A boolean indicating if the operation was successful. 36 | /// - Throws: An error if the operation fails. 37 | func deleteNotification(notificationId: String) async throws -> Bool 38 | } 39 | -------------------------------------------------------------------------------- /Threads/Model/NotificationBO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationBO.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the type of notification event. 11 | enum NotificationType { 12 | case follow // Notification for being followed by another user. 13 | case repost // Notification for a thread being reposted. 14 | case like // Notification for a thread being liked. 15 | case comment // Notification for a thread being commented on. 16 | 17 | /// Initializes a `NotificationType` from a string. 18 | /// - Parameter rawValue: The string representation of the notification type. 19 | /// - Throws: An error if the string does not match any notification type. 20 | init(rawValue: String) { 21 | switch rawValue.lowercased() { 22 | case "follow": 23 | self = .follow 24 | case "repost": 25 | self = .repost 26 | case "like": 27 | self = .like 28 | case "comment": 29 | self = .comment 30 | default: 31 | self = .follow 32 | } 33 | } 34 | } 35 | 36 | /// Business Object representing a Notification. 37 | struct NotificationBO: Identifiable { 38 | /// Unique identifier for the notification. 39 | let id: String 40 | 41 | /// Title of the notification 42 | let title: String 43 | 44 | /// The user who owns this notification. 45 | let ownerUser: UserBO 46 | 47 | /// The user who triggered the notification event. 48 | let byUser: UserBO 49 | 50 | /// The type of the notification. 51 | let type: NotificationType 52 | 53 | /// Detailed message about the notification event. 54 | let message: String 55 | 56 | /// Timestamp when the notification was created. 57 | let timestamp: Date 58 | 59 | /// Boolean flag indicating if the notification has been read. 60 | var isRead: Bool 61 | } 62 | -------------------------------------------------------------------------------- /Threads/ViewModel/ConnectionsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionsViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 23/11/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | 13 | class ConnectionsViewModel: BaseViewModel { 14 | 15 | @Injected(\.followUserUseCase) private var followUserUseCase: FollowUserUseCase 16 | @Injected(\.fetchUserConnectionsUseCase) private var fetchUserConnectionsUseCase: FetchUserConnectionsUseCase 17 | 18 | @Published var users: [UserBO] = [] 19 | 20 | func fetchData(userId: String, connectionType: UserConnectionType) { 21 | executeAsyncTask { 22 | return try await self.fetchUserConnectionsUseCase.execute(params: FetchUserConnectionsParams(userId: userId, connectionType: connectionType)) 23 | } completion: { [weak self] result in 24 | guard let self = self else { return } 25 | if case .success(let users) = result { 26 | self.onFetchUserConnectionsCompleted(users: users) 27 | } 28 | } 29 | } 30 | 31 | func followUser(userId: String) { 32 | executeAsyncTask { 33 | return try await self.followUserUseCase.execute(params: FollowUserParams(userId: userId)) 34 | } completion: { [weak self] result in 35 | guard let self = self else { return } 36 | if case .success(_) = result { 37 | self.onFollowUserCompleted(userId: userId) 38 | } 39 | } 40 | } 41 | 42 | private func onFetchUserConnectionsCompleted(users: [UserBO]) { 43 | self.users = users 44 | } 45 | 46 | private func onFollowUserCompleted(userId: String) { 47 | // Find the index of the user to modify 48 | if let index = users.firstIndex(where: { $0.id == userId }) { 49 | users[index].isFollowedByAuthUser = !users[index].isFollowedByAuthUser 50 | // Reassign the array to trigger the UI update 51 | self.users = users 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Threads/UseCases/FetchThreadsByUserUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchThreadsByUserUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur in the `FetchThreadsByUserUseCase`. 11 | /// - `fetchFailed`: Indicates that fetching threads by user failed. 12 | enum FetchThreadsByUserError: Error { 13 | case fetchFailed 14 | } 15 | 16 | /// Parameters required to fetch threads of a specific user. 17 | /// - `userId`: The unique identifier of the user whose threads are to be fetched. 18 | struct FetchThreadsByUserParams { 19 | var userId: String 20 | } 21 | 22 | /// Use case responsible for fetching threads by a specific user from the repository. 23 | /// - It handles the business logic of fetching all threads created by a particular user. 24 | struct FetchThreadsByUserUseCase { 25 | 26 | /// The repository used to fetch threads from the data source. 27 | let threadsRepository: ThreadsRepository 28 | 29 | /// Executes the process of fetching threads for a specific user. 30 | /// 31 | /// This method makes a call to the repository to fetch the threads for the user identified by `userId`. 32 | /// If the fetching process fails, it throws a `FetchThreadsByUserError.fetchFailed` error. 33 | /// 34 | /// - Parameter params: The parameters needed for fetching the threads. Specifically, `userId` is required. 35 | /// - Returns: A list of `ThreadBO` objects representing the threads created by the user. 36 | /// - Throws: `FetchThreadsByUserError.fetchFailed` if fetching the threads fails due to a data source error. 37 | func execute(params: FetchThreadsByUserParams) async throws -> [ThreadBO] { 38 | do { 39 | // Fetching threads from the repository 40 | return try await threadsRepository.fetchUserThreads(userId: params.userId) 41 | } catch { 42 | // If fetching fails, propagate the error 43 | throw FetchThreadsByUserError.fetchFailed 44 | } 45 | } 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /Threads/ViewModel/EditProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditProfileViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 21/7/24. 6 | // 7 | 8 | import PhotosUI 9 | import SwiftUI 10 | import Factory 11 | import Combine 12 | 13 | class EditProfileViewModel: BaseUserViewModel { 14 | 15 | @Injected(\.updateUserUseCase) private var updateUserUseCase: UpdateUserUseCase 16 | 17 | @Published var selectedItem: PhotosPickerItem? { 18 | didSet { 19 | Task { await loadImage() } 20 | } 21 | } 22 | @Published var bio = "" 23 | @Published var link = "" 24 | @Published var isPrivateProfile = false 25 | @Published var profileImage: Image? 26 | @Published var userProfileUpdated: Bool = false 27 | 28 | private var uiImageData: Data? 29 | 30 | func onUpdateProfile() { 31 | executeAsyncTask({ 32 | return try await self.updateUserUseCase.execute(params: UpdateUserParams(fullname: self.authUserFullName, bio: self.bio, link: self.link, selectedImage: self.uiImageData, isPrivateProfile: self.isPrivateProfile)) 33 | }) { [weak self] (result: Result) in 34 | guard let self = self else { return } 35 | if case .success(let user) = result { 36 | self.onUserUpdated(user: user) 37 | } 38 | } 39 | } 40 | 41 | override func onCurrentUserLoaded(user: UserBO) { 42 | super.onCurrentUserLoaded(user: user) 43 | self.bio = user.bio ?? "" 44 | self.link = user.link ?? "" 45 | self.isPrivateProfile = user.isPrivateProfile 46 | } 47 | 48 | @MainActor 49 | private func loadImage() async { 50 | guard let item = selectedItem else { return } 51 | guard let data = try? await item.loadTransferable(type: Data.self) else { return } 52 | guard let uiImage = UIImage(data: data) else { return } 53 | self.uiImageData = data 54 | self.profileImage = Image(uiImage: uiImage) 55 | } 56 | 57 | private func onUserUpdated(user: UserBO) { 58 | self.onCurrentUserLoaded(user: user) 59 | self.userProfileUpdated = true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Threads/UseCases/SearchUsersUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchUsersUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur during the search for users. 11 | enum SearchUsersError: Error { 12 | /// Error indicating that the user search operation failed. 13 | /// - Parameter message: A detailed message describing the failure. 14 | case searchFailed(message: String) 15 | } 16 | 17 | /// Parameters required for executing a user search operation. 18 | struct SearchUsersParams { 19 | /// The search term used to find users. 20 | let term: String 21 | } 22 | 23 | /// Use case responsible for searching users based on a provided term. 24 | /// 25 | /// The `SearchUsersUseCase` orchestrates the user search operation by interacting 26 | /// with the `UserProfileRepository` to find users whose data matches the given search term. 27 | /// It also handles potential errors during the search process. 28 | struct SearchUsersUseCase { 29 | /// The repository responsible for user profile-related operations. 30 | let userRepository: UserProfileRepository 31 | 32 | /// The repository responsible for authentication-related operations. 33 | let authRepository: AuthenticationRepository 34 | 35 | /// Executes the user search operation. 36 | /// 37 | /// - Parameter params: An instance of `SearchUsersParams` containing the search term. 38 | /// - Returns: An array of `UserBO` objects representing the users found during the search. 39 | /// - Throws: 40 | /// - `SearchUsersError.searchFailed`: If the search operation fails, providing a detailed error message. 41 | func execute(params: SearchUsersParams) async throws -> [UserBO] { 42 | do { 43 | // Perform the search using the user repository and return the results. 44 | return try await userRepository.searchUsers(searchTerm: params.term) 45 | } catch { 46 | // Wrap any thrown error in a `SearchUsersError.searchFailed` with a descriptive message. 47 | throw SearchUsersError.searchFailed(message: error.localizedDescription) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Threads/Repositories/ThreadsRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadsRepository.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur in the `ThreadsRepository`. 11 | enum ThreadsRepositoryError: Error { 12 | case uploadFailed(message: String) // Error when uploading a thread fails 13 | case fetchFailed(message: String) // Error when fetching threads fails 14 | case userThreadsFetchFailed(message: String) // Error when fetching a user's threads fails 15 | case unknown(message: String) // Generic error for other unspecified failures 16 | case likeOperationFailed(message: String) // New exception for like/unlike operation failures 17 | } 18 | 19 | /// Protocol defining operations for managing threads. 20 | protocol ThreadsRepository { 21 | /// Uploads a new thread asynchronously. 22 | /// - Parameter data: The `CreateThreadBO` object containing the thread details. 23 | /// - Returns: A `ThreadBO` object representing the uploaded thread. 24 | /// - Throws: An error if the operation fails. 25 | func uploadThread(data: CreateThreadBO) async throws -> ThreadBO 26 | 27 | /// Fetches all threads asynchronously. 28 | /// - Returns: An array of `ThreadBO` objects representing the threads. 29 | /// - Throws: An error if the operation fails. 30 | func fetchThreads() async throws -> [ThreadBO] 31 | 32 | /// Fetches threads created by a specific user asynchronously. 33 | /// - Parameter userId: The ID of the user whose threads to fetch. 34 | /// - Returns: An array of `ThreadBO` objects. 35 | /// - Throws: An error if the operation fails. 36 | func fetchUserThreads(userId: String) async throws -> [ThreadBO] 37 | 38 | /// Likes or unlikes a thread by the specified user. 39 | /// - Parameter threadId: The ID of the thread to like or unlike. 40 | /// - Parameter userId: The ID of the user performing the like/unlike. 41 | /// - Returns: A boolean indicating if the operation succeeded. 42 | /// - Throws: An error if the like operation fails. 43 | func likeThread(threadId: String, userId: String) async throws -> Bool 44 | } 45 | -------------------------------------------------------------------------------- /Threads/ViewModel/Core/BaseThreadsActionsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseThreadsActionsViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 21/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | class BaseThreadsActionsViewModel: BaseViewModel { 13 | 14 | @Injected(\.likeThreadUseCase) private var likeThreadUseCase: LikeThreadUseCase 15 | 16 | @Published var threads = [ThreadBO]() 17 | @Published var showShareSheet: Bool = false 18 | @Published var shareContent: String = "" 19 | 20 | func onShareTapped(thread: ThreadBO) { 21 | self.shareContent = "Check out this thread: \(thread.caption)" 22 | self.showShareSheet.toggle() 23 | } 24 | 25 | func likeThread(threadId: String) { 26 | executeAsyncTask({ 27 | return try await self.likeThreadUseCase.execute(params: LikeThreadParams(threadId: threadId)) 28 | }) { [weak self] (result: Result) in 29 | guard let self = self else { return } 30 | switch result { 31 | case .success(let isSuccess): 32 | if isSuccess { 33 | self.onThreadLikeSuccessfully(threadId: threadId) 34 | } else { 35 | self.onThreadLikeFailed() 36 | } 37 | case .failure: 38 | self.onThreadLikeFailed() 39 | } 40 | } 41 | } 42 | 43 | private func onThreadLikeSuccessfully(threadId: String) { 44 | self.isLoading = false 45 | // Find the index of the thread to modify 46 | if let index = threads.firstIndex(where: { $0.id == threadId }) { 47 | if threads[index].isLikedByAuthUser { 48 | threads[index].isLikedByAuthUser = false 49 | threads[index].likes -= 1 50 | } else { 51 | threads[index].isLikedByAuthUser = true 52 | threads[index].likes += 1 53 | } 54 | // Reassign the array to trigger the UI update 55 | self.threads = threads 56 | } 57 | } 58 | 59 | private func onThreadLikeFailed() { 60 | self.isLoading = false 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Threads/DataSources/AuthenticationDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationDataSource.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An enumeration representing possible authentication errors. 11 | enum AuthenticationError: Error { 12 | /// Error indicating failure in signing in. 13 | case signInFailed(message: String) 14 | /// Error indicating failure in signing out. 15 | case signOutFailed(message: String) 16 | /// Error indicating failure in signing up. 17 | case signUpFailed(message: String) 18 | /// Error indicating failure in sending the password reset email. 19 | case passwordResetFailed(message: String) 20 | } 21 | 22 | /// A protocol defining authentication operations. 23 | protocol AuthenticationDataSource { 24 | 25 | /// Signs in using email and password. 26 | /// - Parameters: 27 | /// - email: The user's email address. 28 | /// - password: The user's password. 29 | /// - Throws: An `AuthenticationError` if sign-in fails. 30 | func signIn(email: String, password: String) async throws 31 | 32 | /// Signs up a new user using email and password. 33 | /// - Parameters: 34 | /// - email: The user's email address. 35 | /// - password: The user's password. 36 | /// - Returns: The user ID (`uid`) of the newly created user. 37 | /// - Throws: An `AuthenticationError` if sign-up fails. 38 | func signUp(email: String, password: String) async throws -> String 39 | 40 | /// Signs out the current user. 41 | /// - Throws: An `AuthenticationError` in case of failure, including `signOutFailed` if sign-out fails. 42 | func signOut() async throws 43 | 44 | /// Retrieves the current user's ID. 45 | /// - Returns: The user ID if the user is logged in, otherwise `nil`. 46 | /// - Throws: An `AuthenticationError` in case of failure. 47 | func getCurrentUserId() async throws -> String? 48 | 49 | /// Sends a password reset email to the user's email address. 50 | /// - Parameter email: The user's email address to which the reset link will be sent. 51 | /// - Throws: An `AuthenticationError` if the request fails. 52 | func forgotPassword(email: String) async throws 53 | } 54 | -------------------------------------------------------------------------------- /Threads/View/Home/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomeView: View { 11 | 12 | @StateObject var viewModel = HomeViewModel() 13 | 14 | var body: some View { 15 | TabView(selection: $viewModel.selectedTab) { 16 | feedTab() 17 | exploreTab() 18 | createThreadTab() 19 | activityTab() 20 | profileTab() 21 | } 22 | .onChange(of: viewModel.selectedTab) { newValue in 23 | viewModel.showCreateThreadView = (newValue == 2) 24 | } 25 | .sheet(isPresented: $viewModel.showCreateThreadView, onDismiss: { 26 | viewModel.selectedTab = 0 27 | }) { 28 | CreateThreadView() 29 | } 30 | .tint(.black) 31 | } 32 | 33 | // MARK: - Tab Views 34 | 35 | private func feedTab() -> some View { 36 | FeedView() 37 | .tabItem { 38 | Image(systemName: viewModel.selectedTab == 0 ? "house.fill" : "house") 39 | } 40 | .tag(0) 41 | } 42 | 43 | private func exploreTab() -> some View { 44 | ExploreView() 45 | .tabItem { 46 | Image(systemName: "magnifyingglass") 47 | } 48 | .tag(1) 49 | } 50 | 51 | private func createThreadTab() -> some View { 52 | Text("") // Placeholder view 53 | .tabItem { 54 | Image(systemName: "plus.circle") 55 | } 56 | .tag(2) 57 | } 58 | 59 | private func activityTab() -> some View { 60 | ActivityView() 61 | .tabItem { 62 | Image(systemName: viewModel.selectedTab == 3 ? "heart.fill" : "heart") 63 | } 64 | .tag(3) 65 | } 66 | 67 | private func profileTab() -> some View { 68 | ProfileView(user: nil) 69 | .tabItem { 70 | Image(systemName: viewModel.selectedTab == 4 ? "person.fill" : "person") 71 | } 72 | .tag(4) 73 | } 74 | } 75 | 76 | 77 | struct HomeView_Previews: PreviewProvider { 78 | static var previews: some View { 79 | HomeView() 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /Threads/DataSources/ThreadsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadsDataSource.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // Enum representing errors that can occur in the `ThreadsDataSource`. 11 | enum ThreadsDataSourceError: Error { 12 | /// Error indicating that uploading a thread failed. 13 | case uploadFailed 14 | /// Error indicating that the thread was not found. 15 | case threadNotFound 16 | /// Error indicating that the provided thread ID is invalid. 17 | case invalidThreadId(message: String) 18 | /// Error indicating that fetching threads failed. 19 | case fetchThreadsFailed 20 | /// Error indicating that fetching user threads failed. 21 | case fetchUserThreadsFailed 22 | /// Error indicating that the like operation failed (e.g., user already liked the thread). 23 | case likeFailed 24 | } 25 | 26 | /// Protocol defining data source operations for threads. 27 | protocol ThreadsDataSource { 28 | 29 | /// Uploads a new thread using the given DTO. 30 | /// - Parameter dto: The `CreateThreadDTO` object containing thread details. 31 | /// - Throws: An error if the upload fails. 32 | /// - Returns: A `ThreadDTO` representing the uploaded thread. 33 | func uploadThread(_ dto: CreateThreadDTO) async throws -> ThreadDTO 34 | 35 | /// Fetches all threads from Firestore. 36 | /// - Returns: An array of `ThreadDTO` objects. 37 | /// - Throws: An error if fetching fails. 38 | func fetchThreads() async throws -> [ThreadDTO] 39 | 40 | /// Fetches threads created by a specific user. 41 | /// - Parameter uid: The user ID whose threads are to be fetched. 42 | /// - Returns: An array of `ThreadDTO` objects. 43 | /// - Throws: An error if fetching fails. 44 | func fetchUserThreads(uid: String) async throws -> [ThreadDTO] 45 | 46 | /// Likes or dislikes a thread by the user. 47 | /// - Parameters: 48 | /// - threadId: The ID of the thread to like/dislike. 49 | /// - userId: The ID of the user performing the action. 50 | /// - Throws: An error if the operation fails. 51 | /// - Returns: A boolean indicating if the operation was successful. 52 | func likeThread(threadId: String, userId: String) async throws -> Bool 53 | } 54 | -------------------------------------------------------------------------------- /Threads/View/Activity/ActivityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActivityView: View { 11 | 12 | @StateObject var viewModel = ActivityViewModel() 13 | 14 | var body: some View { 15 | NavigationStack { 16 | ActivityViewContent( 17 | notifications: viewModel.notifications, 18 | onDeleteNotification: { 19 | viewModel.deleteNotification(id: $0) 20 | } 21 | ) 22 | .padding(.top) 23 | .refreshable { 24 | viewModel.fetchData() 25 | } 26 | .navigationTitle("Activity") 27 | .navigationBarTitleDisplayMode(.inline) 28 | .toolbar { 29 | ToolbarItem(placement: .navigationBarTrailing) { 30 | Button { 31 | viewModel.fetchData() 32 | } label: { 33 | Image(systemName: "arrow.counterclockwise") 34 | .foregroundColor(.black) 35 | .imageScale(.small) 36 | } 37 | } 38 | }.onAppear { 39 | viewModel.fetchData() 40 | } 41 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 42 | } 43 | } 44 | } 45 | 46 | private struct ActivityViewContent: View { 47 | 48 | var notifications: [NotificationBO] 49 | var onDeleteNotification: (String) -> Void 50 | 51 | var body: some View { 52 | ScrollView(showsIndicators: false) { 53 | LazyVStack { 54 | ForEach(notifications) { notification in 55 | NotificationCell(notification: notification, onProfileImageTapped: { 56 | AnyView(ProfileView(user: notification.byUser)) 57 | }, onDeleteNotification: { 58 | onDeleteNotification(notification.id) 59 | }) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | struct ActivityView_Previews: PreviewProvider { 67 | static var previews: some View { 68 | ActivityView() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Threads/UseCases/LikeThreadUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LikeThreadUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // Enum representing errors that can occur during the "like thread" process. 11 | enum LikeThreadError: Error { 12 | /// Error when the user is not signed in or unable to retrieve user ID. 13 | case signInFailed 14 | /// Error when the like operation fails. 15 | case likeOperationFailed(message: String) 16 | } 17 | 18 | // Parameters for the like thread use case. 19 | struct LikeThreadParams { 20 | var threadId: String 21 | } 22 | 23 | // The use case responsible for liking a thread by a user. 24 | struct LikeThreadUseCase { 25 | let authRepository: AuthenticationRepository 26 | let threadRepository: ThreadsRepository 27 | 28 | /// Executes the like operation for a specific thread by the current user. 29 | /// - Parameter params: The parameters containing the thread ID to be liked. 30 | /// - Returns: A boolean indicating whether the like operation was successful. 31 | /// - Throws: An error if the user cannot be retrieved or the like operation fails. 32 | func execute(params: LikeThreadParams) async throws -> Bool { 33 | // 1. Retrieve the current user's ID after successful sign-in. 34 | guard let userId = try await authRepository.getCurrentUserId() else { 35 | print("LikeThreadUseCase: Failed to retrieve user ID.") 36 | throw LikeThreadError.signInFailed 37 | } 38 | print("LikeThreadUseCase: Successfully retrieved user ID: \(userId)") 39 | 40 | // 2. Perform the like or unlike operation for the thread. 41 | do { 42 | // Attempt to like/unlike the thread. The result is a boolean indicating success. 43 | let success = try await threadRepository.likeThread(threadId: params.threadId, userId: userId) 44 | print("LikeThreadUseCase: Successfully performed like/unlike operation.") 45 | return success 46 | } catch { 47 | print("LikeThreadUseCase: Failed to like/unlike thread with error: \(error.localizedDescription)") 48 | throw LikeThreadError.likeOperationFailed(message: "Failed to like/unlike thread with ID \(params.threadId) for user \(userId): \(error.localizedDescription)") 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Threads/Repositories/AuthenticationRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationRepository.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur in the `AuthenticationRepository`. 11 | enum AuthenticationRepositoryError: Error { 12 | 13 | /// Error indicating that sign-in failed. 14 | case signInFailed(message: String) 15 | 16 | /// Error indicating that the email verification process failed. 17 | case verificationFailed 18 | 19 | /// Error indicating that sign-out failed. 20 | case signOutFailed 21 | 22 | /// Error indicating that the user registration (sign-up) failed. 23 | case signUpFailed(message: String) 24 | 25 | /// Error indicating that fetching the current user ID failed. 26 | case currentUserFetchFailed 27 | 28 | /// Error indicating that the password reset request failed. 29 | case passwordResetFailed(message: String) 30 | } 31 | 32 | /// Protocol defining authentication operations. 33 | protocol AuthenticationRepository { 34 | 35 | /// Signs in the user with the given email and password asynchronously. 36 | func signIn(email: String, password: String) async throws 37 | 38 | /// Registers a new user with the given email and password asynchronously. 39 | /// - Parameters: 40 | /// - email: The user's email address. 41 | /// - password: The user's password. 42 | /// - Returns: The user ID (`uid`) of the newly created user. 43 | /// - Throws: An `AuthenticationRepositoryError` if the sign-up fails. 44 | func signUp(email: String, password: String) async throws -> String 45 | 46 | /// Signs out the current user asynchronously. 47 | func signOut() async throws 48 | 49 | /// Fetches the current user ID asynchronously. 50 | /// - Returns: The current user ID as a string, or `nil` if no user is signed in. 51 | /// - Throws: An `AuthenticationRepositoryError` in case of failure. 52 | func getCurrentUserId() async throws -> String? 53 | 54 | /// Sends a password reset email to the user's email address. 55 | /// - Parameter email: The user's email address to which the reset link will be sent. 56 | /// - Throws: An `AuthenticationRepositoryError` if the request fails. 57 | func forgotPassword(email: String) async throws 58 | } 59 | -------------------------------------------------------------------------------- /Threads/ViewModel/ProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/11/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | class ProfileViewModel: BaseUserViewModel { 13 | 14 | @Injected(\.followUserUseCase) private var followUserUseCase: FollowUserUseCase 15 | @Injected(\.signOutUseCase) private var signOutUseCase: SignOutUseCase 16 | @Injected(\.eventBus) private var appEventBus: EventBus 17 | 18 | @Published var showEditProfile = false 19 | @Published var isAuthUser = false 20 | @Published var user: UserBO? = nil 21 | @Published var showSignOutAlert = false 22 | 23 | func loadUser(user: UserBO) { 24 | self.user = user 25 | } 26 | 27 | func signOut() { 28 | executeAsyncTask({ 29 | return try await self.signOutUseCase.execute() 30 | }) { [weak self] (result: Result) in 31 | guard let self = self else { return } 32 | self.onSignOutCompleted() 33 | } 34 | } 35 | 36 | func followUser() { 37 | if let userId = user?.id { 38 | executeAsyncTask { 39 | return try await self.followUserUseCase.execute(params: FollowUserParams(userId: userId)) 40 | } completion: { [weak self] result in 41 | guard let self = self else { return } 42 | if case .success(_) = result { 43 | self.onFollowUserCompleted() 44 | } 45 | } 46 | } 47 | } 48 | 49 | override func onCurrentUserLoaded(user: UserBO) { 50 | super.onCurrentUserLoaded(user: user) 51 | self.user = user 52 | self.isAuthUser = true 53 | } 54 | 55 | private func onSignOutCompleted() { 56 | self.appEventBus.publish(event: .loggedOut) 57 | } 58 | 59 | private func onFollowUserCompleted() { 60 | if var user = user { 61 | if user.isFollowedByAuthUser { 62 | user.isFollowedByAuthUser = false 63 | user.followers.removeAll(where: { $0 == authUserId }) 64 | } else { 65 | user.isFollowedByAuthUser = true 66 | user.followers.append(authUserId) 67 | } 68 | self.user = user 69 | } 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Threads/UseCases/UpdateUserUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUserUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Error types that may occur during the user update process. 11 | enum UpdateUserError: Error { 12 | case updateFailed 13 | } 14 | 15 | /// Parameters required to update a user's profile. 16 | struct UpdateUserParams { 17 | let fullname: String // The full name of the user. 18 | let bio: String? // The bio of the user (optional). 19 | let link: String? // The user's link (optional). 20 | let selectedImage: Data? // The image data for the user's profile picture (optional). 21 | let isPrivateProfile: Bool // Whether the user's profile is private or not. 22 | } 23 | 24 | /// Use case for updating a user's profile information. 25 | struct UpdateUserUseCase { 26 | let userRepository: UserProfileRepository // Repository for user profile operations. 27 | let authRepository: AuthenticationRepository // Repository for authentication-related operations. 28 | 29 | /// Executes the user update process. 30 | /// 31 | /// This method checks if the current user is authenticated, then updates their profile 32 | /// with the provided information. 33 | /// 34 | /// - Parameter params: The `UpdateUserParams` object containing the user's updated details. 35 | /// - Returns: The updated `UserBO` object representing the user's profile. 36 | /// - Throws: `UpdateUserError.updateFailed` if the user is not authenticated or the update fails. 37 | func execute(params: UpdateUserParams) async throws -> UserBO { 38 | // Retrieve the current authenticated user's ID. 39 | if let userId = try await authRepository.getCurrentUserId() { 40 | // Update the user's profile using the repository. 41 | return try await userRepository.updateUser(data: UpdateUserBO( 42 | userId: userId, 43 | fullname: params.fullname, 44 | bio: params.bio, 45 | link: params.link, 46 | selectedImage: params.selectedImage, 47 | isPrivateProfile: params.isPrivateProfile 48 | )) 49 | } else { 50 | // If no authenticated user is found, throw an error. 51 | throw UpdateUserError.updateFailed 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Threads/UseCases/ForgotPasswordUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForgotPasswordUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing possible errors that can occur during the forgot password process. 11 | enum ForgotPasswordError: Error { 12 | /// Error indicating that the provided email is invalid or not associated with an account. 13 | case invalidEmail 14 | 15 | /// Error indicating that sending the password reset email failed. 16 | case forgotPasswordFailed 17 | } 18 | 19 | /// A structure to hold the parameters required for the forgot password use case. 20 | struct ForgotPasswordParams { 21 | var email: String // The email address that the user provided to reset the password. 22 | } 23 | 24 | /// A use case responsible for handling the forgot password logic. 25 | struct ForgotPasswordUseCase { 26 | let authRepository: AuthenticationRepository // Repository responsible for interacting with the authentication system. 27 | 28 | /// Executes the forgot password process by sending a password reset email. 29 | /// - Parameter params: The parameters for the forgot password process (specifically the user's email). 30 | /// - Throws: A `ForgotPasswordError` if any issue occurs during the process. 31 | /// - Returns: A `Bool` indicating whether the password reset email was sent successfully. 32 | func execute(params: ForgotPasswordParams) async throws -> Bool { 33 | // 1. Attempt to send a password reset email with the provided email address. 34 | print("ForgotPasswordUseCase: Attempting to send password reset email to: \(params.email)") 35 | 36 | do { 37 | // Trying to send the reset password email by calling the repository method 38 | try await authRepository.forgotPassword(email: params.email) 39 | print("ForgotPasswordUseCase: Password reset email sent successfully.") 40 | return true // If the email is sent successfully, return true. 41 | } catch { 42 | // If the password reset email fails to send, print the error and throw a specific error. 43 | print("ForgotPasswordUseCase: Failed to send password reset email with error: \(error.localizedDescription)") 44 | throw ForgotPasswordError.forgotPasswordFailed // Throw an error if the process fails. 45 | } 46 | } 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /Threads/Mapper/Impl/UserMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserMapper.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A class responsible for mapping user data from the data source layer (`UserDataMapper`) 11 | /// to the domain-specific business object (`UserBO`). 12 | class UserMapper: Mapper { 13 | 14 | /// The input type for the mapper, defined as `UserDataMapper`. 15 | typealias Input = UserDataMapper 16 | 17 | /// The output type for the mapper, defined as `UserBO`. 18 | typealias Output = UserBO 19 | 20 | /// Maps a `UserDataMapper` instance to a `UserBO` instance. 21 | /// 22 | /// - Parameter input: An instance of `UserDataMapper` containing raw user data (`UserDTO`) 23 | /// and the authenticated user's ID (`authUserId`). 24 | /// - Returns: A `UserBO` object with all relevant user information mapped from the data source. 25 | /// 26 | /// The mapping process includes: 27 | /// - Extracting user properties such as `id`, `fullname`, `email`, `username`, etc., from the `UserDTO`. 28 | /// - Determining whether the authenticated user follows the target user using the list of `followers` 29 | /// from the `UserDTO` and comparing it to the provided `authUserId`. 30 | func map(_ input: UserDataMapper) -> UserBO { 31 | return UserBO( 32 | id: input.userDTO.userId, 33 | fullname: input.userDTO.fullname, 34 | email: input.userDTO.email, 35 | username: input.userDTO.username, 36 | profileImageUrl: input.userDTO.profileImageUrl, 37 | bio: input.userDTO.bio, 38 | link: input.userDTO.link, 39 | followers: input.userDTO.followers, 40 | following: input.userDTO.following, 41 | isPrivateProfile: input.userDTO.isPrivateProfile, 42 | isFollowedByAuthUser: input.userDTO.followers.contains(input.authUserId) 43 | ) 44 | } 45 | } 46 | 47 | /// A struct that acts as a data wrapper for the mapping process from the data source to the domain layer. 48 | struct UserDataMapper { 49 | 50 | /// The raw user data retrieved from a data source, encapsulated in a `UserDTO`. 51 | var userDTO: UserDTO 52 | 53 | /// The ID of the authenticated user. This is used to compute derived properties such as 54 | /// whether the authenticated user follows the target user. 55 | var authUserId: String 56 | } 57 | -------------------------------------------------------------------------------- /Threads/UseCases/FetchUserConnectionsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchUserConnectionsUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 23/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FetchUserConnectionsError: Error { 11 | case fetchFailed 12 | case fetchFollowersFailed 13 | case fetchFollowingFailed 14 | } 15 | 16 | /// Struct to encapsulate the parameters for fetching user connections (followers or following). 17 | struct FetchUserConnectionsParams { 18 | let userId: String 19 | let connectionType: UserConnectionType 20 | } 21 | 22 | /// Enum to represent whether to fetch followers or following 23 | enum UserConnectionType { 24 | case followers 25 | case following 26 | } 27 | 28 | /// An entity responsible for fetching a user's followers or following. 29 | struct FetchUserConnectionsUseCase { 30 | let userProfileRepository: UserProfileRepository 31 | let authRepository: AuthenticationRepository 32 | 33 | /// Executes the fetch operation for followers or following asynchronously. 34 | /// - Parameter params: The parameters containing the userId and connection type (followers or following). 35 | /// - Returns: A list of `UserBO` objects representing either followers or following for the user. 36 | /// - Throws: An error if the fetch operation fails. 37 | func execute(params: FetchUserConnectionsParams) async throws -> [UserBO] { 38 | guard let authUserId = try await authRepository.getCurrentUserId() else { 39 | throw FetchUserConnectionsError.fetchFailed 40 | } 41 | 42 | do { 43 | // Fetch either followers or following based on the connectionType 44 | switch params.connectionType { 45 | case .followers: 46 | let followers = try await userProfileRepository.getFollowers(userId: params.userId) 47 | return followers 48 | case .following: 49 | let following = try await userProfileRepository.getFollowing(userId: params.userId) 50 | return following 51 | } 52 | } catch { 53 | // Handle specific cases of fetching followers or following 54 | if params.connectionType == .followers { 55 | throw FetchUserConnectionsError.fetchFollowersFailed 56 | } else { 57 | throw FetchUserConnectionsError.fetchFollowingFailed 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Threads/Mapper/Impl/NotificationMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationMapper.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A class responsible for mapping `NotificationDataMapper` (which contains DTOs) to `NotificationBO` (Business Object). 11 | /// This mapper converts the raw data from data transfer objects (DTOs) into the business objects (BOs) used by the application. 12 | /// 13 | /// It also maps related user information by utilizing a `UserMapper` to convert `UserDTO` to `UserBO` objects for both the owner and the actor of the notification. 14 | class NotificationMapper: Mapper { 15 | 16 | // MARK: - Type Aliases 17 | 18 | typealias Input = NotificationDataMapper 19 | typealias Output = NotificationBO 20 | 21 | // MARK: - Dependencies 22 | 23 | private let userMapper: UserMapper 24 | 25 | /// Initializes an instance of `NotificationMapper`. 26 | /// - Parameter userMapper: The `UserMapper` used to map user-related data objects to business objects. 27 | init(userMapper: UserMapper) { 28 | self.userMapper = userMapper 29 | } 30 | 31 | /// Maps a `NotificationDataMapper` (which includes `NotificationDTO` and user DTOs) to a `NotificationBO` (Business Object). 32 | /// 33 | /// - Parameters: 34 | /// - input: The `NotificationDataMapper` object that contains all the data needed for the notification. 35 | /// - Returns: A `NotificationBO` object representing the mapped notification. 36 | func map(_ input: NotificationDataMapper) -> NotificationBO { 37 | return NotificationBO( 38 | id: input.notificationDTO.id, 39 | title: input.notificationDTO.title, 40 | ownerUser: userMapper.map(UserDataMapper(userDTO: input.notificationOwnerUserDTO, authUserId: input.authUserId)), 41 | byUser: userMapper.map(UserDataMapper(userDTO: input.notificationUserDTO, authUserId: input.authUserId)), 42 | type: NotificationType(rawValue: input.notificationDTO.type), 43 | message: input.notificationDTO.message, 44 | timestamp: input.notificationDTO.timestamp, 45 | isRead: input.notificationDTO.isRead 46 | ) 47 | } 48 | } 49 | 50 | 51 | struct NotificationDataMapper { 52 | var notificationDTO: NotificationDTO 53 | var notificationUserDTO: UserDTO 54 | var notificationOwnerUserDTO: UserDTO 55 | var authUserId: String 56 | } 57 | -------------------------------------------------------------------------------- /Threads/UseCases/SignInUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SignInError: Error { 11 | /// Error when the email or password is incorrect. 12 | case invalidCredentials 13 | /// Error when sign-in fails during the authentication process. 14 | case signInFailed 15 | } 16 | 17 | /// Parameters needed for signing in an existing user. 18 | struct SignInParams { 19 | var email: String 20 | var password: String 21 | } 22 | 23 | /// An entity responsible for signing in a user with email and password. 24 | struct SignInUseCase { 25 | let authRepository: AuthenticationRepository 26 | let userProfileRepository: UserProfileRepository 27 | 28 | /// Executes the sign-in process. 29 | /// - Parameter params: The parameters needed for signing in a user. 30 | /// - Returns: A `UserBO` object representing the authenticated user. 31 | /// - Throws: `SignInError` if any part of the process fails. 32 | func execute(params: SignInParams) async throws -> UserBO { 33 | // 1. Attempt to sign in with the provided email and password. 34 | print("SignInUseCase: Attempting to sign in with email: \(params.email)") 35 | do { 36 | try await authRepository.signIn(email: params.email, password: params.password) 37 | print("SignInUseCase: Sign-in successful.") 38 | } catch { 39 | print("SignInUseCase: Sign-in failed with error: \(error.localizedDescription)") 40 | throw SignInError.invalidCredentials 41 | } 42 | 43 | // 2. Retrieve the current user's ID after successful sign-in. 44 | print("SignInUseCase: Retrieving current user ID.") 45 | guard let userId = try await authRepository.getCurrentUserId() else { 46 | print("SignInUseCase: Failed to retrieve user ID.") 47 | throw SignInError.signInFailed 48 | } 49 | print("SignInUseCase: Successfully retrieved user ID: \(userId)") 50 | 51 | // 3. Retrieve the user's profile information. 52 | print("SignInUseCase: Fetching user profile for user ID: \(userId)") 53 | do { 54 | let userBO = try await userProfileRepository.getUser(userId: userId) 55 | print("SignInUseCase: Successfully fetched user profile.") 56 | return userBO 57 | } catch { 58 | print("SignInUseCase: Failed to fetch user profile with error: \(error.localizedDescription)") 59 | throw SignInError.signInFailed 60 | } 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /Threads/ViewModel/ExploreViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreViewModel.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | @MainActor 13 | class ExploreViewModel: BaseUserViewModel { 14 | 15 | @Injected(\.followUserUseCase) private var followUserUseCase: FollowUserUseCase 16 | @Injected(\.searchUsersUseCase) private var searchUsersUseCase: SearchUsersUseCase 17 | @Injected(\.getSuggestionsUseCase) private var getSuggestionsUseCase: GetSuggestionsUseCase 18 | 19 | @Published var searchText = "" { 20 | didSet { 21 | fetchData() 22 | } 23 | } 24 | @Published var users = [UserBO]() 25 | 26 | func fetchData() { 27 | if(!searchText.isEmpty) { 28 | searchUsers() 29 | } else { 30 | fetchSuggestions() 31 | } 32 | } 33 | 34 | func followUser(userId: String) { 35 | executeAsyncTask { 36 | return try await self.followUserUseCase.execute(params: FollowUserParams(userId: userId)) 37 | } completion: { [weak self] result in 38 | guard let self = self else { return } 39 | if case .success(_) = result { 40 | self.onFollowUserCompleted(userId: userId) 41 | } 42 | } 43 | } 44 | 45 | private func onFollowUserCompleted(userId: String) { 46 | // Find the index of the user to modify 47 | if let index = users.firstIndex(where: { $0.id == userId }) { 48 | users[index].isFollowedByAuthUser = !users[index].isFollowedByAuthUser 49 | // Reassign the array to trigger the UI update 50 | self.users = users 51 | } 52 | } 53 | 54 | private func fetchSuggestions() { 55 | fetchUsers(using: self.getSuggestionsUseCase.execute) 56 | } 57 | 58 | private func searchUsers() { 59 | fetchUsers(using: { try await self.searchUsersUseCase.execute(params: SearchUsersParams(term: self.searchText)) }) 60 | } 61 | 62 | private func fetchUsers(using fetchTask: @escaping () async throws -> [UserBO]) { 63 | executeAsyncTask({ 64 | return try await fetchTask() 65 | }) { [weak self] (result: Result<[UserBO], Error>) in 66 | guard let self = self else { return } 67 | if case .success(let users) = result { 68 | self.onFetchDataCompleted(users: users) 69 | } 70 | } 71 | } 72 | 73 | private func onFetchDataCompleted(users: [UserBO]) { 74 | self.users = users 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Threads/UseCases/CreateThreadUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | import Foundation 11 | 12 | /// Enum representing errors that can occur during the creation of a thread. 13 | enum CreateThreadError: Error { 14 | /// Error when the current user ID cannot be retrieved. 15 | case userNotAuthenticated 16 | /// Error when uploading the thread fails. 17 | case uploadFailed(message: String) 18 | } 19 | 20 | /// Parameters needed for creating a new thread. 21 | struct CreateThreadParams { 22 | var caption: String 23 | } 24 | 25 | /// Use case responsible for creating a new thread. 26 | struct CreateThreadUseCase { 27 | let authRepository: AuthenticationRepository 28 | let threadsRepository: ThreadsRepository 29 | 30 | /// Executes the creation of a new thread. 31 | /// - Parameter params: The parameters needed for creating the thread. 32 | /// - Returns: The created `ThreadBO` object. 33 | /// - Throws: `CreateThreadError` if any part of the process fails. 34 | func execute(params: CreateThreadParams) async throws -> ThreadBO { 35 | print("CreateThreadUseCase: Starting thread creation process with params: \(params)") 36 | 37 | // 1. Retrieve the current user's ID. 38 | guard let userId = try await authRepository.getCurrentUserId() else { 39 | print("CreateThreadUseCase: User is not authenticated.") 40 | throw CreateThreadError.userNotAuthenticated 41 | } 42 | print("CreateThreadUseCase: Retrieved user ID: \(userId)") 43 | 44 | // 2. Generate a unique thread ID using UUID. 45 | let threadId = UUID().uuidString 46 | print("CreateThreadUseCase: Generated thread ID: \(threadId)") 47 | 48 | // 3. Create the thread data object. 49 | let threadData = CreateThreadBO(threadId: threadId, ownerUid: userId, caption: params.caption) 50 | print("CreateThreadUseCase: Created thread data: \(threadData)") 51 | 52 | // 4. Upload the thread using the repository. 53 | do { 54 | print("CreateThreadUseCase: Uploading thread.") 55 | let threadCreated = try await threadsRepository.uploadThread(data: threadData) 56 | print("CreateThreadUseCase: Thread uploaded successfully.") 57 | return threadCreated 58 | } catch { 59 | let errorMessage = "Failed to upload thread: \(error.localizedDescription)" 60 | print("CreateThreadUseCase: \(errorMessage)") 61 | throw CreateThreadError.uploadFailed(message: errorMessage) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Threads/View/Feed/FeedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FeedView: View { 11 | 12 | @StateObject var viewModel = FeedViewModel() 13 | 14 | var body: some View { 15 | NavigationStack { 16 | FeedViewContent( 17 | threads: viewModel.threads, 18 | onLikeTapped: { 19 | viewModel.likeThread(threadId: $0) 20 | }, 21 | onSharedTapped: { 22 | viewModel.onShareTapped(thread: $0) 23 | } 24 | ) 25 | .refreshable { 26 | viewModel.fetchThreads() 27 | } 28 | .navigationTitle("Threads") 29 | .navigationBarTitleDisplayMode(.inline) 30 | .toolbar { 31 | ToolbarItem(placement: .navigationBarTrailing) { 32 | Button { 33 | viewModel.fetchThreads() 34 | } label: { 35 | Image(systemName: "arrow.counterclockwise") 36 | .foregroundColor(.black) 37 | .imageScale(.small) 38 | } 39 | } 40 | }.onAppear { 41 | viewModel.fetchThreads() 42 | } 43 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 44 | // Show the share sheet as a modal when the user taps the share button 45 | .sheet(isPresented: $viewModel.showShareSheet) { 46 | // Display the share sheet with the content to share 47 | ShareActivityView(activityItems: [viewModel.shareContent]) 48 | } 49 | } 50 | } 51 | } 52 | 53 | private struct FeedViewContent: View { 54 | 55 | var threads: [ThreadBO] 56 | var onLikeTapped: ((String) -> Void) 57 | var onSharedTapped: ((ThreadBO) -> Void) 58 | 59 | var body: some View { 60 | ScrollView(showsIndicators: false) { 61 | LazyVStack { 62 | ForEach(threads) { thread in 63 | ThreadCell(thread: thread, onProfileImageTapped: { 64 | AnyView(ProfileView(user: thread.user)) 65 | }, onLikeTapped: { 66 | onLikeTapped(thread.id) 67 | }, onShareTapped: { 68 | onSharedTapped(thread) 69 | }) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | struct FeedView_Previews: PreviewProvider { 77 | static var previews: some View { 78 | FeedView() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Threads/UseCases/FollowUserUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowUserUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 21/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum defining the possible errors that may occur during the follow user operation. 11 | enum FollowUserError: Error { 12 | /// Error when retrieving the current authenticated user's ID fails. 13 | case signInFailed 14 | 15 | /// Error when the follow operation fails. 16 | case followOperationFailed(message: String) 17 | } 18 | 19 | /// Structure containing the parameters required to execute the follow user operation. 20 | struct FollowUserParams { 21 | /// The ID of the user to be followed. 22 | var userId: String 23 | } 24 | 25 | /// Use case for following a user. 26 | struct FollowUserUseCase { 27 | 28 | let authRepository: AuthenticationRepository 29 | let userProfileRepository: UserProfileRepository 30 | 31 | /// Executes the follow user use case. 32 | /// 33 | /// This method performs the following steps: 34 | /// 1. Retrieves the authenticated user's ID. 35 | /// 2. If successful, it calls the user profile repository to follow the target user. 36 | /// 3. If an error occurs during the follow operation, an error is thrown with a corresponding message. 37 | /// 38 | /// - Parameter params: The parameters required to execute the use case, including the user ID of the user to follow. 39 | /// - Returns: A boolean indicating whether the operation was successful or not. 40 | /// - Throws: `FollowUserError.signInFailed` if the authenticated user's ID cannot be retrieved, or `FollowUserError.followOperationFailed` if the follow operation fails. 41 | func execute(params: FollowUserParams) async throws -> Bool { 42 | // 1. Retrieve the authenticated user's ID. 43 | guard let authUserId = try await authRepository.getCurrentUserId() else { 44 | print("FollowUserUseCase: Failed to retrieve user ID.") 45 | throw FollowUserError.signInFailed 46 | } 47 | print("FollowUserUseCase: Successfully retrieved user ID: \(authUserId)") 48 | 49 | do { 50 | // 2. Attempt to follow the target user using the user profile repository. 51 | try await userProfileRepository.followUser(authUserId: authUserId, targetUserId: params.userId) 52 | return true 53 | } catch { 54 | // 3. If an error occurs during the follow operation, print and throw an error. 55 | print("FollowUserUseCase: Failed to follow user with error: \(error.localizedDescription)") 56 | throw FollowUserError.followOperationFailed(message: "Failed to follow user with ID \(params.userId): \(error.localizedDescription)") 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/UserCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCell.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UserCell: View { 11 | 12 | let user: UserBO 13 | var onFollowTapped: (() -> Void)? 14 | var onProfileImageTapped: (() -> AnyView)? 15 | 16 | var body: some View { 17 | HStack(spacing: 16) { 18 | 19 | if let destination = onProfileImageTapped { 20 | NavigationLink(destination: destination()) { 21 | // Profile image and user details 22 | ProfileImageView(profileImageUrl: user.profileImageUrl) 23 | } 24 | } else { 25 | ProfileImageView(profileImageUrl: user.profileImageUrl) 26 | } 27 | 28 | UserInfoDetailsView(user: user) 29 | 30 | Spacer() 31 | 32 | // Follow/Unfollow Button 33 | Button(action: { 34 | onFollowTapped?() 35 | }) { 36 | Text(user.isFollowedByAuthUser ? "Following" : "Follow") 37 | .font(.subheadline) 38 | .fontWeight(.semibold) 39 | .foregroundColor(user.isFollowedByAuthUser ? .white : .blue) 40 | .padding(.vertical, 8) 41 | .frame(width: 80) 42 | .background(user.isFollowedByAuthUser ? Color.blue : Color.white) 43 | .cornerRadius(8) 44 | .overlay { 45 | RoundedRectangle(cornerRadius: 8) 46 | .stroke(user.isFollowedByAuthUser ? Color.blue : Color(.systemGray4), lineWidth: 1) 47 | } 48 | } 49 | .padding(.trailing) 50 | } 51 | .padding(.horizontal, 4) 52 | .background(Color.white) 53 | } 54 | } 55 | 56 | private struct UserInfoDetailsView: View { 57 | 58 | let user: UserBO 59 | 60 | var body: some View { 61 | // User Info (Username, Fullname) 62 | VStack(alignment: .leading, spacing: 4) { 63 | Text(user.username) 64 | .font(.headline) 65 | .foregroundColor(.primary) 66 | .lineLimit(1) 67 | 68 | Text(user.fullname) 69 | .font(.subheadline) 70 | .foregroundColor(.secondary) 71 | .lineLimit(1) 72 | } 73 | } 74 | } 75 | 76 | private struct ProfileImageView: View { 77 | 78 | var profileImageUrl: String? 79 | 80 | var body: some View { 81 | CircularProfileImageView(profileImageUrl: profileImageUrl, size: .medium) 82 | .frame(width: 50, height: 50) 83 | .shadow(radius: 1) 84 | } 85 | } 86 | 87 | struct UserCell_Previews: PreviewProvider { 88 | static var previews: some View { 89 | UserCell(user: dev.user) 90 | .previewLayout(.sizeThatFits) 91 | .padding() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Threads/View/Explore/ExploreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ExploreView: View { 11 | @StateObject var viewModel = ExploreViewModel() 12 | 13 | var body: some View { 14 | NavigationStack { 15 | content 16 | // Set navigation title for the screen 17 | .navigationTitle("Explore Users") 18 | // Add searchable functionality to filter users 19 | .searchable(text: $viewModel.searchText, prompt: "Search for users by name") 20 | // Show loading and error overlay if applicable 21 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 22 | .onAppear { 23 | // Load current user data when the view appears 24 | viewModel.loadCurrentUser() 25 | viewModel.fetchData() 26 | } 27 | } 28 | } 29 | 30 | private var content: some View { 31 | VStack { 32 | // Show empty state when there is no users found 33 | if viewModel.users.isEmpty { 34 | emptyStateView 35 | } else { 36 | // Show the list of users when there are search results 37 | ScrollView { 38 | LazyVStack(spacing: 8) { 39 | // Iterate over the list of users and create a navigation link for each 40 | ForEach(viewModel.users) { user in 41 | userNavigationLink(for: user) 42 | } 43 | } 44 | .padding(.horizontal) 45 | } 46 | } 47 | } 48 | .padding(.top, 20) 49 | } 50 | 51 | private var emptyStateView: some View { 52 | VStack(spacing: 16) { 53 | // Display different icons based on whether there is search text or not 54 | Image(systemName: viewModel.searchText.isEmpty ? "magnifyingglass" : "exclamationmark.triangle.fill") 55 | .font(.system(size: 40)) 56 | .foregroundColor(.gray) 57 | 58 | // Show appropriate empty state message 59 | Text(viewModel.searchText.isEmpty ? "Start searching for users" : "No results found") 60 | .font(.title2) 61 | .foregroundColor(.gray) 62 | .multilineTextAlignment(.center) 63 | .padding(.horizontal) 64 | } 65 | .padding() 66 | } 67 | 68 | @ViewBuilder 69 | private func userNavigationLink(for user: UserBO) -> some View { 70 | // User cell that shows user info and follow status 71 | UserCell( 72 | user: user, 73 | onFollowTapped: { 74 | viewModel.followUser(userId: user.id) 75 | }, 76 | onProfileImageTapped: { 77 | AnyView(ProfileView(user: user)) 78 | } 79 | ) 80 | .padding(.vertical, 4) 81 | .background(Divider(), alignment: .bottom) 82 | } 83 | } 84 | 85 | struct ExploreView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | ExploreView() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Threads/View/UserProfile/Components/ProfileHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileHeaderView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProfileHeaderView: View { 11 | 12 | var user: UserBO? 13 | 14 | init(user: UserBO?) { 15 | self.user = user 16 | } 17 | 18 | var body: some View { 19 | HStack(alignment: .top) { 20 | VStack(alignment: .leading, spacing: 12) { 21 | VStack(alignment: .leading, spacing: 4) { 22 | Text(user?.fullname ?? "") 23 | .font(.title2) 24 | .fontWeight(.semibold) 25 | Text(user?.username ?? "") 26 | .font(.subheadline) 27 | .foregroundColor(.gray) 28 | } 29 | 30 | if let bio = user?.bio { 31 | Text(bio) 32 | .font(.footnote) 33 | .foregroundColor(.black) 34 | .padding(.bottom, 8) 35 | } 36 | 37 | HStack(spacing: 16) { 38 | // Followers 39 | if let followers = user?.followers, let userId = user?.id { 40 | NavigationLink(destination: ConnectionsView(userId: userId, connectionType: .followers)) { 41 | Text("\(followers.count) Followers") 42 | .font(.caption) 43 | .foregroundColor(.gray) 44 | } 45 | } 46 | 47 | // Following 48 | if let following = user?.following, let userId = user?.id { 49 | NavigationLink(destination: ConnectionsView(userId: userId, connectionType: .following)) { 50 | Text("\(following.count) Following") 51 | .font(.caption) 52 | .foregroundColor(.gray) 53 | } 54 | } 55 | } 56 | 57 | // Show Link if exists 58 | if let link = user?.link, !link.isEmpty { 59 | Text(link) 60 | .font(.caption) 61 | .foregroundColor(.blue) 62 | .padding(.top, 8) 63 | .onTapGesture { 64 | if let url = URL(string: link) { 65 | UIApplication.shared.open(url) 66 | } 67 | } 68 | } 69 | 70 | // Profile Private Indicator 71 | if let isPrivate = user?.isPrivateProfile, isPrivate { 72 | Text("This profile is private") 73 | .font(.caption) 74 | .foregroundColor(.red) 75 | .padding(.top, 8) 76 | } 77 | } 78 | Spacer() 79 | CircularProfileImageView(profileImageUrl: user?.profileImageUrl, size: .medium) 80 | } 81 | .padding() 82 | } 83 | } 84 | 85 | struct ProfileHeaderView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | ProfileHeaderView(user: dev.user) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Threads/UseCases/SignUpUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignUpUseCase.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur during the sign-up process. 11 | enum SignUpError: Error { 12 | /// Error when the username is not available. 13 | case usernameNotAvailable 14 | /// Error when the passwords do not match. 15 | case passwordsDoNotMatch 16 | /// Error when the sign-up process fails. 17 | case signUpFailed(message: String) 18 | /// Error when creating the user profile fails. 19 | case createUserFailed(message: String) 20 | } 21 | 22 | /// Parameters needed for signing up a new user. 23 | struct SignUpParams { 24 | var username: String 25 | var email: String 26 | var password: String 27 | var repeatPassword: String 28 | var fullname: String 29 | } 30 | 31 | /// An entity responsible for signing up a new user. 32 | struct SignUpUseCase { 33 | let authRepository: AuthenticationRepository 34 | let userRepository: UserProfileRepository 35 | 36 | /// Executes the sign-up process. 37 | /// - Parameter params: The parameters needed for signing up a user. 38 | /// - Returns: The created `UserBO` object. 39 | /// - Throws: `SignUpError` if any part of the process fails. 40 | func execute(params: SignUpParams) async throws -> UserBO { 41 | print("SignUpUseCase: Starting sign-up process with params: \(params)") 42 | 43 | // 1. Check if the passwords match. 44 | guard params.password == params.repeatPassword else { 45 | print("SignUpUseCase: Passwords do not match.") 46 | throw SignUpError.passwordsDoNotMatch 47 | } 48 | print("SignUpUseCase: Passwords match.") 49 | 50 | // 2. Check if the username is available. 51 | print("SignUpUseCase: Checking username availability for: \(params.username)") 52 | let isUsernameAvailable = try await userRepository.checkUsernameAvailability(username: params.username) 53 | guard isUsernameAvailable else { 54 | print("SignUpUseCase: Username \(params.username) is not available.") 55 | throw SignUpError.usernameNotAvailable 56 | } 57 | print("SignUpUseCase: Username \(params.username) is available.") 58 | 59 | // 3. Sign up the user using the provided email and password. 60 | do { 61 | print("SignUpUseCase: Attempting to sign up with email: \(params.email)") 62 | let userId = try await authRepository.signUp(email: params.email, password: params.password) 63 | print("SignUpUseCase: Sign-up successful. User ID: \(userId)") 64 | 65 | // 4. Create the user profile in the UserProfileRepository. 66 | print("SignUpUseCase: Creating user profile with ID: \(userId)") 67 | let userBO = try await userRepository.createUser(data: CreateUserBO( 68 | userId: userId, 69 | fullname: params.fullname, 70 | username: params.username, 71 | email: params.email 72 | )) 73 | print("SignUpUseCase: User profile created successfully. UserBO: \(userBO)") 74 | return userBO 75 | } catch { 76 | let errorMessage = "Sign-up failed: \(error.localizedDescription)" 77 | print("SignUpUseCase: \(errorMessage)") 78 | throw SignUpError.signUpFailed(message: errorMessage) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Threads/View/ThreadCreation/CreateThreadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateThreadView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CreateThreadView: View { 11 | 12 | @StateObject var viewModel = CreateThreadViewModel() 13 | @Environment(\.dismiss) private var onDismiss 14 | 15 | var body: some View { 16 | NavigationStack { 17 | PostThreadView( 18 | authUserProfileImageUrl: viewModel.authUserProfileImageUrl, 19 | authUserUsername: viewModel.authUserUsername, 20 | caption: $viewModel.caption 21 | ) 22 | .padding() 23 | .navigationTitle("New Thread") 24 | .navigationBarTitleDisplayMode(.inline) 25 | .toolbar { 26 | ToolbarItem(placement: .navigationBarLeading) { 27 | Button("Cancel") { 28 | onDismiss() 29 | } 30 | .font(.subheadline) 31 | .foregroundColor(.black) 32 | } 33 | ToolbarItem(placement: .navigationBarTrailing) { 34 | Button("Post") { 35 | viewModel.uploadThread() 36 | onDismiss() 37 | } 38 | .opacity(viewModel.caption.isEmpty ? 0.5 : 1.0) 39 | .disabled(viewModel.caption.isEmpty) 40 | .font(.subheadline) 41 | .fontWeight(.semibold) 42 | .foregroundColor(.black) 43 | } 44 | } 45 | .onReceive(viewModel.$threadUploaded) { success in 46 | if success { 47 | onDismiss() 48 | } 49 | } 50 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 51 | .onAppear { 52 | onLoadCurrentUser() 53 | } 54 | } 55 | } 56 | 57 | private func onLoadCurrentUser() { 58 | viewModel.loadCurrentUser() 59 | } 60 | } 61 | 62 | private struct PostThreadView: View { 63 | 64 | var authUserProfileImageUrl: String 65 | var authUserUsername: String 66 | @Binding var caption: String 67 | 68 | var body: some View { 69 | VStack { 70 | HStack(alignment: .top) { 71 | CircularProfileImageView(profileImageUrl: authUserProfileImageUrl, size: .small) 72 | VStack(alignment: .leading, spacing: 4) { 73 | Text(authUserUsername) 74 | .fontWeight(.semibold) 75 | TextField("Start a thread ...", text: $caption, axis: .vertical) 76 | } 77 | .font(.footnote) 78 | 79 | Spacer() 80 | 81 | if !caption.isEmpty { 82 | Button { 83 | caption = "" 84 | } label: { 85 | Image(systemName: "xmark") 86 | .resizable() 87 | .frame(width: 12, height: 12) 88 | .foregroundColor(.gray) 89 | } 90 | } 91 | } 92 | 93 | Spacer() 94 | } 95 | } 96 | 97 | } 98 | 99 | struct CreateThreadView_Previews: PreviewProvider { 100 | static var previews: some View { 101 | CreateThreadView() 102 | } 103 | } 104 | 105 | -------------------------------------------------------------------------------- /Threads/View/Connections/ConnectionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionsView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 23/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ConnectionsView: View { 11 | 12 | @StateObject var viewModel = ConnectionsViewModel() 13 | 14 | var userId: String 15 | var connectionType: UserConnectionType 16 | 17 | var body: some View { 18 | NavigationStack { 19 | ConnectionsViewContent( 20 | users: viewModel.users, 21 | onFollowTapped: { userId in 22 | viewModel.followUser(userId: userId) 23 | } 24 | ) 25 | .padding(.top) 26 | .refreshable { 27 | viewModel.fetchData(userId: userId, connectionType: connectionType) 28 | } 29 | .navigationTitle(connectionType == .followers ? "Followers" : "Following") 30 | .navigationBarTitleDisplayMode(.inline) 31 | .toolbar { 32 | ToolbarItem(placement: .navigationBarTrailing) { 33 | Button { 34 | viewModel.fetchData(userId: userId, connectionType: connectionType) 35 | } label: { 36 | Image(systemName: "arrow.counterclockwise") 37 | .foregroundColor(.black) 38 | .imageScale(.small) 39 | } 40 | } 41 | } 42 | .onAppear { 43 | viewModel.fetchData(userId: userId, connectionType: connectionType) 44 | } 45 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 46 | } 47 | } 48 | } 49 | 50 | private struct ConnectionsViewContent: View { 51 | 52 | var users: [UserBO] 53 | var onFollowTapped: (String) -> Void 54 | 55 | var body: some View { 56 | VStack { 57 | if users.isEmpty { 58 | emptyStateView 59 | } else { 60 | ScrollView(showsIndicators: false) { 61 | LazyVStack { 62 | ForEach(users) { user in 63 | UserCell( 64 | user: user, 65 | onFollowTapped: { 66 | onFollowTapped(user.id) 67 | }, 68 | onProfileImageTapped: { 69 | AnyView(ProfileView(user: user)) 70 | } 71 | ) 72 | .padding(.vertical, 4) 73 | .background(Divider(), alignment: .bottom) 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | // Empty State View shown when there are no users (followers or following) 82 | private var emptyStateView: some View { 83 | VStack { 84 | Image(systemName: "person.3.fill") 85 | .font(.system(size: 50)) 86 | .foregroundColor(.gray) 87 | 88 | Text("No connections yet.") 89 | .font(.title2) 90 | .foregroundColor(.gray) 91 | .multilineTextAlignment(.center) 92 | .padding(.top, 10) 93 | .padding(.horizontal) 94 | } 95 | .padding(.vertical, 30) 96 | .background(Color.white) 97 | } 98 | } 99 | 100 | struct ConnectionsView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | ConnectionsView(userId: "", connectionType: .followers) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Threads/DataSources/UserDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDataSource.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum representing errors that can occur in the `UserDataSource`. 11 | enum UserDataSourceError: Error { 12 | /// Error indicating that saving user data failed. 13 | case saveFailed 14 | /// Error indicating that the user was not found. 15 | case userNotFound 16 | /// Error indicating that the provided user ID is invalid. 17 | case invalidUserId(message: String) 18 | /// Error indicating that the search operation failed. 19 | case searchFailed(message: String) 20 | } 21 | 22 | /// Protocol defining operations for managing user data. 23 | protocol UserDataSource { 24 | /// Updates user data asynchronously. 25 | /// - Parameter data: The data of the user to be updated. 26 | /// - Returns: A `UserDTO` object representing the updated user. 27 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 28 | func updateUser(data: UpdateUserDTO) async throws -> UserDTO 29 | 30 | /// Creates a new user asynchronously. 31 | /// - Parameter data: The data of the user to be created. 32 | /// - Returns: A `UserDTO` object representing the created user. 33 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 34 | func createUser(data: CreateUserDTO) async throws -> UserDTO 35 | 36 | /// Retrieves user data from Firestore based on the provided user ID asynchronously. 37 | /// - Parameter userId: The ID of the user to retrieve. 38 | /// - Returns: A `UserDTO` object containing the user data. 39 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 40 | func getUserById(userId: String) async throws -> UserDTO 41 | 42 | /// Retrieves user data for a list of user IDs asynchronously. 43 | /// - Parameter userIds: An array of user IDs to retrieve user data for. 44 | /// - Returns: An array of `UserDTO` objects containing the user data. 45 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 46 | func getUserByIdList(userIds: [String]) async throws -> [UserDTO] 47 | 48 | /// Retrieves suggestions for users based on the authenticated user ID asynchronously. 49 | /// - Parameter authUserId: The ID of the authenticated user. 50 | /// - Returns: An array of `UserDTO` objects representing user suggestions. 51 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 52 | func getSuggestions(authUserId: String) async throws -> [UserDTO] 53 | 54 | /// Checks the availability of a username asynchronously. 55 | /// - Parameter username: The username to check for availability. 56 | /// - Returns: A Boolean value indicating whether the username is available. 57 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 58 | func checkUsernameAvailability(username: String) async throws -> Bool 59 | 60 | /// Allows a user to follow or unfollow another user asynchronously. 61 | /// - Parameters: 62 | /// - authUserId: The ID of the user performing the follow/unfollow action. 63 | /// - targetUserId: The ID of the user to be followed or unfollowed. 64 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 65 | func followUser(authUserId: String, targetUserId: String) async throws 66 | 67 | /// Searches for users based on a provided search term asynchronously. 68 | /// 69 | /// - Parameter searchTerm: A string representing the term to search for (e.g., username, fullname). 70 | /// - Returns: An array of `UserDTO` objects that match the search criteria. 71 | /// - Throws: An error if the search operation fails, including errors specified in `UserDataSourceError`. 72 | func searchUsers(searchTerm: String) async throws -> [UserDTO] 73 | } 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.GoogleService-Info.plist 2 | 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swift,xcode,macos 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=swiftpackagemanager,swift,xcode,macos 6 | 7 | ### macOS ### 8 | # General 9 | .DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | 13 | ## Storing some api keys and stuff that you don't want to be uploaded on Github 14 | Secrets.swift 15 | 16 | # Icon must end with two \r 17 | Icon 18 | 19 | # Thumbnails 20 | ._* 21 | 22 | # Files that might appear in the root of a volume 23 | .DocumentRevisions-V100 24 | .fseventsd 25 | .Spotlight-V100 26 | .TemporaryItems 27 | .Trashes 28 | .VolumeIcon.icns 29 | .com.apple.timemachine.donotpresent 30 | 31 | # Directories potentially created on remote AFP share 32 | .AppleDB 33 | .AppleDesktop 34 | Network Trash Folder 35 | Temporary Items 36 | .apdisk 37 | 38 | ### Swift ### 39 | # Xcode 40 | # 41 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 42 | 43 | ## User settings 44 | xcuserdata/ 45 | 46 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 47 | *.xcscmblueprint 48 | *.xccheckout 49 | 50 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 51 | Build/ 52 | DerivedData/ 53 | *.moved-aside 54 | *.pbxuser 55 | !default.pbxuser 56 | *.mode1v3 57 | !default.mode1v3 58 | *.mode2v3 59 | !default.mode2v3 60 | *.perspectivev3 61 | !default.perspectivev3 62 | 63 | ## Obj-C/Swift specific 64 | *.hmap 65 | 66 | ## App packaging 67 | *.ipa 68 | *.dSYM.zip 69 | *.dSYM 70 | 71 | ## Playgrounds 72 | timeline.xctimeline 73 | playground.xcworkspace 74 | 75 | # Swift Package Manager 76 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 77 | # Packages/ 78 | # Package.pins 79 | # Package.resolved 80 | # *.xcodeproj 81 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 82 | # hence it is not needed unless you have added a package configuration file to your project 83 | # .swiftpm 84 | 85 | .build/ 86 | 87 | # CocoaPods 88 | # We recommend against adding the Pods directory to your .gitignore. However 89 | # you should judge for yourself, the pros and cons are mentioned at: 90 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 91 | # Pods/ 92 | # Add this line if you want to avoid checking in source code from the Xcode workspace 93 | # *.xcworkspace 94 | 95 | # Carthage 96 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 97 | # Carthage/Checkouts 98 | 99 | Carthage/Build/ 100 | 101 | # Accio dependency management 102 | Dependencies/ 103 | .accio/ 104 | 105 | # fastlane 106 | # It is recommended to not store the screenshots in the git repo. 107 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 108 | # For more information about the recommended setup visit: 109 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 110 | 111 | fastlane/report.xml 112 | fastlane/Preview.html 113 | fastlane/screenshots/**/*.png 114 | fastlane/test_output 115 | 116 | # Code Injection 117 | # After new code Injection tools there's a generated folder /iOSInjectionProject 118 | # https://github.com/johnno1962/injectionforxcode 119 | 120 | iOSInjectionProject/ 121 | 122 | ### SwiftPackageManager ### 123 | Packages 124 | xcuserdata 125 | *.xcodeproj 126 | 127 | 128 | ### Xcode ### 129 | # Xcode 130 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 131 | 132 | 133 | 134 | 135 | ## Gcc Patch 136 | /*.gcno 137 | 138 | ### Xcode Patch ### 139 | *.xcodeproj/* 140 | !*.xcodeproj/project.pbxproj 141 | !*.xcodeproj/xcshareddata/ 142 | !*.xcworkspace/contents.xcworkspacedata 143 | **/xcshareddata/WorkspaceSettings.xcsettings 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swift,xcode,macos 146 | -------------------------------------------------------------------------------- /Threads/Launch Screen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /Threads/Repositories/Impl/AuthenticationRepositoryImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationRepositoryImpl.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // Class responsible for authentication repository operations. 11 | internal class AuthenticationRepositoryImpl: AuthenticationRepository { 12 | 13 | private let authenticationDataSource: AuthenticationDataSource 14 | 15 | /// Initializes an instance of `AuthenticationRepositoryImpl`. 16 | /// - Parameter authenticationDataSource: The data source for authentication operations. 17 | init(authenticationDataSource: AuthenticationDataSource) { 18 | self.authenticationDataSource = authenticationDataSource 19 | } 20 | 21 | /// Signs in the user with the given email and password asynchronously. 22 | /// - Parameters: 23 | /// - email: The user's email address. 24 | /// - password: The user's password. 25 | /// - Throws: An `AuthenticationRepositoryError` in case of failure, including `signInFailed` if the sign-in fails. 26 | func signIn(email: String, password: String) async throws { 27 | do { 28 | try await authenticationDataSource.signIn(email: email, password: password) 29 | } catch { 30 | print("Sign-in failed: \(error.localizedDescription)") 31 | throw AuthenticationRepositoryError.signInFailed(message: "Sign-in failed: \(error.localizedDescription)") 32 | } 33 | } 34 | 35 | /// Registers a new user with the given email and password asynchronously. 36 | /// - Parameters: 37 | /// - email: The user's email address. 38 | /// - password: The user's password. 39 | /// - Returns: The user ID (`uid`) of the newly created user. 40 | /// - Throws: An `AuthenticationRepositoryError` if the sign-up fails. 41 | func signUp(email: String, password: String) async throws -> String { 42 | do { 43 | // Delegate the sign-up operation to the data source. 44 | let userId = try await authenticationDataSource.signUp(email: email, password: password) 45 | print("Successfully signed up user with ID: \(userId)") 46 | return userId 47 | } catch { 48 | // Handle and rethrow the error with a custom message. 49 | print("Sign-up failed: \(error.localizedDescription)") 50 | throw AuthenticationRepositoryError.signUpFailed(message: "Sign-up failed: \(error.localizedDescription)") 51 | } 52 | } 53 | 54 | /// Signs out the current user asynchronously. 55 | /// - Throws: An `AuthenticationRepositoryError` in case of failure, including specific errors related to sign-out failure. 56 | func signOut() async throws { 57 | do { 58 | try await authenticationDataSource.signOut() 59 | } catch { 60 | print(error.localizedDescription) 61 | throw AuthenticationRepositoryError.signOutFailed 62 | } 63 | } 64 | 65 | /// Fetches the current user ID asynchronously. 66 | /// - Returns: The current user ID as a string, or `nil` if no user is signed in. 67 | /// - Throws: An `AuthenticationRepositoryError` in case of failure, including specific errors related to user ID fetching failure. 68 | func getCurrentUserId() async throws -> String? { 69 | do { 70 | return try await authenticationDataSource.getCurrentUserId() 71 | } catch { 72 | print(error.localizedDescription) 73 | throw AuthenticationRepositoryError.currentUserFetchFailed 74 | } 75 | } 76 | 77 | /// Sends a password reset email to the user's email address. 78 | /// - Parameter email: The user's email address to which the reset link will be sent. 79 | /// - Throws: An `AuthenticationRepositoryError` if the request fails. 80 | func forgotPassword(email: String) async throws { 81 | do { 82 | return try await authenticationDataSource.forgotPassword(email: email) 83 | } catch { 84 | print(error.localizedDescription) 85 | throw AuthenticationRepositoryError.passwordResetFailed(message: error.localizedDescription) 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/NotificationCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationCell.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 23/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import SwipeActions 10 | 11 | struct NotificationCell: View { 12 | 13 | let notification: NotificationBO 14 | var onProfileImageTapped: (() -> AnyView)? 15 | var onDeleteNotification: (() -> Void)? 16 | 17 | var body: some View { 18 | HStack(spacing: 16) { 19 | 20 | if let destination = onProfileImageTapped { 21 | NavigationLink(destination: destination()) { 22 | ProfileImageView(profileImageUrl: notification.byUser.profileImageUrl) 23 | } 24 | } else { 25 | ProfileImageView(profileImageUrl: notification.byUser.profileImageUrl) 26 | } 27 | 28 | NotificationDetails(notification: notification) 29 | 30 | Spacer() 31 | 32 | VStack { 33 | // Notification Type Icon 34 | NotificationTypeIcon(type: notification.type) 35 | .frame(width: 20, height: 20) 36 | .padding([.top, .trailing], 8) 37 | 38 | Spacer() 39 | } 40 | } 41 | .padding(.horizontal) 42 | .padding(.vertical, 8) 43 | .background(Color.white) 44 | .addSwipeAction(edge: .trailing) { 45 | Button { 46 | onDeleteNotification?() 47 | } label: { 48 | Image(systemName: "trash") 49 | .foregroundColor(.white) 50 | .imageScale(.large) 51 | } 52 | .frame(width: 120, height: 100, alignment: .center) 53 | .contentShape(Rectangle()) 54 | .background(Color.red) 55 | } 56 | 57 | } 58 | } 59 | 60 | private struct NotificationDetails: View { 61 | 62 | let notification: NotificationBO 63 | 64 | var body: some View { 65 | VStack(alignment: .leading, spacing: 6) { 66 | // Notification Title 67 | Text(notification.title) 68 | .font(.headline) 69 | .foregroundColor(.primary) 70 | .lineLimit(1) 71 | 72 | // Notification Message 73 | Text(notification.message) 74 | .font(.footnote) 75 | .foregroundColor(.secondary) 76 | .lineLimit(2) 77 | 78 | // Timestamp 79 | Text(notification.timestamp.timeAgoDisplay()) 80 | .font(.caption) 81 | .foregroundColor(.gray) 82 | } 83 | } 84 | } 85 | 86 | /// A helper view to display a user's profile image. 87 | private struct ProfileImageView: View { 88 | 89 | var profileImageUrl: String? 90 | 91 | var body: some View { 92 | CircularProfileImageView(profileImageUrl: profileImageUrl, size: .medium) 93 | .frame(width: 48, height: 48) 94 | .shadow(radius: 2) 95 | } 96 | } 97 | 98 | /// A helper view to display an icon for a notification type. 99 | private struct NotificationTypeIcon: View { 100 | 101 | let type: NotificationType 102 | 103 | var body: some View { 104 | Image(systemName: iconName) 105 | .resizable() 106 | .scaledToFit() 107 | .frame(width: 20, height: 20) 108 | .foregroundColor(.blue) 109 | .background(Circle().fill(Color.blue.opacity(0.1))) 110 | } 111 | 112 | private var iconName: String { 113 | switch type { 114 | case .follow: 115 | return "person.fill.badge.plus" 116 | case .like: 117 | return "heart.fill" 118 | case .comment: 119 | return "text.bubble.fill" 120 | case .repost: 121 | return "arrow.2.squarepath" 122 | } 123 | } 124 | } 125 | 126 | 127 | struct NotificationCell_Previews: PreviewProvider { 128 | static var previews: some View { 129 | NotificationCell(notification: dev.notification) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Threads/View/ForgotPassword/ForgotPasswordView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForgotPasswordView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ForgotPasswordView: View { 11 | 12 | @StateObject var viewModel = ForgotPasswordViewModel() 13 | 14 | @Environment(\.dismiss) private var onDismiss 15 | 16 | var body: some View { 17 | ZStack { 18 | BackgroundImage(imageName: "main_background") 19 | VStack { 20 | Spacer() 21 | ForgotPasswordContent() 22 | Spacer() 23 | ForgotPasswordFormView(email: $viewModel.email) 24 | Spacer() 25 | SendResetLinkButtonView(onSendLink: { 26 | viewModel.sendResetLink() 27 | }) 28 | Spacer() 29 | Divider() 30 | SignInLinkView() 31 | DeveloperCreditView() 32 | }.padding() 33 | } 34 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 35 | .alert(isPresented: $viewModel.resetLinkSent) { 36 | Alert( 37 | title: Text("Success"), 38 | message: Text("We have sent a password reset link to your email."), 39 | dismissButton: .default(Text("OK"), action: { 40 | onDismiss() 41 | }) 42 | ) 43 | } 44 | .environment(\.colorScheme, .light) 45 | } 46 | } 47 | 48 | private struct ForgotPasswordContent: View { 49 | var body: some View { 50 | VStack { 51 | Image("app_logo") 52 | .resizable() 53 | .aspectRatio(contentMode: .fit) 54 | .frame(maxWidth: .infinity) 55 | .edgesIgnoringSafeArea(.all) 56 | Text("Forgot Password?") 57 | .font(.title) 58 | .fontWeight(.bold) 59 | .foregroundColor(.white) 60 | .padding() 61 | Text("Enter your email address, and we’ll send you a link to reset your password.") 62 | .font(.title3) 63 | .foregroundColor(.white) 64 | .multilineTextAlignment(.center) 65 | .padding(.horizontal) 66 | } 67 | .padding(.horizontal, 30) 68 | } 69 | } 70 | 71 | private struct ForgotPasswordFormView: View { 72 | 73 | @Binding var email: String 74 | 75 | var body: some View { 76 | VStack(spacing: 16) { 77 | TextField("Enter your email", text: $email) 78 | .autocapitalization(.none) 79 | .modifier(ThreadsTextFieldModifier()) 80 | } 81 | } 82 | } 83 | 84 | private struct SendResetLinkButtonView: View { 85 | 86 | var onSendLink: () -> Void 87 | 88 | var body: some View { 89 | Button { 90 | onSendLink() 91 | } label: { 92 | Text("Send Reset Link") 93 | .font(.subheadline) 94 | .fontWeight(.semibold) 95 | .foregroundColor(.white) 96 | .frame(width: 352, height: 44) 97 | .background(Color.black) 98 | .cornerRadius(8) 99 | .overlay( 100 | RoundedRectangle(cornerRadius: 8) 101 | .stroke(Color.white, lineWidth: 2) 102 | ) 103 | } 104 | } 105 | } 106 | 107 | private struct SignInLinkView: View { 108 | 109 | var body: some View { 110 | NavigationLink { 111 | SignInView() 112 | .navigationBarBackButtonHidden(true) 113 | } label: { 114 | HStack(spacing: 3) { 115 | Text("Remember your password?") 116 | Text("Sign In") 117 | } 118 | .foregroundColor(.white) 119 | .fontWeight(.bold) 120 | .font(.footnote) 121 | }.padding(.vertical, 16) 122 | } 123 | } 124 | 125 | struct ForgotPasswordView_Previews: PreviewProvider { 126 | static var previews: some View { 127 | ForgotPasswordView() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Threads/DataSources/Impl/FirebaseAuthenticationDataSourceImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirebaseAuthenticationDataSourceImpl.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | import Firebase 10 | import FirebaseFirestore 11 | 12 | /// A data source responsible for handling authentication operations using Firestore. 13 | internal class FirebaseAuthenticationDataSourceImpl: AuthenticationDataSource { 14 | 15 | 16 | /// Signs in using email and password. 17 | /// - Parameters: 18 | /// - email: The user's email address. 19 | /// - password: The user's password. 20 | /// - Throws: An `AuthenticationError` if sign-in fails. 21 | func signIn(email: String, password: String) async throws { 22 | do { 23 | // Attempt to sign in using Firebase's Auth API. 24 | let authResult = try await Auth.auth().signIn(withEmail: email, password: password) 25 | 26 | // Check if user information is available after sign-in. 27 | guard authResult.user.email != nil else { 28 | throw AuthenticationError.signInFailed(message: "User information is incomplete.") 29 | } 30 | 31 | print("Successfully signed in as: \(authResult.user.email ?? "Unknown email")") 32 | } catch { 33 | // Handle and rethrow the error with a custom message. 34 | print("Sign-in error: \(error.localizedDescription)") 35 | throw AuthenticationError.signInFailed(message: "Sign-in failed: \(error.localizedDescription)") 36 | } 37 | } 38 | 39 | /// Signs up a new user using email and password. 40 | /// - Parameters: 41 | /// - email: The user's email address. 42 | /// - password: The user's password. 43 | /// - Returns: The user ID (`uid`) of the newly created user. 44 | /// - Throws: An `AuthenticationError` if sign-up fails. 45 | func signUp(email: String, password: String) async throws -> String { 46 | do { 47 | // Attempt to create a user with email and password. 48 | let authResult = try await Auth.auth().createUser(withEmail: email, password: password) 49 | let userId = authResult.user.uid 50 | print("Successfully signed up user with ID: \(userId)") 51 | return userId 52 | } catch { 53 | // Handle and rethrow the error with a custom message. 54 | print("Sign-up error: \(error.localizedDescription)") 55 | throw AuthenticationError.signUpFailed(message: "Sign-up failed: \(error.localizedDescription)") 56 | } 57 | } 58 | 59 | /// Signs out the current user. 60 | /// - Throws: An `AuthenticationError` in case of failure, including `signOutFailed` if sign-out fails. 61 | func signOut() async throws { 62 | do { 63 | try Auth.auth().signOut() 64 | } catch { 65 | print(error.localizedDescription) 66 | throw AuthenticationError.signOutFailed(message: "Sign-out failed: \(error.localizedDescription)") 67 | } 68 | } 69 | 70 | /// Retrieves the ID of the current user. 71 | /// - Returns: The user ID if the user is signed in, otherwise `nil`. 72 | /// - Throws: An `AuthenticationError` in case of failure. 73 | func getCurrentUserId() async throws -> String? { 74 | guard let userSession = Auth.auth().currentUser else { 75 | return nil 76 | } 77 | return userSession.uid 78 | } 79 | 80 | /// Sends a password reset email to the user's email address. 81 | /// - Parameter email: The user's email address to which the reset link will be sent. 82 | /// - Throws: An `AuthenticationError` if the request fails. 83 | func forgotPassword(email: String) async throws { 84 | do { 85 | // Attempt to send a password reset email. 86 | try await Auth.auth().sendPasswordReset(withEmail: email) 87 | print("Password reset email sent to: \(email)") 88 | } catch { 89 | // Handle and rethrow the error with a custom message. 90 | print("Password reset error: \(error.localizedDescription)") 91 | throw AuthenticationError.passwordResetFailed(message: "Password reset failed: \(error.localizedDescription)") 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Threads/View/UserProfile/Components/UserContentListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserContentListView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 20/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UserContentListView: View { 11 | 12 | @StateObject var viewModel = UserContentListViewModel() 13 | @Namespace var animation 14 | 15 | // Width calculation for the filter bar 16 | private var filterBarWidth: CGFloat { 17 | let count = CGFloat(ProfileThreadFilter.allCases.count) 18 | return UIScreen.main.bounds.width / count - 16 19 | } 20 | 21 | let user: UserBO 22 | 23 | 24 | init(user: UserBO) { 25 | self.user = user 26 | } 27 | 28 | var body: some View { 29 | VStack { 30 | // Filter Bar 31 | HStack { 32 | // Loop through all available filters 33 | ForEach(ProfileThreadFilter.allCases) { filter in 34 | VStack { 35 | // Title for each filter 36 | Text(filter.title) 37 | .font(.subheadline) 38 | .fontWeight(viewModel.selectedFilter == filter ? .bold : .regular) 39 | 40 | // Show the active selection indicator (underline) for the selected filter 41 | if viewModel.selectedFilter == filter { 42 | Rectangle() 43 | .foregroundColor(.black) 44 | .frame(width: filterBarWidth, height: 1) 45 | .matchedGeometryEffect(id: "item", in: animation) 46 | } else { 47 | // Invisible rectangle when the filter is not selected 48 | Rectangle() 49 | .foregroundColor(.clear) 50 | .frame(width: filterBarWidth, height: 1) 51 | } 52 | } 53 | .onTapGesture { 54 | // Animate filter selection change when tapped 55 | withAnimation(.spring()) { 56 | viewModel.selectedFilter = filter 57 | } 58 | } 59 | } 60 | } 61 | 62 | // Threads List 63 | if viewModel.threads.isEmpty { 64 | emptyStateView 65 | } else { 66 | LazyVStack { 67 | ForEach(viewModel.threads) { thread in 68 | ThreadCell(thread: thread, onLikeTapped: { 69 | viewModel.likeThread(threadId: thread.threadId) 70 | }, onShareTapped: { 71 | viewModel.onShareTapped(thread: thread) 72 | }) 73 | } 74 | } 75 | } 76 | } 77 | .padding(.vertical, 8) 78 | .onAppear { 79 | viewModel.loadUser(user: user) 80 | viewModel.fetchUserThreads() 81 | } 82 | // Show the share sheet as a modal when the user taps the share button 83 | .sheet(isPresented: $viewModel.showShareSheet) { 84 | // Display the share sheet with the content to share 85 | ShareActivityView(activityItems: [viewModel.shareContent]) 86 | } 87 | } 88 | 89 | // Empty State View shown when there are no threads 90 | private var emptyStateView: some View { 91 | VStack { 92 | Image(systemName: "face.dashed.fill") 93 | .font(.system(size: 50)) 94 | .foregroundColor(.gray) 95 | 96 | Text("This user has no posts yet.") 97 | .font(.title2) 98 | .foregroundColor(.gray) 99 | .multilineTextAlignment(.center) 100 | .padding(.top, 10) 101 | .padding(.horizontal) 102 | } 103 | .padding(.vertical, 30) 104 | .background(Color.white) 105 | } 106 | } 107 | 108 | 109 | struct UserContentListView_Previews: PreviewProvider { 110 | static var previews: some View { 111 | UserContentListView(user: dev.user) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Threads/View/EditProfile/EditProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditProfileView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | import PhotosUI 10 | 11 | struct EditProfileView: View { 12 | 13 | @StateObject var viewModel = EditProfileViewModel() 14 | @Environment(\.dismiss) private var onDimiss 15 | 16 | var body: some View { 17 | NavigationStack { 18 | ZStack { 19 | Color(.systemGroupedBackground) 20 | .edgesIgnoringSafeArea([.bottom, .horizontal]) 21 | 22 | VStack { 23 | HStack { 24 | VStack(alignment: .leading) { 25 | Text("Name") 26 | .fontWeight(.semibold) 27 | TextField("Enter your fullname ...", text: $viewModel.authUserFullName, axis: .vertical) 28 | } 29 | .font(.footnote) 30 | 31 | Spacer() 32 | 33 | PhotosPicker(selection: $viewModel.selectedItem) { 34 | if let image = viewModel.profileImage { 35 | image 36 | .resizable() 37 | .scaledToFill() 38 | .frame(width: 40, height: 40) 39 | .clipShape(Circle()) 40 | } else { 41 | CircularProfileImageView(profileImageUrl: viewModel.authUserProfileImageUrl, size: .small) 42 | } 43 | } 44 | 45 | } 46 | 47 | Divider() 48 | 49 | VStack(alignment: .leading) { 50 | Text("Bio") 51 | .fontWeight(.semibold) 52 | TextField("Enter your bio ...", text: $viewModel.bio, axis: .vertical) 53 | } 54 | .font(.footnote) 55 | 56 | Divider() 57 | 58 | VStack(alignment: .leading) { 59 | Text("Link") 60 | .fontWeight(.semibold) 61 | TextField("Add link ...", text: $viewModel.link) 62 | } 63 | .font(.footnote) 64 | 65 | Divider() 66 | 67 | Toggle("Private Profile", isOn: $viewModel.isPrivateProfile) 68 | } 69 | .font(.footnote) 70 | .padding() 71 | .background(.white) 72 | .cornerRadius(10) 73 | .overlay { 74 | RoundedRectangle(cornerRadius: 10) 75 | .stroke(Color(.systemGray4), lineWidth: 1) 76 | } 77 | .padding() 78 | 79 | } 80 | .navigationTitle("Edit Profile") 81 | .navigationBarTitleDisplayMode(.inline) 82 | .toolbar { 83 | ToolbarItem(placement: .navigationBarLeading) { 84 | Button("Cancel") { 85 | onDimiss() 86 | } 87 | .font(.subheadline) 88 | .foregroundColor(.black) 89 | } 90 | ToolbarItem(placement: .navigationBarTrailing) { 91 | Button("Done") { 92 | viewModel.onUpdateProfile() 93 | } 94 | .font(.subheadline) 95 | .fontWeight(.semibold) 96 | .foregroundColor(.black) 97 | } 98 | } 99 | .onReceive(viewModel.$userProfileUpdated) { success in 100 | if success { 101 | onDimiss() 102 | } 103 | } 104 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 105 | .onAppear { 106 | viewModel.loadCurrentUser() 107 | } 108 | } 109 | } 110 | } 111 | 112 | struct EditProfileView_Previews: PreviewProvider { 113 | static var previews: some View { 114 | EditProfileView() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Threads/View/SignUp/SignUpView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignUpView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SignUpView: View { 11 | 12 | @StateObject var viewModel = SignUpViewModel() 13 | 14 | var body: some View { 15 | ZStack { 16 | BackgroundImage(imageName: "main_background") 17 | VStack { 18 | Spacer() 19 | SingUpContent() 20 | Spacer() 21 | SignUpForm( 22 | email: $viewModel.email, 23 | password: $viewModel.password, 24 | repeatPassword: $viewModel.repeatPassword, 25 | fullname: $viewModel.fullname, 26 | username: $viewModel.username 27 | ) 28 | SingUpButton(onSignUp: { 29 | viewModel.signUp() 30 | }) 31 | Spacer() 32 | Divider() 33 | SignInLinkButton() 34 | DeveloperCreditView() 35 | }.padding() 36 | } 37 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 38 | .environment(\.colorScheme, .light) 39 | } 40 | } 41 | 42 | private struct SingUpContent: View { 43 | var body: some View { 44 | VStack { 45 | Image("app_logo") 46 | .resizable() 47 | .aspectRatio(contentMode: .fit) 48 | .frame(maxWidth: .infinity) 49 | .edgesIgnoringSafeArea(.all) 50 | Text("Connect with others, share your moments, and be part of a real-time community.") 51 | .font(.title3) 52 | .foregroundColor(.white) 53 | .multilineTextAlignment(.center) 54 | .padding(.horizontal) 55 | } 56 | .padding(.horizontal, 30) 57 | 58 | } 59 | } 60 | 61 | private struct SignUpForm: View { 62 | 63 | @Binding var email: String 64 | @Binding var password: String 65 | @Binding var repeatPassword: String 66 | @Binding var fullname: String 67 | @Binding var username: String 68 | 69 | var body: some View { 70 | VStack(spacing: 16) { 71 | TextField("Enter your email", text: $email) 72 | .modifier(ThreadsTextFieldModifier()) 73 | 74 | SecureField("Enter your password", text: $password) 75 | .modifier(ThreadsTextFieldModifier()) 76 | 77 | SecureField("Repeat your password", text: $repeatPassword) 78 | .modifier(ThreadsTextFieldModifier()) 79 | 80 | TextField("Enter your full name", text: $fullname) 81 | .modifier(ThreadsTextFieldModifier()) 82 | 83 | TextField("Enter your username", text: $username) 84 | .autocapitalization(.none) 85 | .modifier(ThreadsTextFieldModifier()) 86 | } 87 | } 88 | 89 | } 90 | 91 | private struct SingUpButton: View { 92 | 93 | var onSignUp: () -> Void 94 | 95 | var body: some View { 96 | Button { 97 | onSignUp() 98 | } label: { 99 | Text("Sign Up") 100 | .font(.subheadline) 101 | .fontWeight(.semibold) 102 | .foregroundColor(.white) 103 | .frame(width: 352, height: 44) 104 | .background(.black) 105 | .cornerRadius(8) 106 | .overlay( 107 | RoundedRectangle(cornerRadius: 8) 108 | .stroke(Color.white, lineWidth: 2) 109 | ) 110 | }.padding(.vertical) 111 | } 112 | } 113 | 114 | private struct SignInLinkButton: View { 115 | 116 | @Environment(\.dismiss) var dismiss 117 | 118 | var body: some View { 119 | Button { 120 | dismiss() 121 | } label: { 122 | HStack(spacing: 3) { 123 | Text("Already have an account?") 124 | Text("Sign In") 125 | .fontWeight(.semibold) 126 | } 127 | .foregroundColor(.white) 128 | .fontWeight(.bold) 129 | .font(.footnote) 130 | } 131 | .padding(.vertical, 16) 132 | } 133 | 134 | } 135 | 136 | struct SignUpView_Previews: PreviewProvider { 137 | static var previews: some View { 138 | SignUpView() 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Threads/View/SignIn/SignInView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 9/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SignInView: View { 11 | 12 | @StateObject var viewModel = SignInViewModel() 13 | 14 | var body: some View { 15 | ZStack { 16 | BackgroundImage(imageName: "main_background") 17 | VStack { 18 | Spacer() 19 | SingInContent() 20 | Spacer() 21 | SignInFormView( 22 | email: $viewModel.email, 23 | password: $viewModel.password 24 | ) 25 | ForgotPasswordLinkView() 26 | SignInButtonView(onSignIn: { 27 | viewModel.signIn() 28 | }) 29 | Spacer() 30 | Divider() 31 | SignUpLinkView() 32 | DeveloperCreditView() 33 | }.padding() 34 | } 35 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 36 | .environment(\.colorScheme, .light) 37 | } 38 | } 39 | 40 | private struct SingInContent: View { 41 | var body: some View { 42 | VStack { 43 | Image("app_logo") 44 | .resizable() 45 | .aspectRatio(contentMode: .fit) 46 | .frame(maxWidth: .infinity) 47 | .edgesIgnoringSafeArea(.all) 48 | Text("Welcome to Threads") 49 | .font(.title) 50 | .fontWeight(.bold) 51 | .foregroundColor(.white) 52 | .padding() 53 | Text("Join the conversation. Share your thoughts and connect with friends in real time.") 54 | .font(.title3) 55 | .foregroundColor(.white) 56 | .multilineTextAlignment(.center) 57 | .padding(.horizontal) 58 | } 59 | .padding(.horizontal, 30) 60 | 61 | } 62 | } 63 | 64 | private struct SignInFormView: View { 65 | 66 | @Binding var email: String 67 | @Binding var password: String 68 | 69 | var body: some View { 70 | VStack(spacing: 16) { 71 | TextField("Enter your email", text: $email) 72 | .autocapitalization(.none) 73 | .modifier(ThreadsTextFieldModifier()) 74 | 75 | SecureField("Enter you password", text: $password) 76 | .modifier(ThreadsTextFieldModifier()) 77 | } 78 | } 79 | } 80 | 81 | private struct ForgotPasswordLinkView: View { 82 | 83 | var body: some View { 84 | NavigationLink { 85 | ForgotPasswordView() 86 | .navigationBarBackButtonHidden(true) 87 | } label: { 88 | Text("Forgot Password?") 89 | .font(.footnote) 90 | .fontWeight(.semibold) 91 | .padding(.vertical) 92 | .padding(.trailing, 28) 93 | .foregroundColor(.white) 94 | .frame(maxWidth: .infinity, alignment: .trailing) 95 | } 96 | } 97 | } 98 | 99 | private struct SignInButtonView: View { 100 | 101 | var onSignIn: () -> Void 102 | 103 | var body: some View { 104 | Button { 105 | onSignIn() 106 | } label: { 107 | Text("Login") 108 | .font(.subheadline) 109 | .fontWeight(.semibold) 110 | .foregroundColor(.white) 111 | .frame(width: 352, height: 44) 112 | .background(Color.black) 113 | .cornerRadius(8) 114 | .overlay( 115 | RoundedRectangle(cornerRadius: 8) 116 | .stroke(Color.white, lineWidth: 2) 117 | ) 118 | } 119 | } 120 | } 121 | 122 | private struct SignUpLinkView: View { 123 | 124 | var body: some View { 125 | NavigationLink { 126 | SignUpView() 127 | .navigationBarBackButtonHidden(true) 128 | } label: { 129 | HStack(spacing: 3) { 130 | Text("Don't have an account?") 131 | Text("Sign Up") 132 | } 133 | .foregroundColor(.white) 134 | .fontWeight(.bold) 135 | .font(.footnote) 136 | }.padding(.vertical, 16) 137 | } 138 | } 139 | 140 | struct SignInView_Previews: PreviewProvider { 141 | static var previews: some View { 142 | SignInView() 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Threads/DataSources/Impl/FirestoreNotificationsDataSourceImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirestoreNotificationsDataSourceImpl.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 22/11/24. 6 | // 7 | 8 | import Foundation 9 | import FirebaseFirestore 10 | import FirebaseFirestoreSwift 11 | 12 | // Firestore implementation of `NotificationsDataSource`. 13 | internal class FirestoreNotificationsDataSourceImpl: NotificationsDataSource { 14 | 15 | private let notificationsCollection = "threads_notifications" 16 | private let db = Firestore.firestore() 17 | 18 | /// Fetches notifications for a specific user. 19 | /// - Parameter uid: The user ID whose notifications are to be fetched. 20 | /// - Returns: An array of `NotificationDTO` objects, sorted by timestamp. 21 | func fetchUserNotifications(uid: String) async throws -> [NotificationDTO] { 22 | do { 23 | let snapshot = try await db 24 | .collection(notificationsCollection) 25 | .whereField("ownerUserId", isEqualTo: uid) 26 | .getDocuments() 27 | 28 | let notifications = snapshot.documents.compactMap { document in 29 | try? document.data(as: NotificationDTO.self) 30 | } 31 | 32 | if notifications.isEmpty { 33 | throw NotificationsDataSourceError.fetchUserNotificationsFailed 34 | } 35 | 36 | // Sort notifications by timestamp in descending order 37 | return notifications.sorted { $0.timestamp > $1.timestamp } 38 | } catch { 39 | print("Error fetching user's notifications: \(error.localizedDescription)") 40 | throw NotificationsDataSourceError.fetchUserNotificationsFailed 41 | } 42 | } 43 | 44 | /// Fetches a notification by its ID. 45 | /// - Parameter notificationId: The ID of the notification to fetch. 46 | /// - Returns: A `NotificationDTO` representing the notification. 47 | private func getNotificationById(notificationId: String) async throws -> NotificationDTO { 48 | do { 49 | let documentSnapshot = try await db 50 | .collection(notificationsCollection) 51 | .document(notificationId) 52 | .getDocument() 53 | 54 | guard let notification = try? documentSnapshot.data(as: NotificationDTO.self) else { 55 | print("Notification not found with ID: \(notificationId)") 56 | throw NotificationsDataSourceError.notificationNotFound 57 | } 58 | return notification 59 | } catch { 60 | print("Error getting notification by ID: \(error.localizedDescription)") 61 | throw NotificationsDataSourceError.notificationNotFound 62 | } 63 | } 64 | 65 | /// Marks a specific notification as read. 66 | /// - Parameter notificationId: The ID of the notification to mark as read. 67 | /// - Throws: An error if the operation fails. 68 | /// - Returns: A boolean indicating if the operation was successful. 69 | func markNotificationAsRead(notificationId: String) async throws -> Bool { 70 | let notificationRef = db.collection(notificationsCollection).document(notificationId) 71 | 72 | do { 73 | let notificationSnapshot = try await notificationRef.getDocument() 74 | guard var notification = try? notificationSnapshot.data(as: NotificationDTO.self) else { 75 | print("Notification not found") 76 | throw NotificationsDataSourceError.notificationNotFound 77 | } 78 | notification.isRead = true 79 | try await notificationRef.updateData([ 80 | "isRead": true 81 | ]) 82 | return true 83 | } catch { 84 | print("Error marking notification as read: \(error.localizedDescription)") 85 | throw NotificationsDataSourceError.markAsReadFailed 86 | } 87 | } 88 | 89 | /// Deletes a specific notification. 90 | /// - Parameter notificationId: The ID of the notification to be deleted. 91 | /// - Throws: An error if the operation fails. 92 | /// - Returns: A boolean indicating if the operation was successful. 93 | func deleteNotification(notificationId: String) async throws -> Bool { 94 | let notificationRef = db.collection(notificationsCollection).document(notificationId) 95 | 96 | do { 97 | try await notificationRef.delete() 98 | return true 99 | } catch { 100 | print("Error deleting notification: \(error.localizedDescription)") 101 | throw NotificationsDataSourceError.deleteNotificationFailed 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Threads/View/UserProfile/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProfileView: View { 11 | 12 | @StateObject var viewModel = ProfileViewModel() 13 | 14 | var user: UserBO? 15 | 16 | init(user: UserBO?) { 17 | self.user = user 18 | } 19 | 20 | var body: some View { 21 | NavigationStack { 22 | ProfileViewContent( 23 | user: viewModel.user, 24 | isAuthUser: viewModel.isAuthUser, 25 | showEditProfile: $viewModel.showEditProfile, 26 | onFollowUserTapped: { 27 | viewModel.followUser() 28 | } 29 | ) 30 | .refreshable { 31 | loadData() 32 | } 33 | .sheet(isPresented: $viewModel.showEditProfile, onDismiss: { 34 | loadData() 35 | }, content: { 36 | EditProfileView() 37 | }) 38 | .toolbar { 39 | if viewModel.isAuthUser { 40 | ToolbarItem(placement: .navigationBarTrailing) { 41 | Button { 42 | viewModel.showSignOutAlert.toggle() 43 | } label: { 44 | Image(systemName: "rectangle.portrait.and.arrow.right") 45 | .foregroundColor(.black) 46 | .imageScale(.small) 47 | } 48 | } 49 | } 50 | } 51 | .navigationBarTitleDisplayMode(.inline) 52 | .padding(.horizontal) 53 | .modifier(LoadingAndErrorOverlayModifier(isLoading: $viewModel.isLoading, errorMessage: $viewModel.errorMessage)) 54 | .onAppear { 55 | loadData() 56 | } 57 | .alert(isPresented: $viewModel.showSignOutAlert) { 58 | Alert( 59 | title: Text("Are you sure?"), 60 | message: Text("Do you really want to sign out?"), 61 | primaryButton: .destructive(Text("Sign Out")) { 62 | viewModel.signOut() 63 | }, 64 | secondaryButton: .cancel() 65 | ) 66 | } 67 | } 68 | } 69 | 70 | private func loadData() { 71 | if let user = user { 72 | viewModel.loadUser(user: user) 73 | } else { 74 | viewModel.loadCurrentUser() 75 | } 76 | } 77 | } 78 | 79 | private struct ProfileViewContent: View { 80 | 81 | var user: UserBO? 82 | var isAuthUser: Bool 83 | @Binding var showEditProfile: Bool 84 | 85 | var onFollowUserTapped: (() -> Void)? 86 | 87 | var body: some View { 88 | ScrollView(showsIndicators: false) { 89 | VStack(spacing: 20) { 90 | ProfileHeaderView(user: user) 91 | if isAuthUser { 92 | Button { 93 | showEditProfile.toggle() 94 | } label: { 95 | Text("Edit Profile") 96 | .font(.subheadline) 97 | .fontWeight(.semibold) 98 | .foregroundColor(.black) 99 | .frame(width: 352, height: 32) 100 | .background(.white) 101 | .cornerRadius(8) 102 | .overlay { 103 | RoundedRectangle(cornerRadius: 8) 104 | .stroke(Color(.systemGray4), lineWidth: 1) 105 | } 106 | } 107 | } else { 108 | Button { 109 | onFollowUserTapped?() 110 | } label: { 111 | Text(user?.isFollowedByAuthUser ?? false ? "Following": "Follow") 112 | .font(.subheadline) 113 | .fontWeight(.semibold) 114 | .foregroundColor(.white) 115 | .frame(width: 352, height: 32) 116 | .background(.black) 117 | .cornerRadius(8) 118 | } 119 | } 120 | 121 | if let user = user { 122 | UserContentListView(user: user) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | 129 | struct ProfileView_Previews: PreviewProvider { 130 | static var previews: some View { 131 | ProfileView(user: dev.user) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Threads/Mapper/Impl/ThreadMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadMapper.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A class that implements the `Mapper` protocol to map a `ThreadDataMapper` to a `ThreadBO`. 11 | /// It uses a `UserMapper` to map the `UserDTO` to a `UserBO`. 12 | /// 13 | /// The `ThreadMapper` class is responsible for converting the raw data transfer objects (DTOs) into 14 | /// business objects (BOs) that are used in the application's domain layer. 15 | /// 16 | /// - The `ThreadMapper` class takes a `ThreadDataMapper` as input, which contains a `ThreadDTO` and a `UserDTO`. 17 | /// - It returns a `ThreadBO` (Business Object) that represents the thread in the application's domain layer. 18 | /// - The `UserMapper` dependency is used to transform the `UserDTO` into a `UserBO`. 19 | /// 20 | /// This class allows you to easily convert between data formats (DTOs) and the business objects used 21 | /// in the app, making it easier to manage and manipulate thread-related data throughout the application. 22 | /// 23 | /// Example usage: 24 | /// ```swift 25 | /// let userMapper = UserMapper() 26 | /// let threadMapper = ThreadMapper(userMapper: userMapper) 27 | /// let threadDataMapper = ThreadDataMapper(threadDTO: someThreadDTO, userDTO: someUserDTO, authUserId: someUserId) 28 | /// let threadBO = threadMapper.map(threadDataMapper) 29 | /// ``` 30 | /// 31 | class ThreadMapper: Mapper { 32 | /// Defines the input type as `ThreadDataMapper` and output type as `ThreadBO`. 33 | typealias Input = ThreadDataMapper 34 | typealias Output = ThreadBO 35 | 36 | // A userMapper instance responsible for mapping the `UserDTO` to `UserBO`. 37 | private let userMapper: UserMapper 38 | 39 | /// Initializes the `ThreadMapper` with a `UserMapper` dependency. 40 | /// 41 | /// - Parameter userMapper: An instance of `UserMapper` used to map `UserDTO` to `UserBO`. 42 | init(userMapper: UserMapper) { 43 | self.userMapper = userMapper 44 | } 45 | 46 | /// Maps a `ThreadDataMapper` to a `ThreadBO` object. 47 | /// 48 | /// This function converts the data transfer objects (`ThreadDTO` and `UserDTO`) into business objects (`ThreadBO` and `UserBO`). 49 | /// 50 | /// The `ThreadDTO` contains the thread's basic information such as its ID, caption, timestamp, and likes. 51 | /// The `UserDTO` contains information about the user who owns the thread, such as their username and profile image. 52 | /// The `ThreadMapper` maps these into a `ThreadBO`, which contains a `UserBO` and other business logic properties. 53 | /// 54 | /// - Parameter data: A `ThreadDataMapper` object containing the `ThreadDTO`, `UserDTO`, and the `authUserId`. 55 | /// - Returns: A `ThreadBO` object populated with the mapped data. 56 | /// 57 | /// - Note: This method also checks if the `authUserId` (the current authenticated user's ID) is present 58 | /// in the list of users who have liked the thread (`likedBy`), which is used to determine if the 59 | /// thread is liked by the authenticated user. This results in the `isLikedByAuthUser` flag in the `ThreadBO`. 60 | func map(_ data: ThreadDataMapper) -> ThreadBO { 61 | return ThreadBO( 62 | threadId: data.threadDTO.threadId, 63 | ownerUid: data.threadDTO.ownerUid, 64 | caption: data.threadDTO.caption, 65 | timestamp: data.threadDTO.timestamp, 66 | likes: data.threadDTO.likes, 67 | isLikedByAuthUser: data.threadDTO.likedBy.contains(data.authUserId), 68 | user: userMapper.map(UserDataMapper(userDTO: data.userDTO, authUserId: data.authUserId)) 69 | ) 70 | } 71 | } 72 | 73 | /// A struct that contains both the `ThreadDTO` and `UserDTO` data. 74 | /// 75 | /// This struct is used as an intermediary data structure that holds the raw data (DTOs) needed to create a `ThreadBO`. 76 | /// It allows the mapper to transform the `ThreadDTO` and `UserDTO` into a `ThreadBO`, a business object. 77 | /// 78 | /// - `threadDTO`: Represents the data transfer object for the thread. It contains information about the thread itself, 79 | /// such as the thread's unique ID (`threadId`), the owner ID (`ownerUid`), the thread's caption (`caption`), 80 | /// the timestamp of creation (`timestamp`), the number of likes (`likes`), and the list of users who liked the thread (`likedBy`). 81 | /// - `userDTO`: Represents the data transfer object for the user. It contains information about the user associated with the thread, 82 | /// such as their username and profile image URL. 83 | /// - `authUserId`: The ID of the currently authenticated user, used to determine if the current user has liked the thread. 84 | struct ThreadDataMapper { 85 | var threadDTO: ThreadDTO // Represents the thread data transfer object 86 | var userDTO: UserDTO // Represents the user data transfer object 87 | var authUserId: String // The ID of the authenticated user 88 | } 89 | -------------------------------------------------------------------------------- /Threads/View/Core/Components/ThreadCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadCell.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 18/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThreadCell: View { 11 | let thread: ThreadBO 12 | var onProfileImageTapped: (() -> AnyView)? 13 | var onLikeTapped: (() -> Void)? 14 | var onCommentTapped: (() -> Void)? 15 | var onShareTapped: (() -> Void)? 16 | var onRepostTapped: (() -> Void)? 17 | 18 | var body: some View { 19 | VStack(spacing: 12) { 20 | HStack(alignment: .top, spacing: 12) { 21 | 22 | if let destination = onProfileImageTapped { 23 | NavigationLink(destination: destination()) { 24 | // Profile image and user details 25 | CircularProfileImageView(profileImageUrl: thread.user?.profileImageUrl, size: .small) 26 | .shadow(radius: 1) 27 | } 28 | } else { 29 | CircularProfileImageView(profileImageUrl: thread.user?.profileImageUrl, size: .small) 30 | .shadow(radius: 1) 31 | } 32 | 33 | VStack(alignment: .leading, spacing: 6) { 34 | HStack { 35 | // Username and timestamp 36 | Text(thread.user?.username ?? "Unknown User") 37 | .font(.subheadline) 38 | .fontWeight(.semibold) 39 | .foregroundColor(.primary) 40 | 41 | Spacer() 42 | 43 | Text(thread.timestamp.timeAgoDisplay()) 44 | .font(.caption) 45 | .foregroundColor(Color.gray) 46 | } 47 | 48 | // Thread caption 49 | Text(thread.caption) 50 | .font(.subheadline) 51 | .foregroundColor(.primary) 52 | .lineLimit(4) 53 | .multilineTextAlignment(.leading) 54 | .padding(.bottom, 8) 55 | 56 | // Actions (like, comment, repost, share) 57 | HStack(spacing: 20) { 58 | Button(action: { 59 | onLikeTapped?() 60 | }) { 61 | HStack { 62 | Image(systemName: thread.isLikedByAuthUser ? "heart.fill" : "heart") 63 | .foregroundColor(.red) 64 | .font(.body) 65 | 66 | // Display number of likes 67 | if thread.likes > 0 { 68 | Text("\(thread.likes)") 69 | .font(.caption) 70 | .foregroundColor(.gray) 71 | } 72 | } 73 | } 74 | 75 | // Comment button 76 | Button(action: { 77 | onCommentTapped?() 78 | }) { 79 | Image(systemName: "bubble.right") 80 | .foregroundColor(.black) 81 | .font(.body) 82 | } 83 | 84 | // Repost button 85 | Button(action: { 86 | onRepostTapped?() 87 | }) { 88 | Image(systemName: "arrow.rectanglepath") 89 | .foregroundColor(.black) 90 | .font(.body) 91 | } 92 | 93 | // Share button 94 | Button(action: { 95 | onShareTapped?() 96 | }) { 97 | Image(systemName: "paperplane") 98 | .foregroundColor(.black) 99 | .font(.body) 100 | } 101 | } 102 | .padding(.top, 8) 103 | .foregroundColor(.primary) 104 | .font(.system(size: 20)) 105 | } 106 | } 107 | .padding(.horizontal) 108 | .padding(.vertical, 8) 109 | .background(Color.white) 110 | } 111 | .padding(.vertical, 8) 112 | } 113 | } 114 | 115 | struct ThreadCell_Previews: PreviewProvider { 116 | static var previews: some View { 117 | ThreadCell(thread: dev.thread) 118 | .previewLayout(.sizeThatFits) 119 | .padding() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Threads/Repositories/UserProfileRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserProfileRepository.swift 3 | // Threads 4 | // 5 | // Created by Sergio Sánchez Sánchez on 8/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Enum defining the possible errors that can occur within the UserProfileRepository. 11 | enum UserProfileRepositoryError: Error { 12 | 13 | /// Error when an operation related to storage (e.g., file upload) fails. 14 | case storageError(message: String) 15 | 16 | /// Error when the user profile update fails. 17 | case updateProfileFailed(message: String) 18 | 19 | /// Error when the user creation fails. 20 | case createUserFailed(message: String) 21 | 22 | /// Error when following or unfollowing a user fails. 23 | case followUserFailed(message: String) 24 | 25 | /// Error when retrieving user data fails. 26 | case getUserFailed(message: String) 27 | 28 | /// Error when fetching user suggestions fails. 29 | case getSuggestionsFailed(message: String) 30 | 31 | /// Error when checking username availability fails. 32 | case checkUsernameAvailabilityFailed(message: String) 33 | 34 | /// Error when searching for users fails. 35 | case searchUsersFailed(message: String) 36 | 37 | /// Error when retrieving the list of users the user is following. 38 | case followingFailed(message: String) 39 | 40 | /// Error when retrieving the list of users who are following the user. 41 | case followersFailed(message: String) 42 | 43 | /// Error when there is a failure in user-related operations that doesn't fit other categories. 44 | case generic(message: String) 45 | } 46 | 47 | 48 | /// A repository for user profile-related operations. 49 | protocol UserProfileRepository { 50 | 51 | /// Updates an existing user's profile with the provided details. 52 | /// 53 | /// - Parameter data: An `UpdateUserBO` object containing the updated user information. 54 | /// - Returns: The updated `UserBO` object representing the user. 55 | /// - Throws: Any error encountered during the profile update process. 56 | func updateUser(data: UpdateUserBO) async throws -> UserBO 57 | 58 | 59 | /// Creates a new user profile with the provided details. 60 | /// 61 | /// - Parameter data: A `CreateUserBO` object containing the new user's information. 62 | /// - Returns: The newly created `UserBO` object representing the user. 63 | /// - Throws: Any error encountered during the user creation process. 64 | func createUser(data: CreateUserBO) async throws -> UserBO 65 | 66 | /// Retrieves user information asynchronously. 67 | /// - Parameter userId: The ID of the user to retrieve. 68 | /// - Returns: A `User` object representing the retrieved user. 69 | /// - Throws: An error if user retrieval fails. 70 | func getUser(userId: String) async throws -> UserBO 71 | 72 | /// Checks the availability of a username asynchronously. 73 | /// - Parameter username: The username to check for availability. 74 | /// - Returns: A boolean value indicating whether the username is available or not. 75 | /// - Throws: An error if the availability check fails. 76 | func checkUsernameAvailability(username: String) async throws -> Bool 77 | 78 | /// Fetches user suggestions for the specified authenticated user asynchronously. 79 | /// - Parameter authUserId: The ID of the authenticated user for whom to fetch suggestions. 80 | /// - Returns: An array of `User` objects representing user suggestions. 81 | /// - Throws: An error if suggestion retrieval fails. 82 | func getSuggestions(authUserId: String) async throws -> [UserBO] 83 | 84 | /// Allows a user to follow or unfollow another user asynchronously. 85 | /// - Parameters: 86 | /// - authUserId: The ID of the user performing the follow/unfollow action. 87 | /// - targetUserId: The ID of the user to be followed or unfollowed. 88 | /// - Throws: An error if the operation fails, including errors specified in `UserDataSourceError`. 89 | func followUser(authUserId: String, targetUserId: String) async throws 90 | 91 | /// Searches for users based on a provided search term asynchronously. 92 | /// 93 | /// - Parameter searchTerm: A string representing the term to search for (e.g., username, fullname). 94 | /// - Returns: An array of `UserBO` objects that match the search criteria. 95 | /// - Throws: An error if the search operation fails. 96 | func searchUsers(searchTerm: String) async throws -> [UserBO] 97 | 98 | /// Retrieves the list of users that the user is following. 99 | /// - Parameter userId: The ID of the user. 100 | /// - Returns: An array of `UserBO` objects representing users that the user is following. 101 | /// - Throws: An error if the retrieval fails. 102 | func getFollowing(userId: String) async throws -> [UserBO] 103 | 104 | /// Retrieves the list of users who are following the user. 105 | /// - Parameter userId: The ID of the user. 106 | /// - Returns: An array of `UserBO` objects representing users who are following the user. 107 | /// - Throws: An error if the retrieval fails. 108 | func getFollowers(userId: String) async throws -> [UserBO] 109 | } 110 | --------------------------------------------------------------------------------