├── Mintfile ├── Modules ├── Features │ ├── Settings │ │ ├── Sources │ │ │ ├── Settings │ │ │ │ ├── Classes │ │ │ │ │ ├── Generated │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── DI │ │ │ │ │ │ ├── PublicAssembly │ │ │ │ │ │ │ └── SettingsPublicAssembly.swift │ │ │ │ │ │ └── PresentationAssembly │ │ │ │ │ │ │ └── SettingsPresentationAssembly.swift │ │ │ │ │ └── UI │ │ │ │ │ │ └── Presentation │ │ │ │ │ │ ├── UserStories │ │ │ │ │ │ └── RootSettings │ │ │ │ │ │ │ ├── RootSettingsAssembly.swift │ │ │ │ │ │ │ ├── RootSettingsFeature.swift │ │ │ │ │ │ │ └── RootSettingsView.swift │ │ │ │ │ │ └── SharedComponents │ │ │ │ │ │ └── UIComponents │ │ │ │ │ │ └── MailView │ │ │ │ │ │ └── MailView.swift │ │ │ │ └── Resources │ │ │ │ │ └── Assets.xcassets │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── Settings │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── header-icon.imageset │ │ │ │ │ ├── app_icon-60@2x.png │ │ │ │ │ ├── app_icon-60@3x.png │ │ │ │ │ └── Contents.json │ │ │ └── SettingsInterfaces │ │ │ │ └── Classes │ │ │ │ └── DI │ │ │ │ └── ISettingsPublicAssembly.swift │ │ ├── Makefile │ │ ├── .gitignore │ │ ├── Tests │ │ │ └── SettingsTests │ │ │ │ └── SettingsTests.swift │ │ ├── swiftgen.yml │ │ └── Package.swift │ └── Home │ │ ├── README.md │ │ ├── .gitignore │ │ ├── Tests │ │ └── HomeTests │ │ │ └── HomeTests.swift │ │ ├── Sources │ │ ├── HomeInterfaces │ │ │ └── Classes │ │ │ │ └── DI │ │ │ │ └── IHomePublicAssembly.swift │ │ └── Home │ │ │ └── Classes │ │ │ ├── Core │ │ │ ├── Providers │ │ │ │ ├── AppNameProvider │ │ │ │ │ ├── IAppNameProvider.swift │ │ │ │ │ └── AppNameProvider.swift │ │ │ │ └── PageLoaderProvider │ │ │ │ │ └── PageLoaderProvider.swift │ │ │ ├── Services │ │ │ │ ├── CommentsService │ │ │ │ │ ├── ICommentsService.swift │ │ │ │ │ ├── Requests │ │ │ │ │ │ └── CommentRequest.swift │ │ │ │ │ ├── CommentsService.swift │ │ │ │ │ └── Responses │ │ │ │ │ │ └── Comment.swift │ │ │ │ └── PostsService │ │ │ │ │ ├── IPostsService.swift │ │ │ │ │ ├── Requests │ │ │ │ │ ├── BaseRequest.swift │ │ │ │ │ ├── PostRequest.swift │ │ │ │ │ └── PostIdentifiersRequest.swift │ │ │ │ │ ├── PostsService.swift │ │ │ │ │ └── Responses │ │ │ │ │ └── Post.swift │ │ │ └── DI │ │ │ │ └── DependencyValues │ │ │ │ ├── DependencyValues+PostsPager.swift │ │ │ │ ├── DependencyValues+PostsService.swift │ │ │ │ ├── DependencyValues+CommentsPager.swift │ │ │ │ ├── DependencyValues+RepliesService.swift │ │ │ │ ├── DependencyValues+PostsViewModelFactory.swift │ │ │ │ ├── DependencyValues+RepliesViewModelFactory.swift │ │ │ │ └── DependencyValues+PostDetailViewModelFactory.swift │ │ │ ├── UI │ │ │ └── Presentation │ │ │ │ ├── SharedComponents │ │ │ │ ├── Helpers │ │ │ │ │ └── Bootstrappable │ │ │ │ │ │ ├── IBootstrappable.swift │ │ │ │ │ │ └── BootstrappableAssembly.swift │ │ │ │ └── UIComponents │ │ │ │ │ ├── ImageView │ │ │ │ │ └── ImageView.swift │ │ │ │ │ ├── CommentView │ │ │ │ │ ├── CommentHeaderView.swift │ │ │ │ │ └── CommentView.swift │ │ │ │ │ ├── SafariView │ │ │ │ │ └── SafariView.swift │ │ │ │ │ ├── IndicatorView │ │ │ │ │ └── IndicatorView.swift │ │ │ │ │ ├── SegmentControlView │ │ │ │ │ ├── SegmentControlItemView.swift │ │ │ │ │ └── SegmentControlView.swift │ │ │ │ │ └── ArticleView │ │ │ │ │ └── ArticleView.swift │ │ │ │ └── UserStories │ │ │ │ ├── PostDetail │ │ │ │ ├── CommentsPager │ │ │ │ │ ├── LoadBehaviour.swift │ │ │ │ │ ├── ICommentsPager.swift │ │ │ │ │ ├── CommentsPager.swift │ │ │ │ │ └── CommentsPaginatorService.swift │ │ │ │ ├── Views │ │ │ │ │ ├── Styles │ │ │ │ │ │ └── RepliesButtonStyle.swift │ │ │ │ │ └── ShortCommentView.swift │ │ │ │ ├── PostDetailAssembly.swift │ │ │ │ ├── PostDetailViewModelFactory.swift │ │ │ │ ├── PostDetailFeature.swift │ │ │ │ └── PostDetailView.swift │ │ │ │ ├── Replies │ │ │ │ ├── RepliesService │ │ │ │ │ ├── ReplyComment.swift │ │ │ │ │ ├── IRepliesService.swift │ │ │ │ │ └── RepliesService.swift │ │ │ │ ├── RepliesAssembly.swift │ │ │ │ ├── RepliesFeature.swift │ │ │ │ ├── Views │ │ │ │ │ └── RepliesCommentView.swift │ │ │ │ ├── RepliesViewModelFactory.swift │ │ │ │ └── RepliesView.swift │ │ │ │ └── Posts │ │ │ │ ├── Model │ │ │ │ ├── Page+Map.swift │ │ │ │ └── PostType.swift │ │ │ │ ├── PostsPager │ │ │ │ ├── PostsPager.swift │ │ │ │ └── PostsPaginatorService.swift │ │ │ │ ├── Views │ │ │ │ ├── NavigationTitleView │ │ │ │ │ ├── NavigationTitleView.swift │ │ │ │ │ ├── NavigationTitleViewStore.swift │ │ │ │ │ └── NavigationTitleAssembly.swift │ │ │ │ ├── PostSidebarView.swift │ │ │ │ └── PostListView.swift │ │ │ │ ├── PostViewModelFactory.swift │ │ │ │ ├── PostsAssembly.swift │ │ │ │ ├── PostsViewStore.swift │ │ │ │ └── PostsView.swift │ │ │ └── DI │ │ │ ├── PublicAssembly │ │ │ └── HomePublicAssembly.swift │ │ │ ├── ServicesAssembly │ │ │ └── HomeServicesAssembly.swift │ │ │ └── PresentationAssembly │ │ │ └── HomePresentationAssembly.swift │ │ └── Package.swift └── Common │ ├── DesignKit │ ├── Sources │ │ └── DesignKit │ │ │ ├── Classes │ │ │ └── Design │ │ │ │ ├── Generated │ │ │ │ └── .gitkeep │ │ │ │ └── Core │ │ │ │ └── CGFloat+.swift │ │ │ └── Resources │ │ │ ├── Colors.xcassets │ │ │ ├── Contents.json │ │ │ ├── dynamic_gray.colorset │ │ │ │ └── Contents.json │ │ │ └── dynamic_light_gray.colorset │ │ │ │ └── Contents.json │ │ │ └── Fonts │ │ │ ├── Montserrat-Bold.ttf │ │ │ ├── Montserrat-Thin.ttf │ │ │ ├── Montserrat-Black.ttf │ │ │ ├── Montserrat-Italic.ttf │ │ │ ├── Montserrat-Light.ttf │ │ │ ├── Montserrat-Medium.ttf │ │ │ ├── Montserrat-ExtraBold.ttf │ │ │ ├── Montserrat-Regular.ttf │ │ │ ├── Montserrat-SemiBold.ttf │ │ │ ├── Montserrat-BlackItalic.ttf │ │ │ ├── Montserrat-BoldItalic.ttf │ │ │ ├── Montserrat-ExtraLight.ttf │ │ │ ├── Montserrat-LightItalic.ttf │ │ │ ├── Montserrat-ThinItalic.ttf │ │ │ ├── Montserrat-MediumItalic.ttf │ │ │ ├── Montserrat-SemiBoldItalic.ttf │ │ │ ├── Montserrat-ExtraBoldItalic.ttf │ │ │ └── Montserrat-ExtraLightItalic.ttf │ ├── README.md │ ├── Makefile │ ├── .gitignore │ ├── swiftgen.yml │ ├── Tests │ │ └── DesignKitTests │ │ │ └── DesignKitTests.swift │ └── Package.swift │ ├── AppUtils │ ├── README.md │ ├── .gitignore │ ├── Tests │ │ └── AppUtilsTests │ │ │ └── AppUtilsTests.swift │ ├── Sources │ │ └── AppUtils │ │ │ └── Classes │ │ │ ├── Helpers │ │ │ └── DateFormatter+.swift │ │ │ ├── Core │ │ │ └── IDateFormatter.swift │ │ │ └── DI │ │ │ ├── Locator.swift │ │ │ └── AppAssembly.swift │ └── Package.swift │ ├── HackerNewsLocalization │ ├── Sources │ │ └── HackerNewsLocalization │ │ │ ├── Classes │ │ │ └── .gitkeep │ │ │ ├── HackerNewsLocalization.swift │ │ │ └── Resources │ │ │ └── en.lproj │ │ │ └── Localizable.strings │ ├── README.md │ ├── Makefile │ ├── .gitignore │ ├── swiftgen.yml │ └── Package.swift │ └── UIExtensions │ ├── README.md │ ├── .gitignore │ ├── Sources │ └── UIExtensions │ │ └── Classes │ │ ├── Extensions │ │ ├── UIFont+SUI.swift │ │ ├── View │ │ │ ├── View+EraseToAnyView.swift │ │ │ └── View+Shake.swift │ │ └── UIWindow+MotionEnded.swift │ │ ├── Helpers │ │ ├── UIDevice+Notification.swift │ │ └── NSAttributedString+HTML.swift │ │ └── ViewModifiers │ │ └── DeviceShakeViewModifier.swift │ ├── Tests │ └── UIExtensionsTests │ │ └── UIExtensionsTests.swift │ └── Package.swift ├── HackerNews ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── icon.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Configurations │ │ ├── Beta.xcconfig │ │ ├── Release.xcconfig │ │ ├── Base.xcconfig │ │ └── Debug.xcconfig │ └── Info.plist └── Classes │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── ApplicationRoot │ ├── View │ │ └── TabBar │ │ │ ├── RootTabBarViewStore.swift │ │ │ ├── Tab.swift │ │ │ ├── Factory │ │ │ └── RootTabBarViewFactory.swift │ │ │ ├── RootTabBarAssembly.swift │ │ │ └── RootTabBarView.swift │ └── Assembly │ │ ├── DependenciesAssembly.swift │ │ └── ApplicationAssembly.swift │ └── HackerNewsApp.swift ├── HackerNewsTests └── HackerNewsTests.swift ├── fastlane ├── Pluginfile ├── Matchfile ├── Appfile ├── report.xml ├── README.md └── Fastfile ├── Gemfile ├── scripts └── setup_build_tools.sh ├── Makefile ├── hooks └── pre-commit ├── .github └── workflows │ └── ios.yml ├── .swiftformat ├── README.md ├── .gitignore ├── .swiftlint.yml └── project.yml /Mintfile: -------------------------------------------------------------------------------- 1 | nicklockwood/SwiftFormat@0.54.0 2 | realm/SwiftLint@0.55.1 -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/Generated/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Classes/Design/Generated/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Modules/Features/Home/README.md: -------------------------------------------------------------------------------- 1 | # Home 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/README.md: -------------------------------------------------------------------------------- 1 | # AppUtils 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/Sources/HackerNewsLocalization/Classes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/README.md: -------------------------------------------------------------------------------- 1 | # DesignKit 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/README.md: -------------------------------------------------------------------------------- 1 | # UIExtensions 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/README.md: -------------------------------------------------------------------------------- 1 | # HackerNewsLocalization 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Makefile: -------------------------------------------------------------------------------- 1 | ## swiftgen: Trigger code generation from assets with swiftgen tool 2 | swiftgen: 3 | swiftgen -------------------------------------------------------------------------------- /Modules/Features/Settings/Makefile: -------------------------------------------------------------------------------- 1 | ## swiftgen: Trigger code generation from assets with swiftgen tool 2 | swiftgen: 3 | swiftgen -------------------------------------------------------------------------------- /HackerNews/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HackerNewsTests/HackerNewsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Nikita Vasilev 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/Makefile: -------------------------------------------------------------------------------- 1 | ## swiftgen: Trigger code generation from assets with swiftgen tool 2 | swiftgen: 3 | swiftgen -------------------------------------------------------------------------------- /HackerNews/Resources/Configurations/Beta.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Base.xcconfig" 2 | 3 | ENVIRONMENT = debug 4 | APP_GROUP = group.$(BASE_BUNDLE_ID).beta -------------------------------------------------------------------------------- /HackerNews/Resources/Configurations/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Base.xcconfig" 2 | 3 | ENVIRONMENT = production 4 | APP_GROUP = $(BASE_BUNDLE_ID) -------------------------------------------------------------------------------- /fastlane/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | gem 'fastlane-plugin-badge' -------------------------------------------------------------------------------- /HackerNews/Resources/Configurations/Base.xcconfig: -------------------------------------------------------------------------------- 1 | HTTPS = https:/ 2 | 3 | BASE_BUNDLE_ID = com.nikitavasilev.hackernews 4 | DEVELOPMENT_TEAM = ZY8BJSR866 -------------------------------------------------------------------------------- /HackerNews/Resources/Configurations/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Base.xcconfig" 2 | 3 | ENVIRONMENT = debug 4 | APP_GROUP = group.$(BASE_BUNDLE_ID).debug -------------------------------------------------------------------------------- /HackerNews/Classes/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HackerNews/Resources/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/HackerNews/Resources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Thin.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Black.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Italic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Light.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Medium.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraBold.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-SemiBold.ttf -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url "git@github.com:nik3212/certs-ios.git" 2 | clone_branch_directly true 3 | app_identifier(["com.nikitavasilev.HackerNews", "com.nikitavasilev.HackerNews.beta", "com.nikitavasilev.HackerNews.debug"]) -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-BlackItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-BoldItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraLight.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-LightItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ThinItalic.ttf -------------------------------------------------------------------------------- /Modules/Features/Settings/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-MediumItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraBoldItalic.ttf -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Common/DesignKit/Sources/DesignKit/Resources/Fonts/Montserrat-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "dotenv" 4 | gem "fastlane" 5 | gem "slather" 6 | 7 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 8 | eval_gemfile(plugins_path) if File.exist?(plugins_path) -------------------------------------------------------------------------------- /Modules/Features/Home/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /HackerNews/Resources/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 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Modules/Features/Home/Tests/HomeTests/HomeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | @testable import Home 7 | import XCTest 8 | 9 | final class HomeTests: XCTestCase {} 10 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "provides-namespace" : true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Tests/AppUtilsTests/AppUtilsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | @testable import AppUtils 7 | import XCTest 8 | 9 | final class AppUtilsTests: XCTestCase {} 10 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Tests/SettingsTests/SettingsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | @testable import Settings 7 | import XCTest 8 | 9 | final class SettingsTests: XCTestCase {} 10 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/header-icon.imageset/app_icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/header-icon.imageset/app_icon-60@2x.png -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/header-icon.imageset/app_icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns-vasilev/HackerNews/HEAD/Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/header-icon.imageset/app_icon-60@3x.png -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/HomeInterfaces/Classes/DI/IHomePublicAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public protocol IHomePublicAssembly { 9 | func assemble() -> AnyView 10 | } 11 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | team_id "A8WE5LL2GU" 2 | itc_team_name "Nikita Vasilev" 3 | itc_team_id "118933826" 4 | 5 | for_platform :ios do 6 | app_identifier "com.nikitavasilev.HackerNews" 7 | 8 | for_lane :beta do 9 | app_identifier "com.nikitavasilev.HackerNews.beta" 10 | end 11 | end -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/SettingsInterfaces/Classes/DI/ISettingsPublicAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public protocol ISettingsPublicAssembly { 9 | func assemble() -> AnyView 10 | } 11 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Providers/AppNameProvider/IAppNameProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | protocol IAppNameProvider { 9 | var applicationName: String { get } 10 | } 11 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/Helpers/Bootstrappable/IBootstrappable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | protocol IBootstrappable { 9 | func bootstrap() 10 | } 11 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Extensions/UIFont+SUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public extension UIFont { 9 | var sui: SwiftUI.Font { 10 | SwiftUI.Font(self) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/CommentsService/ICommentsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | protocol ICommentsService { 9 | func loadComment(id: Int) async throws -> Comment 10 | } 11 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/CommentsPager/LoadBehaviour.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | enum LoadBehaviour { 9 | case reload 10 | case useCache 11 | } 12 | -------------------------------------------------------------------------------- /Modules/Features/Settings/swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Sources/Settings/Resources 2 | output_dir: Sources/Settings/Classes/Generated 3 | xcassets: 4 | inputs: Assets.xcassets 5 | outputs: 6 | templateName: swift5 7 | output: Assets.swift 8 | params: 9 | publicAccess: true 10 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/Sources/HackerNewsLocalization/HackerNewsLocalization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | public struct HackerNewsLocalization { 7 | public private(set) var text = "Hello, World!" 8 | 9 | public init() {} 10 | } 11 | -------------------------------------------------------------------------------- /HackerNews/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Extensions/View/View+EraseToAnyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public extension View { 9 | func eraseToAnyView() -> AnyView { 10 | AnyView(self) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesService/ReplyComment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | struct ReplyComment { 9 | var comment: Comment 10 | var replies: [ReplyComment] = [] 11 | } 12 | -------------------------------------------------------------------------------- /scripts/setup_build_tools.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | which -s xcodegen 4 | if [[ $? != 0 ]] ; then 5 | # Install xcodegen 6 | echo "Installing xcodegen." 7 | brew install xcodegen 8 | fi 9 | 10 | which -s swiftgen 11 | if [[ $? != 0 ]] ; then 12 | # Install swiftgen 13 | echo "Installing swiftgen." 14 | brew install swiftgen 15 | fi -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Helpers/UIDevice+Notification.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | extension UIDevice { 9 | static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") 10 | } 11 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesService/IRepliesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | 9 | protocol IRepliesService { 10 | func loadComments(for commentID: Int) async throws -> ReplyComment 11 | } 12 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/IPostsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | protocol IPostsService { 9 | func loadIDs(for type: PostType) async throws -> [Int] 10 | func loadPosts(with ids: [Int]) async throws -> [Post] 11 | } 12 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Sources/HackerNewsLocalization/Resources 2 | output_dir: Sources/HackerNewsLocalization/Classes 3 | strings: 4 | inputs: 5 | - en.lproj/Localizable.strings 6 | outputs: 7 | templateName: structured-swift5 8 | output: Strings.swift 9 | params: 10 | publicAccess: true 11 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Extensions/View/View+Shake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | public extension View { 9 | func onShake(perform action: @escaping () -> Void) -> some View { 10 | modifier(DeviceShakeViewModifier(action: action)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Sources/AppUtils/Classes/Helpers/DateFormatter+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension DateFormatter { 9 | static var EEEEMMMd: DateFormatter = { 10 | let formatter = DateFormatter() 11 | formatter.dateFormat = "EEEE, MMM d" 12 | return formatter 13 | }() 14 | } 15 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Sources/AppUtils/Classes/Core/IDateFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - IDateFormatter 9 | 10 | public protocol IDateFormatter { 11 | func string(from date: Date) -> String 12 | } 13 | 14 | // MARK: - DateFormatter + IDateFormatter 15 | 16 | extension DateFormatter: IDateFormatter {} 17 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Classes/Design/Core/CGFloat+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension CGFloat { 9 | /// 12px 10 | static let size12 = 12.0 11 | /// 13px 12 | static let size13 = 13.0 13 | /// 15px 14 | static let size15 = 15.0 15 | /// 17px 16 | static let size17 = 17.0 17 | } 18 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/Helpers/Bootstrappable/BootstrappableAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | class BootstrappableAssembly: IBootstrappable { 9 | // MARK: Initialization 10 | 11 | init() { 12 | bootstrap() 13 | } 14 | 15 | // MARK: IBootstrappable 16 | 17 | func bootstrap() {} 18 | } 19 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Sources/DesignKit/Resources 2 | output_dir: Sources/DesignKit/Classes/Design/Generated 3 | xcassets: 4 | inputs: Colors.xcassets 5 | outputs: 6 | templateName: swift5 7 | output: Colors.swift 8 | params: 9 | publicAccess: true 10 | fonts: 11 | inputs: Fonts 12 | outputs: 13 | templateName: swift4 14 | output: Fonts.swift 15 | params: 16 | publicAccess: 1 17 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Extensions/UIWindow+MotionEnded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | extension UIWindow { 9 | override open func motionEnded(_ motion: UIEvent.EventSubtype, with _: UIEvent?) { 10 | if motion == .motionShake { 11 | NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/Requests/BaseRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import NetworkLayerInterfaces 8 | 9 | class BaseRequest: IRequest { 10 | var path: String { "" } 11 | 12 | var httpMethod: NetworkLayerInterfaces.HTTPMethod { 13 | .get 14 | } 15 | 16 | var domainName: String { 17 | "https://hacker-news.firebaseio.com/v0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Tests/DesignKitTests/DesignKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | @testable import DesignKit 7 | import XCTest 8 | 9 | final class DesignKitTests: XCTestCase { 10 | func testExample() throws { 11 | // This is an example of a functional test case. 12 | // Use XCTAssert and related functions to verify your tests produce the correct 13 | // results. 14 | XCTAssertEqual(DesignKit().text, "Hello, World!") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/Requests/PostRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final class PostRequest: BaseRequest { 9 | // MARK: Properties 10 | 11 | private let id: Int 12 | 13 | // MARK: Initialization 14 | 15 | init(id: Int) { 16 | self.id = id 17 | } 18 | 19 | // MARK: BaseRequest 20 | 21 | override var path: String { 22 | "item/\(id).json" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Resources/Assets.xcassets/Settings/header-icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "app_icon-60@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "app_icon-60@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/CommentsService/Requests/CommentRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final class CommentRequest: BaseRequest { 9 | // MARK: Properties 10 | 11 | private let id: Int 12 | 13 | // MARK: Initialization 14 | 15 | init(id: Int) { 16 | self.id = id 17 | } 18 | 19 | // MARK: BaseRequest 20 | 21 | override var path: String { 22 | "item/\(id).json" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/Views/Styles/RepliesButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct RepliesButtonStyle: ButtonStyle { 9 | func makeBody(configuration: Configuration) -> some View { 10 | configuration.label 11 | .foregroundStyle(configuration.isPressed ? Color.orange.opacity(0.8) : Color.orange) 12 | .font(.footnote) 13 | .fontWeight(.bold) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Tests/UIExtensionsTests/UIExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | @testable import UIExtensions 7 | import XCTest 8 | 9 | final class UIExtensionsTests: XCTestCase { 10 | func testExample() throws { 11 | // This is an example of a functional test case. 12 | // Use XCTAssert and related functions to verify your tests produce the correct 13 | // results. 14 | XCTAssertEqual(UIExtensions().text, "Hello, World!") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /HackerNews/Classes/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import DesignKit 7 | import Pulse 8 | import UIKit 9 | 10 | class AppDelegate: NSObject, UIApplicationDelegate { 11 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 12 | #if DEBUG 13 | URLSessionProxyDelegate.enableAutomaticRegistration() 14 | #endif 15 | FontFamily.registerAllCustomFonts() 16 | return true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/ViewModifiers/DeviceShakeViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct DeviceShakeViewModifier: ViewModifier { 9 | let action: () -> Void 10 | 11 | func body(content: Content) -> some View { 12 | content 13 | .onAppear() 14 | .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in 15 | action() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+PostsPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | 9 | extension DependencyValues { 10 | var postsPager: PostsPager { 11 | get { self[PostsPagerKey.self] } 12 | set { self[PostsPagerKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - PostsPagerKey 17 | 18 | private enum PostsPagerKey: DependencyKey { 19 | static var liveValue: PostsPager = Locator.shared.resolve() 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+PostsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | 9 | extension DependencyValues { 10 | var postsService: IPostsService { 11 | get { self[PostsServiceKey.self] } 12 | set { self[PostsServiceKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - PostsServiceKey 17 | 18 | private enum PostsServiceKey: DependencyKey { 19 | static var liveValue: IPostsService = Locator.shared.resolve() 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+CommentsPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | 9 | extension DependencyValues { 10 | var commentsPager: ICommentsPager { 11 | get { self[CommentsPagerKey.self] } 12 | set { self[CommentsPagerKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - CommentsPagerKey 17 | 18 | private enum CommentsPagerKey: DependencyKey { 19 | static var liveValue: ICommentsPager = Locator.shared.resolve() 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/ImageView/ImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Kingfisher 7 | import SwiftUI 8 | 9 | // MARK: - ImageView 10 | 11 | struct ImageView: View { 12 | // MARK: Properties 13 | 14 | private let url: URL 15 | 16 | // MARK: Initialization 17 | 18 | init(url: URL) { 19 | self.url = url 20 | } 21 | 22 | // MARK: - View 23 | 24 | var body: some View { 25 | KFImage.url(url) 26 | .resizable() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/DI/PublicAssembly/SettingsPublicAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SettingsInterfaces 7 | import SwiftUI 8 | 9 | public final class SettingsPublicAssembly: ISettingsPublicAssembly { 10 | private let presentationAssembly: ISettingsPresentationAssembly 11 | 12 | public init() { 13 | presentationAssembly = SettingsPresentationAssembly() 14 | } 15 | 16 | public func assemble() -> AnyView { 17 | presentationAssembly.rootSettingsAssembly.assemble() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CHILD_MAKEFILES_DIRS = $(sort $(dir $(wildcard Modules/*/*/Makefile))) 2 | 3 | all: bootstrap swiftgen 4 | 5 | bootstrap: hook scripts 6 | mint bootstrap 7 | 8 | hook: 9 | ln -sf ../../hooks/pre-commit .git/hooks/pre-commit 10 | chmod +x .git/hooks/pre-commit 11 | 12 | mint: 13 | mint bootstrap 14 | 15 | lint: 16 | mint run swiftlint 17 | 18 | fmt: 19 | mint run swiftformat Sources Tests 20 | 21 | setup_build_tools: 22 | sh scripts/setup_build_tools.sh 23 | 24 | swiftgen: 25 | @for d in $(CHILD_MAKEFILES_DIRS); do ( cd $$d && make swiftgen; ); done 26 | 27 | .PHONY: all bootstrap hook mint lint fmt setup_build_tools -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+RepliesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | 9 | extension DependencyValues { 10 | var repliesService: IRepliesService { 11 | get { self[RepliesServiceKey.self] } 12 | set { self[RepliesServiceKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - RepliesServiceKey 17 | 18 | private enum RepliesServiceKey: DependencyKey { 19 | static var liveValue: IRepliesService = Locator.shared.resolve() 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/DI/PresentationAssembly/SettingsPresentationAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - ISettingsPresentationAssembly 9 | 10 | protocol ISettingsPresentationAssembly { 11 | var rootSettingsAssembly: IRootSettingsAssembly { get } 12 | } 13 | 14 | // MARK: - SettingsPresentationAssembly 15 | 16 | final class SettingsPresentationAssembly: ISettingsPresentationAssembly { 17 | var rootSettingsAssembly: any IRootSettingsAssembly { 18 | RootSettingsAssembly() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable prefixed_toplevel_constant 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "UIExtensions", 9 | platforms: [.iOS(.v13)], 10 | products: [ 11 | .library(name: "UIExtensions", targets: ["UIExtensions"]), 12 | ], 13 | dependencies: [], 14 | targets: [ 15 | .target(name: "UIExtensions", dependencies: []), 16 | .testTarget(name: "UIExtensionsTests", dependencies: ["UIExtensions"]), 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable all 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "HackerNewsLocalization", 9 | defaultLocalization: "en", 10 | platforms: [ 11 | .iOS(.v16), 12 | ], 13 | products: [ 14 | .library(name: "HackerNewsLocalization", targets: ["HackerNewsLocalization"]), 15 | ], 16 | dependencies: [], 17 | targets: [ 18 | .target(name: "HackerNewsLocalization", dependencies: []), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/CommentsPager/ICommentsPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | 9 | // MARK: - ICommentsPager 10 | 11 | protocol ICommentsPager { 12 | func load(request: OffsetPaginationRequest, behaviour: LoadBehaviour, postID: Int) async throws -> Page 13 | } 14 | 15 | extension ICommentsPager { 16 | func load(request: OffsetPaginationRequest, postID: Int) async throws -> Page { 17 | try await load(request: request, behaviour: .useCache, postID: postID) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+PostsViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import Foundation 8 | 9 | extension DependencyValues { 10 | var postsViewModelFactory: IPostViewModelFactory { 11 | get { self[PostViewModelFactoryKey.self] } 12 | set { self[PostViewModelFactoryKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - PostViewModelFactoryKey 17 | 18 | private enum PostViewModelFactoryKey: DependencyKey { 19 | static var liveValue: IPostViewModelFactory = PostViewModelFactory(dateTimeFormatter: RelativeDateTimeFormatter()) 20 | } 21 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/View/TabBar/RootTabBarViewStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import Foundation 8 | import OrderedCollections 9 | 10 | struct RootTabBarViewStore: Reducer { 11 | struct State: Equatable, Hashable { 12 | @BindingState var tabs: OrderedSet 13 | @BindingState var tab: Tab 14 | } 15 | 16 | enum Action { 17 | case tab(Tab) 18 | } 19 | 20 | var body: some ReducerOf { 21 | Reduce { _, action in 22 | switch action { 23 | case .tab: 24 | return .none 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable all 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "DesignKit", 9 | platforms: [.iOS(.v16)], 10 | products: [ 11 | .library(name: "DesignKit", targets: ["DesignKit"]), 12 | ], 13 | dependencies: [], 14 | targets: [ 15 | .target( 16 | name: "DesignKit", 17 | dependencies: [], 18 | resources: [ 19 | .process("Resources"), 20 | ] 21 | ), 22 | .testTarget(name: "DesignKitTests", dependencies: ["DesignKit"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+RepliesViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import Foundation 8 | 9 | extension DependencyValues { 10 | var repliesViewModelFactory: IRepliesViewModelFactory { 11 | get { self[RepliesViewModelFactoryKey.self] } 12 | set { self[RepliesViewModelFactoryKey.self] = newValue } 13 | } 14 | } 15 | 16 | // MARK: - RepliesViewModelFactoryKey 17 | 18 | private enum RepliesViewModelFactoryKey: DependencyKey { 19 | static var liveValue: IRepliesViewModelFactory = RepliesViewModelFactory(dateTimeFormatter: RelativeDateTimeFormatter()) 20 | } 21 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/View/TabBar/Tab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import HackerNewsLocalization 8 | 9 | enum Tab: Int, Identifiable { 10 | case home 11 | case settings 12 | 13 | var id: Self { self } 14 | 15 | var name: String { 16 | switch self { 17 | case .home: 18 | return L10n.TabBar.Item.home 19 | case .settings: 20 | return L10n.TabBar.Item.settings 21 | } 22 | } 23 | 24 | var iconSystemName: String { 25 | switch self { 26 | case .home: 27 | return "house" 28 | case .settings: 29 | return "gearshape" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable prefixed_toplevel_constant 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "AppUtils", 9 | products: [ 10 | .library(name: "AppUtils", targets: ["AppUtils"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/AliSoftware/Dip.git", .upToNextMajor(from: "7.1.1")), 14 | ], 15 | targets: [ 16 | .target(name: "AppUtils", dependencies: [ 17 | .product(name: "Dip", package: "Dip"), 18 | ]), 19 | .testTarget(name: "AppUtilsTests", dependencies: ["AppUtils"]), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/DI/DependencyValues/DependencyValues+PostDetailViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import Foundation 8 | 9 | // MARK: - PostDetailViewModelFactoryKey 10 | 11 | private enum PostDetailViewModelFactoryKey: DependencyKey { 12 | static var liveValue: IPostDetailViewModelFactory = PostDetailViewModelFactory(dateTimeFormatter: RelativeDateTimeFormatter()) 13 | } 14 | 15 | extension DependencyValues { 16 | var postDetailViewModelFactory: IPostDetailViewModelFactory { 17 | get { self[PostDetailViewModelFactoryKey.self] } 18 | set { self[PostDetailViewModelFactoryKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Providers/AppNameProvider/AppNameProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - AppNameProvider 9 | 10 | final class AppNameProvider: IAppNameProvider { 11 | // MARK: Properties 12 | 13 | private let bundle: Bundle 14 | 15 | // MARK: Initialization 16 | 17 | init(bundle: Bundle) { 18 | self.bundle = bundle 19 | } 20 | 21 | // MARK: IAppNameProvider 22 | 23 | var applicationName: String { 24 | bundle.infoDictionary?[.bundleNameKey] as? String ?? "" 25 | } 26 | } 27 | 28 | // MARK: - Constants 29 | 30 | private extension String { 31 | static let bundleNameKey = "CFBundleName" 32 | } 33 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/CommentsPager/CommentsPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | 9 | actor CommentsPager: ICommentsPager { 10 | // MARK: Properties 11 | 12 | private let paginator: CommentsPaginatorService 13 | 14 | // MARK: Initialization 15 | 16 | init(paginator: CommentsPaginatorService) { 17 | self.paginator = paginator 18 | } 19 | 20 | // MARK: Public 21 | 22 | func load(request: OffsetPaginationRequest, behaviour: LoadBehaviour, postID: Int) async throws -> Page { 23 | try await paginator.loadPage(request: request, behaviour: behaviour, postID: postID) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/UI/Presentation/UserStories/RootSettings/RootSettingsAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import SwiftUI 8 | 9 | // MARK: - IRootSettingsAssembly 10 | 11 | protocol IRootSettingsAssembly { 12 | func assemble() -> AnyView 13 | } 14 | 15 | // MARK: - RootSettingsAssembly 16 | 17 | final class RootSettingsAssembly: IRootSettingsAssembly { 18 | private lazy var store: StoreOf = Store( 19 | initialState: RootSettingsFeature.State(isEmailSheetPresented: false) 20 | ) { 21 | RootSettingsFeature() 22 | } 23 | 24 | func assemble() -> AnyView { 25 | AnyView(RootSettingsView(store: store)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Model/Page+Map.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | 8 | extension Page { 9 | func map(_ closure: @escaping (T) throws -> U) rethrows -> Page { 10 | let items = try items.map { try closure($0) } 11 | let page = Page( 12 | items: items, 13 | hasMoreData: hasMoreData 14 | ) 15 | return page 16 | } 17 | 18 | func compactMap(_ closure: @escaping (T) throws -> U?) rethrows -> Page { 19 | let items = try items.compactMap { try closure($0) } 20 | let page = Page( 21 | items: items, 22 | hasMoreData: hasMoreData 23 | ) 24 | return page 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostsPager/PostsPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | import NetworkLayerInterfaces 9 | 10 | actor PostsPager { 11 | // MARK: Properties 12 | 13 | private let paginators: [PostType: any IOffsetPageLoader] 14 | 15 | // MARK: Initialization 16 | 17 | init(paginators: [PostType: any IOffsetPageLoader]) { 18 | self.paginators = paginators 19 | } 20 | 21 | // MARK: Public 22 | 23 | func load(request: OffsetPaginationRequest, postType: PostType) async throws -> Page { 24 | // FIXME: Remove force unwrapping 25 | // swiftlint:disable:next force_unwrapping 26 | try await paginators[postType]!.loadPage(request: request) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Colors.xcassets/dynamic_gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "235", 9 | "green" : "235", 10 | "red" : "235" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "57", 27 | "green" : "57", 28 | "red" : "57" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Modules/Common/DesignKit/Sources/DesignKit/Resources/Colors.xcassets/dynamic_light_gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "14", 9 | "green" : "14", 10 | "red" : "14" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "203", 27 | "green" : "203", 28 | "red" : "203" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/Requests/PostIdentifiersRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final class PostIdentifiersRequest: BaseRequest { 9 | // MARK: Properties 10 | 11 | private let postType: PostType 12 | 13 | // MARK: Initialization 14 | 15 | init(postType: PostType) { 16 | self.postType = postType 17 | } 18 | 19 | // MARK: IRequest 20 | 21 | override var path: String { 22 | switch postType { 23 | case .new: 24 | return "newstories.json" 25 | case .best: 26 | return "beststories.json" 27 | case .top: 28 | return "topstories.json" 29 | case .ask: 30 | return "askstories.json" 31 | case .show: 32 | return "showstories.json" 33 | case .jobs: 34 | return "jobstories.json" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Modules/Common/HackerNewsLocalization/Sources/HackerNewsLocalization/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "tab-bar.item.home" = "Home"; 2 | "tab-bar.item.search" = "Search"; 3 | "tab-bar.item.settings" = "Settings"; 4 | "news.article.comments" = "%d comments"; 5 | "settings.menu.send_feedback" = "Send Feedback"; 6 | "settings.menu.rate_us" = "Rate Us"; 7 | "settings.menu.github" = "GitHub"; 8 | "settings.navbar.title" = "Settings"; 9 | "comment.expand-branch" = "Expand the branch (%@ Replies)"; 10 | "comment.user.unknown" = "Unknown"; 11 | "post-details.navigation-bar.title" = "Comments"; 12 | "replies.navigation-bar.title" = "Replies"; 13 | "sidebar.items.settings" = "Settings"; 14 | "sidebar.groups.feeds.title" = "Feeds"; 15 | "common.actions.cancel" = "Cancel"; 16 | "common.actions.close" = "Close"; 17 | "settings.author" = "By %@"; 18 | "post-type.new" = "New"; 19 | "post-type.best" = "Best"; 20 | "post-type.top" = "Top"; 21 | "post-type.ask" = "Ask"; 22 | "post-type.show" = "Show"; 23 | "post-type.jobs" = "Jobs"; 24 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/View/TabBar/Factory/RootTabBarViewFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import HomeInterfaces 7 | import SettingsInterfaces 8 | import SwiftUI 9 | 10 | struct RootTabBarViewFactory { 11 | // MARK: Properties 12 | 13 | private let homePublicAssembly: IHomePublicAssembly 14 | private let settingsPublicAssembly: ISettingsPublicAssembly 15 | 16 | // MARK: Initialization 17 | 18 | init(homePublicAssembly: IHomePublicAssembly, settingsPublicAssembly: ISettingsPublicAssembly) { 19 | self.homePublicAssembly = homePublicAssembly 20 | self.settingsPublicAssembly = settingsPublicAssembly 21 | } 22 | 23 | // MARK: Internal 24 | 25 | func view(for tab: Tab) -> some View { 26 | switch tab { 27 | case .home: 28 | return homePublicAssembly.assemble() 29 | case .settings: 30 | return settingsPublicAssembly.assemble() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/CommentsService/CommentsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import NetworkLayer 8 | import NetworkLayerInterfaces 9 | 10 | // MARK: - CommentsService 11 | 12 | final class CommentsService { 13 | // MARK: Private 14 | 15 | private let requestProcessor: IRequestProcessor 16 | 17 | // MARK: Initialization 18 | 19 | init(requestProcessor: IRequestProcessor) { 20 | self.requestProcessor = requestProcessor 21 | } 22 | 23 | // MARK: Private 24 | 25 | private func _loadComment(id: Int) async throws -> Comment { 26 | let request = CommentRequest(id: id) 27 | return try await requestProcessor.send(request, strategy: nil, delegate: nil, configure: nil).data 28 | } 29 | } 30 | 31 | // MARK: ICommentsService 32 | 33 | extension CommentsService: ICommentsService { 34 | func loadComment(id: Int) async throws -> Comment { 35 | try await _loadComment(id: id) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/CommentsService/Responses/Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | struct Comment: Decodable, Equatable { 9 | let id: Int 10 | let author: String? 11 | let text: String? 12 | let time: Int 13 | let kids: [Int] 14 | 15 | // var comments: [Comment] = [] 16 | 17 | enum CodingKeys: CodingKey { 18 | case id 19 | case by 20 | case text 21 | case time 22 | case kids 23 | } 24 | 25 | init(from decoder: Decoder) throws { 26 | let container = try decoder.container(keyedBy: CodingKeys.self) 27 | id = try container.decode(Int.self, forKey: .id) 28 | author = try container.decodeIfPresent(String.self, forKey: .by) 29 | text = try container.decodeIfPresent(String.self, forKey: .text) 30 | time = try container.decode(Int.self, forKey: .time) 31 | kids = try container.decodeIfPresent([Int].self, forKey: .kids) ?? [] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/Assembly/DependenciesAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Home 7 | import HomeInterfaces 8 | import NetworkLayer 9 | import NetworkLayerInterfaces 10 | import Settings 11 | import SettingsInterfaces 12 | 13 | // MARK: - IDependenciesAssembly 14 | 15 | protocol IDependenciesAssembly { 16 | var homePublicAssembly: IHomePublicAssembly { get } 17 | var settingsPublicAssembly: ISettingsPublicAssembly { get } 18 | var networkAssembly: INetworkLayerAssembly { get } 19 | } 20 | 21 | // MARK: - DependenciesAssembly 22 | 23 | final class DependenciesAssembly: IDependenciesAssembly { 24 | var homePublicAssembly: IHomePublicAssembly { 25 | HomePublicAssembly(requestProcessor: networkAssembly.assemble(), settingsAssembly: settingsPublicAssembly) 26 | } 27 | 28 | var networkAssembly: INetworkLayerAssembly { 29 | NetworkLayerAssembly() 30 | } 31 | 32 | var settingsPublicAssembly: ISettingsPublicAssembly { 33 | SettingsPublicAssembly() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/CommentView/CommentHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - CommentHeaderView 9 | 10 | struct CommentHeaderView: View { 11 | private let viewModel: ViewModel 12 | 13 | init(viewModel: ViewModel) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | var body: some View { 18 | HStack(alignment: .center) { 19 | IndicatorView(viewModel: .init(imageName: .person, text: viewModel.username)) 20 | 21 | Divider() 22 | 23 | IndicatorView(viewModel: .init(imageName: .calendar, text: viewModel.date)) 24 | } 25 | } 26 | } 27 | 28 | // MARK: CommentHeaderView.ViewModel 29 | 30 | extension CommentHeaderView { 31 | struct ViewModel { 32 | let username: String 33 | let date: String 34 | } 35 | } 36 | 37 | private extension String { 38 | static let person = "person.circle.fill" 39 | static let calendar = "calendar.circle.fill" 40 | } 41 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Sources/AppUtils/Classes/DI/Locator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Dip 7 | import Foundation 8 | 9 | // MARK: - IDefinition 10 | 11 | public protocol IDefinition { 12 | func implements(type a: A.Type) -> IDefinition 13 | } 14 | 15 | // MARK: - Locator 16 | 17 | public final class Locator { 18 | let container = DependencyContainer() 19 | 20 | public static let shared = Locator() 21 | 22 | public func resolve() -> T { 23 | do { 24 | return try container.resolve() 25 | } catch { 26 | fatalError("Object with type \(String(describing: T.self)) must be register in the container") 27 | } 28 | } 29 | 30 | public func register( 31 | _ scope: ComponentScope = .shared, 32 | type: T.Type = T.self, 33 | tag: DependencyTagConvertible? = nil, 34 | types _: [Any.Type] = [], 35 | factory: @escaping (()) throws -> T 36 | ) { 37 | container.register(scope, type: type, tag: tag, factory: factory) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/DI/PublicAssembly/HomePublicAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import HomeInterfaces 7 | import NetworkLayerInterfaces 8 | import SettingsInterfaces 9 | import SwiftUI 10 | import UIExtensions 11 | 12 | public final class HomePublicAssembly: IHomePublicAssembly { 13 | // MARK: Properties 14 | 15 | private let presentationAssembly: IHomePresentationAssembly 16 | private let servicesAssembly: IHomeServicesAssembly 17 | 18 | // MARK: Initialization 19 | 20 | public init(requestProcessor: IRequestProcessor, settingsAssembly: ISettingsPublicAssembly) { 21 | servicesAssembly = HomeServicesAssembly(requestProcessor: requestProcessor) 22 | presentationAssembly = HomePresentationAssembly( 23 | servicesAssembly: servicesAssembly, 24 | settingsAssembly: settingsAssembly 25 | ) 26 | } 27 | 28 | // MARK: IHomePublicAssembly 29 | 30 | public func assemble() -> AnyView { 31 | presentationAssembly.newsAssembly.assemble().eraseToAnyView() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Views/NavigationTitleView/NavigationTitleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import DesignKit 8 | import SwiftUI 9 | import UIExtensions 10 | 11 | struct NavigationTitleView: View { 12 | // MARK: Properties 13 | 14 | private let store: StoreOf 15 | 16 | // MARK: Initialization 17 | 18 | init(store: StoreOf) { 19 | self.store = store 20 | } 21 | 22 | // MARK: View 23 | 24 | var body: some View { 25 | WithViewStore(store, observe: { $0 }) { viewStore in 26 | VStack(alignment: .leading) { 27 | Text(viewStore.date) 28 | .font(FontFamily.Montserrat.semiBold.font(size: .size13).sui) 29 | Text(viewStore.appName) 30 | .font(FontFamily.Montserrat.bold.font(size: .size17).sui) 31 | } 32 | } 33 | .onAppear { 34 | store.send(.appear) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/SafariView/SafariView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SafariServices 7 | import SwiftUI 8 | 9 | struct SafariView: UIViewControllerRepresentable { 10 | let url: URL 11 | let onDismiss: () -> Void 12 | 13 | class Coordinator: NSObject, SFSafariViewControllerDelegate { 14 | let parent: SafariView 15 | 16 | init(parent: SafariView) { 17 | self.parent = parent 18 | } 19 | 20 | func safariViewControllerDidFinish(_: SFSafariViewController) { 21 | parent.onDismiss() 22 | } 23 | } 24 | 25 | func makeCoordinator() -> Coordinator { 26 | Coordinator(parent: self) 27 | } 28 | 29 | func makeUIViewController(context: Context) -> SFSafariViewController { 30 | let safariViewController = SFSafariViewController(url: url) 31 | safariViewController.delegate = context.coordinator 32 | return safariViewController 33 | } 34 | 35 | func updateUIViewController(_: SFSafariViewController, context _: Context) {} 36 | } 37 | -------------------------------------------------------------------------------- /HackerNews/Classes/HackerNewsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import Home 8 | import PulseUI 9 | import SwiftUI 10 | import UIExtensions 11 | 12 | @main 13 | struct HackerNewsApp: App { 14 | // MARK: Properties 15 | 16 | @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate 17 | #if DEBUG 18 | @State private var showConsole = false 19 | #endif 20 | 21 | private let assembly: IApplicationAssembly = ApplicationAssembly(dependencies: DependenciesAssembly()) 22 | 23 | // MARK: View 24 | 25 | var body: some Scene { 26 | WindowGroup { 27 | #if DEBUG 28 | assembly.assemble() 29 | .sheet(isPresented: $showConsole) { 30 | NavigationView { 31 | ConsoleView() 32 | } 33 | } 34 | .onShake { 35 | showConsole.toggle() 36 | } 37 | #else 38 | assembly.assemble() 39 | #endif 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Providers/PageLoaderProvider/PageLoaderProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | 8 | // MARK: - PageLoaderProvider 9 | 10 | actor PageLoaderProvider { 11 | // MARK: Properties 12 | 13 | /// The service responsible for loading pages of data. 14 | private let paginatorService: any IOffsetPageLoader 15 | 16 | // MARK: Initialization 17 | 18 | init(paginatorService: any IOffsetPageLoader) { 19 | self.paginatorService = paginatorService 20 | } 21 | 22 | // MARK: Private 23 | 24 | /// Loads a specific page of data and updates the internal state. 25 | private func loadPage(limit: Int, offset: Int) async throws -> Page { 26 | try await paginatorService.loadPage(request: OffsetPaginationRequest(limit: limit, offset: offset)) 27 | } 28 | } 29 | 30 | // MARK: IOffsetPageLoader 31 | 32 | extension PageLoaderProvider: IOffsetPageLoader { 33 | func loadPage(request: OffsetPaginationRequest) async throws -> Page { 34 | try await loadPage(limit: request.limit, offset: request.offset) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Views/NavigationTitleView/NavigationTitleViewStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | import Foundation 9 | 10 | struct NavigationTitleViewStore: Reducer { 11 | // MARK: Types 12 | 13 | struct State: Equatable { 14 | var date: String 15 | var appName: String 16 | } 17 | 18 | enum Action { 19 | case appear 20 | } 21 | 22 | // MARK: Properties 23 | 24 | private let appNameProvider: IAppNameProvider 25 | private let dateFormatter: IDateFormatter 26 | 27 | // MARK: Initialization 28 | 29 | init(appNameProvider: IAppNameProvider, dateFormatter: IDateFormatter) { 30 | self.appNameProvider = appNameProvider 31 | self.dateFormatter = dateFormatter 32 | } 33 | 34 | // MARK: Reducer 35 | 36 | func reduce(into state: inout State, action: Action) -> Effect { 37 | switch action { 38 | case .appear: 39 | state.date = dateFormatter.string(from: Date()) 40 | state.appName = appNameProvider.applicationName 41 | return .none 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Modules/Common/AppUtils/Sources/AppUtils/Classes/DI/AppAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Dip 7 | import Foundation 8 | 9 | open class AppAssembly { 10 | // MARK: Properties 11 | 12 | private static let container = Locator.shared 13 | 14 | // MARK: Initialization 15 | 16 | public init() { 17 | Dip.logLevel = .None 18 | } 19 | 20 | // MARK: Methods 21 | 22 | public func resolve( 23 | _ type: T.Type = T.self, 24 | scope: ComponentScope = .shared, 25 | tag: DependencyTagConvertible? = nil, 26 | factory: @escaping () -> T 27 | ) -> T { 28 | if let object = try? Locator.shared.container.resolve(type, tag: tag) as? T { return object } 29 | 30 | Locator.shared.container.register(scope, type: type, tag: tag) { _ in factory() } 31 | 32 | return resolve(tag: tag) 33 | } 34 | 35 | // MARK: Private 36 | 37 | private func resolve(tag: DependencyTagConvertible?) -> T { 38 | do { 39 | return try Locator.shared.container.resolve(tag: tag) as T 40 | } catch { 41 | fatalError("[AppAssembly] The instance with type \(String(describing: T.self)) wasn't found in the container.") 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | git diff --diff-filter=d --staged --name-only | grep -e '\.swift$' | while read line; do 3 | if [[ $line == *"/Generated"* ]]; then 4 | echo "IGNORING GENERATED FILE: " "$line"; 5 | else 6 | mint run swiftformat swiftformat "${line}"; 7 | git add "$line"; 8 | fi 9 | done 10 | 11 | LINT=$(which mint) 12 | if [[ -e "${LINT}" ]]; then 13 | # Export files in SCRIPT_INPUT_FILE_$count to lint against later 14 | count=0 15 | while IFS= read -r file_path; do 16 | export SCRIPT_INPUT_FILE_$count="$file_path" 17 | count=$((count + 1)) 18 | done < <(git diff --name-only --cached --diff-filter=d | grep ".swift$") 19 | export SCRIPT_INPUT_FILE_COUNT=$count 20 | 21 | if [ "$count" -eq 0 ]; then 22 | echo "No files to lint!" 23 | exit 0 24 | fi 25 | 26 | echo "Found $count lintable files! Linting now.." 27 | mint run swiftlint --use-script-input-files --strict --config .swiftlint.yml 28 | RESULT=$? # swiftline exit value is number of errors 29 | 30 | if [ $RESULT -eq 0 ]; then 31 | echo "🎉 Well done. No violation." 32 | fi 33 | exit $RESULT 34 | else 35 | echo "⚠️ WARNING: SwiftLint not found" 36 | echo "⚠️ You might want to edit .git/hooks/pre-commit to locate your swiftlint" 37 | exit 0 38 | fi 39 | 40 | xcodegen -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | import SwiftUI 9 | 10 | // MARK: - IRepliesAssembly 11 | 12 | protocol IRepliesAssembly { 13 | func assemble(store: StoreOf) -> AnyView 14 | } 15 | 16 | // MARK: - RepliesAssembly 17 | 18 | final class RepliesAssembly: BootstrappableAssembly, IRepliesAssembly { 19 | // MARK: Properties 20 | 21 | private let commentsService: ICommentsService 22 | private let postsService: IPostsService 23 | 24 | // MARK: Initialization 25 | 26 | init(commentsService: ICommentsService, postsService: IPostsService) { 27 | self.commentsService = commentsService 28 | self.postsService = postsService 29 | } 30 | 31 | // MARK: IRepliesAssembly 32 | 33 | func assemble(store: StoreOf) -> AnyView { 34 | let view = RepliesView(store: store) 35 | return view.eraseToAnyView() 36 | } 37 | 38 | // MARK: IBootstrappable 39 | 40 | override func bootstrap() { 41 | Locator.shared.register(type: IRepliesService.self) { 42 | RepliesService(commentsService: self.commentsService) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/Assembly/ApplicationAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - IApplicationAssembly 9 | 10 | protocol IApplicationAssembly { 11 | func assemble() -> AnyView 12 | } 13 | 14 | // MARK: - ApplicationAssembly 15 | 16 | final class ApplicationAssembly: IApplicationAssembly { 17 | // MARK: Properties 18 | 19 | private var dependencies: IDependenciesAssembly 20 | 21 | // MARK: Initialization 22 | 23 | init(dependencies: IDependenciesAssembly) { 24 | self.dependencies = dependencies 25 | } 26 | 27 | // MARK: IApplicationAssembly 28 | 29 | func assemble() -> AnyView { 30 | VStack { 31 | if UIDevice.current.userInterfaceIdiom == .phone { 32 | tabbarView 33 | } else { 34 | contentView 35 | } 36 | }.eraseToAnyView() 37 | } 38 | 39 | private var tabbarView: AnyView { 40 | RootTabBarAssembly( 41 | homePublicAssembly: dependencies.homePublicAssembly, 42 | settingsPublicAssembly: dependencies.settingsPublicAssembly 43 | ).assemble() 44 | } 45 | 46 | private var contentView: AnyView { 47 | dependencies.homePublicAssembly.assemble() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /fastlane/report.xml: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | 8 | @Reducer 9 | struct RepliesFeature { 10 | // MARK: Types 11 | 12 | struct State: Equatable { 13 | let commentID: Int 14 | var replies: [RepliesCommentView.ViewModel] 15 | } 16 | 17 | enum Action { 18 | case refresh 19 | case replies([RepliesCommentView.ViewModel]) 20 | } 21 | 22 | // MARK: Properties 23 | 24 | @Dependency(\.repliesService) var repliesService 25 | @Dependency(\.repliesViewModelFactory) var viewModelFactory 26 | 27 | // MARK: Reducer 28 | 29 | var body: some ReducerOf { 30 | Reduce { state, action in 31 | switch action { 32 | case .refresh: 33 | return .run { [state] send in 34 | let comments = try await repliesService.loadComments(for: state.commentID) 35 | let viewModels = viewModelFactory.makeViewModel(from: comments) 36 | return await send(.replies(viewModels)) 37 | } 38 | case let .replies(viewModel): 39 | state.replies = viewModel 40 | return .none 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Model/PostType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import HackerNewsLocalization 8 | 9 | enum PostType: Int, CaseIterable, Identifiable { 10 | case top 11 | case new 12 | case best 13 | case ask 14 | case show 15 | case jobs 16 | 17 | var title: String { 18 | switch self { 19 | case .new: 20 | return L10n.PostType.new 21 | case .best: 22 | return L10n.PostType.best 23 | case .top: 24 | return L10n.PostType.top 25 | case .ask: 26 | return L10n.PostType.ask 27 | case .show: 28 | return L10n.PostType.show 29 | case .jobs: 30 | return L10n.PostType.jobs 31 | } 32 | } 33 | 34 | var systemName: String { 35 | switch self { 36 | case .new: 37 | return "sun.min.fill" 38 | case .best: 39 | return "star.fill" 40 | case .top: 41 | return "square.stack.3d.up.fill" 42 | case .ask: 43 | return "brain.head.profile.fill" 44 | case .show: 45 | return "eyes.inverse" 46 | case .jobs: 47 | return "suitcase.fill" 48 | } 49 | } 50 | 51 | var id: Self { self } 52 | } 53 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/IndicatorView/IndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - IndicatorView 9 | 10 | struct IndicatorView: View { 11 | // MARK: Properties 12 | 13 | private let viewModel: ViewModel 14 | 15 | // MARK: Initialization 16 | 17 | init(viewModel: ViewModel) { 18 | self.viewModel = viewModel 19 | } 20 | 21 | // MARK: View 22 | 23 | var body: some View { 24 | HStack(spacing: .headerSpacing) { 25 | Image(systemName: viewModel.imageName) 26 | .resizable() 27 | .frame(width: .headerImageSize, height: .headerImageSize) 28 | .foregroundStyle(Color(uiColor: UIColor.secondaryLabel)) 29 | Text(viewModel.text) 30 | .lineLimit(1) 31 | .font(.caption) 32 | .foregroundStyle(Color(uiColor: UIColor.secondaryLabel)) 33 | } 34 | } 35 | } 36 | 37 | // MARK: IndicatorView.ViewModel 38 | 39 | extension IndicatorView { 40 | struct ViewModel { 41 | let imageName: String 42 | let text: String 43 | } 44 | } 45 | 46 | // MARK: Constants 47 | 48 | private extension CGFloat { 49 | static let headerSpacing = 4.0 50 | static let headerImageSize = 12.0 51 | } 52 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/UI/Presentation/SharedComponents/UIComponents/MailView/MailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import MessageUI 7 | import SwiftUI 8 | 9 | struct MailView: UIViewControllerRepresentable { 10 | @Binding var isPresented: Bool 11 | 12 | class Coordinator: NSObject, MFMailComposeViewControllerDelegate { 13 | @Binding var isPresented: Bool 14 | 15 | init(isPresented: Binding) { 16 | _isPresented = isPresented 17 | } 18 | 19 | func mailComposeController( 20 | _ controller: MFMailComposeViewController, 21 | didFinishWith _: MFMailComposeResult, 22 | error _: Error? 23 | ) { 24 | isPresented = false 25 | controller.dismiss(animated: true) 26 | } 27 | } 28 | 29 | func makeCoordinator() -> Coordinator { 30 | Coordinator(isPresented: $isPresented) 31 | } 32 | 33 | func makeUIViewController(context: Context) -> MFMailComposeViewController { 34 | let vc = MFMailComposeViewController() 35 | vc.setSubject("Feedback") 36 | vc.setToRecipients(["your_email@example.com"]) 37 | vc.mailComposeDelegate = context.coordinator 38 | return vc 39 | } 40 | 41 | func updateUIViewController(_: MFMailComposeViewController, context _: Context) {} 42 | } 43 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Views/NavigationTitleView/NavigationTitleAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | import SwiftUI 9 | 10 | // MARK: - INavigationTitleAssembly 11 | 12 | protocol INavigationTitleAssembly { 13 | func assemble() -> AnyView 14 | } 15 | 16 | // MARK: - NavigationTitleAssembly 17 | 18 | final class NavigationTitleAssembly: INavigationTitleAssembly { 19 | // MARK: Properties 20 | 21 | private let dateTimeFormatter: RelativeDateTimeFormatter 22 | private let appNameProvider: IAppNameProvider 23 | 24 | private lazy var store: StoreOf = Store( 25 | initialState: NavigationTitleViewStore.State(date: "", appName: "") 26 | ) { 27 | NavigationTitleViewStore( 28 | appNameProvider: self.appNameProvider, 29 | dateFormatter: DateFormatter.EEEEMMMd 30 | ) 31 | } 32 | 33 | // MARK: Initialization 34 | 35 | init(appNameProvider: IAppNameProvider, dateTimeFormatter: RelativeDateTimeFormatter) { 36 | self.appNameProvider = appNameProvider 37 | self.dateTimeFormatter = dateTimeFormatter 38 | } 39 | 40 | // MARK: INavigationTitleAssembly 41 | 42 | func assemble() -> AnyView { 43 | NavigationTitleView(store: store).eraseToAnyView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/DI/ServicesAssembly/HomeServicesAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import Foundation 8 | import NetworkLayerInterfaces 9 | 10 | // MARK: - IHomeServicesAssembly 11 | 12 | protocol IHomeServicesAssembly { 13 | var postsService: IPostsService { get } 14 | var commentsService: ICommentsService { get } 15 | var appNameProvider: IAppNameProvider { get } 16 | } 17 | 18 | // MARK: - HomeServicesAssembly 19 | 20 | final class HomeServicesAssembly: AppAssembly, IHomeServicesAssembly { 21 | // MARK: Properties 22 | 23 | private let requestProcessor: IRequestProcessor 24 | 25 | // MARK: Initialization 26 | 27 | init(requestProcessor: IRequestProcessor) { 28 | self.requestProcessor = requestProcessor 29 | } 30 | 31 | // MARK: IHomeServicesAssembly 32 | 33 | var postsService: IPostsService { 34 | resolve(IPostsService.self) { 35 | PostsService(requestProcessor: self.requestProcessor) 36 | } 37 | } 38 | 39 | var appNameProvider: IAppNameProvider { 40 | resolve(IAppNameProvider.self) { 41 | AppNameProvider(bundle: .main) 42 | } 43 | } 44 | 45 | var commentsService: ICommentsService { 46 | resolve(ICommentsService.self) { 47 | CommentsService(requestProcessor: self.requestProcessor) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesService/RepliesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - RepliesService 9 | 10 | final class RepliesService { 11 | // MARK: Properties 12 | 13 | private let commentsService: ICommentsService 14 | 15 | // MARK: Initialization 16 | 17 | init(commentsService: ICommentsService) { 18 | self.commentsService = commentsService 19 | } 20 | } 21 | 22 | // MARK: IRepliesService 23 | 24 | extension RepliesService: IRepliesService { 25 | func loadComments(for id: Int) async throws -> ReplyComment { 26 | let comment = try await commentsService.loadComment(id: id) 27 | 28 | if comment.kids.isEmpty { 29 | return ReplyComment(comment: comment) 30 | } 31 | 32 | let subComments = try await withThrowingTaskGroup(of: ReplyComment.self, returning: [ReplyComment].self) { taskGroup in 33 | for id in comment.kids { 34 | taskGroup.addTask { try await self.loadComments(for: id) } 35 | } 36 | 37 | let comments = try await taskGroup.reduce(into: [ReplyComment]()) { $0.append($1) } 38 | .sorted(by: { $0.comment.time < $1.comment.time }) 39 | return comments 40 | } 41 | 42 | return ReplyComment(comment: comment, replies: subComments) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/SegmentControlView/SegmentControlItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - SegmentControlItemView 9 | 10 | struct SegmentControlItemView: View { 11 | // MARK: Properties 12 | 13 | let id: ID 14 | let action: (() -> Void)? 15 | @Binding var selection: ID 16 | @ViewBuilder var content: () -> ContentView 17 | @ViewBuilder var background: () -> BackgroundView 18 | 19 | // MARK: View 20 | 21 | var body: some View { 22 | Button( 23 | action: { 24 | withAnimation { 25 | selection = id 26 | action?() 27 | } 28 | }, label: { 29 | content() 30 | } 31 | ) 32 | .clipShape(background()) 33 | } 34 | } 35 | 36 | #if DEBUG 37 | struct SegmentControlItemView_Previews: PreviewProvider { 38 | static var previews: some View { 39 | SegmentControlItemView( 40 | id: PostType.new, 41 | action: nil, 42 | selection: .constant(.new), 43 | content: { Text(PostType.new.title) }, 44 | background: { RoundedRectangle(cornerRadius: 20) } 45 | ) 46 | } 47 | } 48 | #endif 49 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable prefixed_toplevel_constant 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "Settings", 9 | platforms: [.iOS(.v17)], 10 | products: [ 11 | .library(name: "Settings", targets: ["Settings"]), 12 | .library(name: "SettingsInterfaces", targets: ["SettingsInterfaces"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")), 16 | .package(path: "../../Common/HackerNewsLocalization"), 17 | .package(path: "../../Common/DesignKit"), 18 | .package(path: "../../Common/UIExtensions"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Settings", 23 | dependencies: [ 24 | "SettingsInterfaces", 25 | .product(name: "UIExtensions", package: "UIExtensions"), 26 | .product(name: "DesignKit", package: "DesignKit"), 27 | .product(name: "HackerNewsLocalization", package: "HackerNewsLocalization"), 28 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 29 | ] 30 | ), 31 | .target(name: "SettingsInterfaces"), 32 | .testTarget(name: "SettingsTests", dependencies: ["Settings"]), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/View/TabBar/RootTabBarAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import HomeInterfaces 8 | import SettingsInterfaces 9 | import SwiftUI 10 | import UIExtensions 11 | 12 | // MARK: - IRootTabBarAssembly 13 | 14 | protocol IRootTabBarAssembly { 15 | func assemble() -> AnyView 16 | } 17 | 18 | // MARK: - RootTabBarAssembly 19 | 20 | final class RootTabBarAssembly: IRootTabBarAssembly { 21 | // MARK: Private 22 | 23 | private let homePublicAssembly: IHomePublicAssembly 24 | private let settingsPublicAssembly: ISettingsPublicAssembly 25 | 26 | private lazy var store: Store = .init( 27 | initialState: RootTabBarViewStore.State(tabs: [.home, .settings], tab: .home) 28 | ) { 29 | RootTabBarViewStore() 30 | } 31 | 32 | // MARK: Initialization 33 | 34 | init(homePublicAssembly: IHomePublicAssembly, settingsPublicAssembly: ISettingsPublicAssembly) { 35 | self.homePublicAssembly = homePublicAssembly 36 | self.settingsPublicAssembly = settingsPublicAssembly 37 | } 38 | 39 | // MARK: IRootTabBarAssembly 40 | 41 | func assemble() -> AnyView { 42 | RootTabBarView( 43 | store: store, 44 | viewFactory: RootTabBarViewFactory( 45 | homePublicAssembly: homePublicAssembly, 46 | settingsPublicAssembly: settingsPublicAssembly 47 | ) 48 | ).eraseToAnyView() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios generate_new_certificates 19 | 20 | ```sh 21 | [bundle exec] fastlane ios generate_new_certificates 22 | ``` 23 | 24 | Generate new certificates 25 | 26 | ### ios register 27 | 28 | ```sh 29 | [bundle exec] fastlane ios register 30 | ``` 31 | 32 | Regester devices on apple portal 33 | 34 | ### ios beta 35 | 36 | ```sh 37 | [bundle exec] fastlane ios beta 38 | ``` 39 | 40 | Create a HackerNews Beta build for TestFlight 41 | 42 | ### ios production 43 | 44 | ```sh 45 | [bundle exec] fastlane ios production 46 | ``` 47 | 48 | Create a HackerNews Production build for TestFlight 49 | 50 | ### ios test 51 | 52 | ```sh 53 | [bundle exec] fastlane ios test 54 | ``` 55 | 56 | Run Unit Tests 57 | 58 | ### ios gym_params 59 | 60 | ```sh 61 | [bundle exec] fastlane ios gym_params 62 | ``` 63 | 64 | Returns the parameters that should be used in any fastlane build 65 | 66 | ---- 67 | 68 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 69 | 70 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 71 | 72 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 73 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/CommentView/CommentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import UIExtensions 8 | 9 | // MARK: - CommentView 10 | 11 | struct CommentView: View { 12 | // MARK: Properties 13 | 14 | let viewModel: ViewModel 15 | 16 | // MARK: View 17 | 18 | var body: some View { 19 | VStack(alignment: .leading) { 20 | CommentHeaderView(viewModel: .init(username: viewModel.username, date: viewModel.date)) 21 | 22 | Divider() 23 | 24 | contentView 25 | } 26 | .fixedSize(horizontal: false, vertical: true) 27 | } 28 | 29 | private var contentView: some View { 30 | VStack(alignment: .leading) { 31 | Text(AttributedString(.html(withBody: viewModel.text))) 32 | } 33 | } 34 | } 35 | 36 | // MARK: CommentView.ViewModel 37 | 38 | extension CommentView { 39 | struct ViewModel: Equatable, Identifiable { 40 | let id = UUID() 41 | let username: String 42 | let date: String 43 | let text: String 44 | } 45 | } 46 | 47 | // MARK: Constants 48 | 49 | private extension CGFloat { 50 | static let headerSpacing = 4.0 51 | static let headerImageSize = 12.0 52 | } 53 | 54 | // MARK: Preview 55 | 56 | // #Preview { 57 | // CommentView( 58 | // viewModel: CommentView.ViewModel( 59 | // username: "nsvasilev", 60 | // date: "22 hours ago", 61 | // text: "Comment message" 62 | // ) 63 | // ) 64 | // } 65 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/PostsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import NetworkLayerInterfaces 8 | 9 | // MARK: - PostsService 10 | 11 | final class PostsService { 12 | // MARK: Private 13 | 14 | private let requestProcessor: IRequestProcessor 15 | 16 | // MARK: Initialization 17 | 18 | init(requestProcessor: IRequestProcessor) { 19 | self.requestProcessor = requestProcessor 20 | } 21 | 22 | // MARK: Private 23 | 24 | private func loadPost(withID id: Int) async throws -> Post { 25 | let request = PostRequest(id: id) 26 | return try await requestProcessor.send(request, strategy: nil, delegate: nil, configure: nil).data 27 | } 28 | } 29 | 30 | // MARK: IPostsService 31 | 32 | extension PostsService: IPostsService { 33 | func loadIDs(for type: PostType) async throws -> [Int] { 34 | let request = PostIdentifiersRequest(postType: type) 35 | return try await requestProcessor.send(request, strategy: nil, delegate: nil, configure: nil).data 36 | } 37 | 38 | func loadPosts(with ids: [Int]) async throws -> [Post] { 39 | try await withThrowingTaskGroup(of: Post.self, returning: [Post].self, body: { taskGroup in 40 | for id in ids { 41 | taskGroup.addTask { try await self.loadPost(withID: id) } 42 | } 43 | 44 | let posts = try await taskGroup.reduce(into: [Post]()) { $0.append($1) } 45 | .sorted(by: { $0.time > $1.time }) 46 | 47 | return posts 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/ios.yml: -------------------------------------------------------------------------------- 1 | name: "HackerNews" 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | - master 8 | pull_request: 9 | paths: 10 | - '.swiftlint.yml' 11 | - ".github/workflows/**" 12 | - "HackerNews/**" 13 | - "HackerNewsTests/**" 14 | - "Modules/**" 15 | 16 | jobs: 17 | iOS: 18 | name: 19 | runs-on: macos-14 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - destination: "OS=17.0,name=iPhone 14 Pro" 25 | name: "iOS" 26 | scheme: "Debug" 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | - name: Install Dependencies 31 | run: make setup_build_tools 32 | - name: Generate resources 33 | run: make swiftgen 34 | - name: Generate project 35 | run: xcodegen generate 36 | - name: Select Xcode version 37 | run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer' 38 | - name: Run tests 39 | run: | 40 | bundle add fastlane 41 | bundle exec fastlane test 42 | 43 | discover-typos: 44 | name: Discover Typos 45 | runs-on: macOS-12 46 | env: 47 | DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Discover typos 51 | run: | 52 | export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin" 53 | python3 -m pip install --upgrade pip 54 | python3 -m pip install codespell 55 | codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*,./fastlane/*" 56 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/Core/Services/PostsService/Responses/Post.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | struct Post: Decodable, Equatable, Identifiable { 9 | /// The item's unique id. 10 | let id: Int 11 | 12 | /// The title of the story, poll or job. 13 | let title: String? 14 | 15 | /// The story's score, or the votes for a pollopt. 16 | let score: Int? 17 | 18 | /// The username of the item's author. 19 | let author: String? 20 | 21 | /// The URL of the story. 22 | let url: String? 23 | 24 | /// The ids of the item's comments, in ranked display order. 25 | var kids: [Int] 26 | 27 | /// Creation date of the item, in Unix Time. 28 | var time: Int 29 | 30 | private enum CodingKeys: String, CodingKey { 31 | case id 32 | case title 33 | case score 34 | case by 35 | case url 36 | case kids 37 | case time 38 | } 39 | 40 | // MARK: Initialization 41 | 42 | init(from decoder: Decoder) throws { 43 | let container = try decoder.container(keyedBy: CodingKeys.self) 44 | id = try container.decode(Int.self, forKey: .id) 45 | title = try? container.decode(String.self, forKey: .title) 46 | score = try? container.decode(Int.self, forKey: .score) 47 | author = try? container.decode(String.self, forKey: .by) 48 | url = try? container.decode(String.self, forKey: .url) 49 | kids = (try? container.decode([Int].self, forKey: .kids)) ?? [] 50 | time = (try? container.decode(Int.self, forKey: .time)) ?? .zero 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/UI/Presentation/UserStories/RootSettings/RootSettingsFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import UIKit 8 | 9 | // MARK: - RootSettingsFeature 10 | 11 | @Reducer 12 | struct RootSettingsFeature { 13 | struct State: Equatable { 14 | var isEmailSheetPresented: Bool = false 15 | } 16 | 17 | enum Action: Equatable { 18 | case sendFeedback 19 | case rateUs 20 | case openGitHub 21 | case setEmailSheetPresented(Bool) 22 | } 23 | 24 | var body: some ReducerOf { 25 | Reduce { state, action in 26 | switch action { 27 | case .sendFeedback: 28 | state.isEmailSheetPresented = true 29 | return .none 30 | 31 | case .rateUs: 32 | if let url = URL(string: .rateUs) { 33 | UIApplication.shared.open(url) 34 | } 35 | return .none 36 | 37 | case .openGitHub: 38 | if let url = URL(string: .githubURL) { 39 | UIApplication.shared.open(url) 40 | } 41 | return .none 42 | 43 | case let .setEmailSheetPresented(isPresented): 44 | state.isEmailSheetPresented = isPresented 45 | return .none 46 | } 47 | } 48 | } 49 | } 50 | 51 | // MARK: - Constants 52 | 53 | private extension String { 54 | static let githubURL = "https://github.com/nik3212/HackerNews" 55 | static let rateUs = "https://apps.apple.com/app/idYOUR_APP_ID?action=write-review" 56 | } 57 | -------------------------------------------------------------------------------- /HackerNews/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleExecutable 6 | $(EXECUTABLE_NAME) 7 | CFBundleIdentifier 8 | $(PRODUCT_BUNDLE_IDENTIFIER) 9 | CFBundleInfoDictionaryVersion 10 | 6.0 11 | CFBundleName 12 | $(PRODUCT_NAME) 13 | CFBundlePackageType 14 | APPL 15 | CFBundleShortVersionString 16 | $(MARKETING_VERSION) 17 | CFBundleURLTypes 18 | 19 | 20 | CFBundleTypeRole 21 | Editor 22 | CFBundleURLSchemes 23 | 24 | 25 | 26 | CFBundleVersion 27 | 2 28 | ITSAppUsesNonExemptEncryption 29 | 30 | LSRequiresIPhoneOS 31 | 32 | NSAppTransportSecurity 33 | 34 | NSAllowsArbitraryLoads 35 | 36 | 37 | UILaunchScreens 38 | 39 | UILaunchScreen 40 | 41 | 42 | UISupportedInterfaceOrientations 43 | 44 | UIInterfaceOrientationPortrait 45 | 46 | UISupportedInterfaceOrientations~ipad 47 | 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationPortraitUpsideDown 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/PostDetailAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import ComposableArchitecture 8 | import SwiftUI 9 | 10 | // MARK: - IPostDetailAssembly 11 | 12 | protocol IPostDetailAssembly { 13 | func assemble(store: StoreOf) -> AnyView 14 | } 15 | 16 | // MARK: - PostDetailAssembly 17 | 18 | final class PostDetailAssembly: BootstrappableAssembly, IPostDetailAssembly { 19 | // MARK: Properties 20 | 21 | private let commentsService: ICommentsService 22 | private let postsService: IPostsService 23 | private let repliesAssembly: IRepliesAssembly 24 | 25 | // MARK: Initialization 26 | 27 | init(commentsService: ICommentsService, postsService: IPostsService, repliesAssembly: IRepliesAssembly) { 28 | self.commentsService = commentsService 29 | self.postsService = postsService 30 | self.repliesAssembly = repliesAssembly 31 | } 32 | 33 | // MARK: IPostDetailAssembly 34 | 35 | func assemble(store: StoreOf) -> AnyView { 36 | let view = PostDetailView(store: store, repliesAssembly: repliesAssembly) 37 | return view.eraseToAnyView() 38 | } 39 | 40 | // MARK: BootstrappableAssembly 41 | 42 | override func bootstrap() { 43 | Locator.shared.register(type: ICommentsPager.self) { 44 | let paginator = CommentsPaginatorService( 45 | commentsService: self.commentsService, 46 | postsService: self.postsService 47 | ) 48 | return CommentsPager(paginator: paginator) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # Stream rules 2 | 3 | --swiftversion 5.7 4 | 5 | # Use 'swiftformat --options' to list all of the possible options 6 | 7 | --header "\nHackerNews\nCopyright © {created.year} Nikita Vasilev. All rights reserved.\n//" 8 | 9 | --enable blankLinesBetweenScopes 10 | --enable blankLinesAtStartOfScope 11 | --enable blankLinesAtEndOfScope 12 | --enable blankLinesAroundMark 13 | --enable anyObjectProtocol 14 | --enable consecutiveBlankLines 15 | --enable consecutiveSpaces 16 | --enable duplicateImports 17 | --enable elseOnSameLine 18 | --enable emptyBraces 19 | --enable initCoderUnavailable 20 | --enable leadingDelimiters 21 | --enable numberFormatting 22 | --enable preferKeyPath 23 | --enable redundantBreak 24 | --enable redundantFileprivate 25 | --enable redundantGet 26 | --enable redundantInit 27 | --enable redundantLet 28 | --enable redundantLetError 29 | --enable redundantNilInit 30 | --enable redundantObjc 31 | --enable redundantParens 32 | --enable redundantPattern 33 | --enable redundantRawValues 34 | --enable redundantReturn 35 | --enable redundantSelf 36 | --enable redundantVoidReturnType 37 | --enable semicolons 38 | --enable sortedImports 39 | --enable sortedSwitchCases 40 | --enable spaceAroundBraces 41 | --enable spaceAroundBrackets 42 | --enable spaceAroundComments 43 | --enable spaceAroundGenerics 44 | --enable spaceAroundOperators 45 | --enable spaceInsideBraces 46 | --enable spaceInsideBrackets 47 | --enable spaceInsideComments 48 | --enable spaceInsideGenerics 49 | --enable spaceInsideParens 50 | --enable strongOutlets 51 | --enable strongifiedSelf 52 | --enable todos 53 | --enable trailingClosures 54 | --enable unusedArguments 55 | --enable void 56 | --enable markTypes 57 | --enable isEmpty 58 | --enable redundantExtensionACL 59 | 60 | # format options 61 | 62 | --wraparguments before-first 63 | --wrapcollections before-first 64 | --maxwidth 140 -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/DI/PresentationAssembly/HomePresentationAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import SettingsInterfaces 8 | 9 | // MARK: - IHomePresentationAssembly 10 | 11 | protocol IHomePresentationAssembly { 12 | var newsAssembly: IPostsAssembly { get } 13 | } 14 | 15 | // MARK: - HomePresentationAssembly 16 | 17 | final class HomePresentationAssembly: IHomePresentationAssembly { 18 | // MARK: Properties 19 | 20 | private let settingsAssembly: ISettingsPublicAssembly 21 | private let servicesAssembly: IHomeServicesAssembly 22 | 23 | // MARK: Initialization 24 | 25 | init(servicesAssembly: IHomeServicesAssembly, settingsAssembly: ISettingsPublicAssembly) { 26 | self.servicesAssembly = servicesAssembly 27 | self.settingsAssembly = settingsAssembly 28 | } 29 | 30 | // MARK: IHomePresentationAssembly 31 | 32 | var newsAssembly: IPostsAssembly { 33 | PostsAssembly( 34 | postsService: servicesAssembly.postsService, 35 | appNameProvider: servicesAssembly.appNameProvider, 36 | postDetailsAssembly: postDetailsAssembly, 37 | settingsAssembly: settingsAssembly 38 | ) 39 | } 40 | 41 | // MARK: Private 42 | 43 | private var postDetailsAssembly: IPostDetailAssembly { 44 | PostDetailAssembly( 45 | commentsService: servicesAssembly.commentsService, 46 | postsService: servicesAssembly.postsService, 47 | repliesAssembly: repliesAssembly 48 | ) 49 | } 50 | 51 | private var repliesAssembly: IRepliesAssembly { 52 | RepliesAssembly( 53 | commentsService: servicesAssembly.commentsService, 54 | postsService: servicesAssembly.postsService 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/SegmentControlView/SegmentControlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - SegmentControlView 9 | 10 | struct SegmentControlView: View { 11 | // MARK: Properties 12 | 13 | let segments: [ID] 14 | @Binding var selection: ID 15 | @ViewBuilder var content: (ID) -> ContentView 16 | @ViewBuilder var background: () -> BackgroundView 17 | 18 | // MARK: View 19 | 20 | var body: some View { 21 | ScrollViewReader { value in 22 | ScrollView(.horizontal, showsIndicators: false) { 23 | HStack { 24 | ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in 25 | SegmentControlItemView( 26 | id: segment, 27 | action: { value.scrollTo(index) }, 28 | selection: $selection, 29 | content: { content(segment) }, 30 | background: { background() } 31 | ) 32 | .id(index) 33 | .fixedSize() 34 | } 35 | } 36 | .padding(.horizontal, 20.0) 37 | } 38 | } 39 | } 40 | } 41 | 42 | // MARK: - Preview 43 | 44 | #if DEBUG 45 | struct SegmentControlView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | SegmentControlView( 48 | segments: PostType.allCases, 49 | selection: .constant(.new), 50 | content: { Text($0.title) }, 51 | background: { RoundedRectangle(cornerRadius: 20) } 52 | ) 53 | } 54 | } 55 | #endif 56 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/Views/RepliesCommentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - RepliesCommentView 9 | 10 | struct RepliesCommentView: View { 11 | // MARK: Properties 12 | 13 | let viewModel: ViewModel 14 | 15 | // MARK: View 16 | 17 | var body: some View { 18 | contentView 19 | } 20 | 21 | // MARK: Private 22 | 23 | private var commentView: some View { 24 | VStack(alignment: .leading) { 25 | Text(AttributedString(.html(withBody: viewModel.comment.text))) 26 | } 27 | } 28 | 29 | private var verticalLine: some View { 30 | RoundedRectangle(cornerRadius: 2.0) 31 | .frame(maxHeight: .infinity) 32 | .frame(width: 2.0) 33 | .foregroundStyle(.orange) 34 | } 35 | 36 | private var contentView: some View { 37 | VStack(alignment: .leading) { 38 | CommentHeaderView(viewModel: .init(username: viewModel.comment.username, date: viewModel.comment.date)) 39 | 40 | Divider() 41 | 42 | HStack { 43 | if viewModel.level > 0 { 44 | verticalLine 45 | .padding(.vertical, 4.0) 46 | } 47 | 48 | commentView 49 | } 50 | .padding(.leading, .spacing * CGFloat(viewModel.level)) 51 | } 52 | .fixedSize(horizontal: false, vertical: true) 53 | } 54 | } 55 | 56 | // MARK: RepliesCommentView.ViewModel 57 | 58 | extension RepliesCommentView { 59 | struct ViewModel: Identifiable, Equatable { 60 | var id: UUID { comment.id } 61 | let comment: CommentView.ViewModel 62 | let level: Int 63 | } 64 | } 65 | 66 | // MARK: - Constants 67 | 68 | private extension CGFloat { 69 | static let cornerRadius = 16.0 70 | static let spacing = 8.0 71 | } 72 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostsPager/PostsPaginatorService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | 9 | // MARK: - PostsPaginatorService 10 | 11 | actor PostsPaginatorService: IOffsetPageLoader { 12 | // MARK: Properties 13 | 14 | private let postsService: IPostsService 15 | private let postType: PostType 16 | 17 | private var ids: [PostType: [Int]] = [:] 18 | 19 | // MARK: Initialization 20 | 21 | init(postsService: IPostsService, postType: PostType) { 22 | self.postsService = postsService 23 | self.postType = postType 24 | } 25 | 26 | // MARK: IPaginatorService 27 | 28 | // FIXME: Implement a force update when refreshing articles 29 | func loadPage(request: OffsetPaginationRequest) async throws -> Page { 30 | let ids = try await prefetchIDs(for: postType) 31 | let posts = try await postsService.loadPosts( 32 | with: Array(ids[safe: request.offset ... request.limit + request.offset - 1]) 33 | ) 34 | 35 | let offset = request.limit + request.offset 36 | 37 | return Page( 38 | items: posts, 39 | hasMoreData: offset < ids.count 40 | ) 41 | } 42 | 43 | // MARK: Private 44 | 45 | private func prefetchIDs(for postType: PostType) async throws -> [Int] { 46 | if let ids = ids[postType], !ids.isEmpty { return ids } 47 | 48 | let ids = try await postsService.loadIDs(for: postType) 49 | self.ids[postType] = ids 50 | return ids 51 | } 52 | } 53 | 54 | // MARK: Private 55 | 56 | // FIXME: Make a public extension 57 | extension Array { 58 | subscript(safe range: ClosedRange) -> [Element] { 59 | let minIndex = Swift.max(range.lowerBound, 0) 60 | let maxIndex = Swift.min(range.upperBound, count - 1) 61 | 62 | guard minIndex <= maxIndex else { 63 | return [] 64 | } 65 | 66 | return Array(self[minIndex ... maxIndex]) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/PostDetailViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import HackerNewsLocalization 8 | 9 | // MARK: - IPostDetailViewModelFactory 10 | 11 | protocol IPostDetailViewModelFactory { 12 | func makeViewModel(from comment: Comment) -> ShortCommentView.ViewModel? 13 | } 14 | 15 | // MARK: - PostDetailViewModelFactory 16 | 17 | struct PostDetailViewModelFactory: IPostDetailViewModelFactory { 18 | // MARK: Properties 19 | 20 | private let dateTimeFormatter: RelativeDateTimeFormatter 21 | 22 | // MARK: Initialization 23 | 24 | init(dateTimeFormatter: RelativeDateTimeFormatter) { 25 | self.dateTimeFormatter = dateTimeFormatter 26 | } 27 | 28 | // MARK: IPostDetailViewModelFactory 29 | 30 | func makeViewModel(from comment: Comment) -> ShortCommentView.ViewModel? { 31 | guard let commentVM: CommentView.ViewModel = makeViewModel(from: comment) else { return nil } 32 | 33 | return ShortCommentView.ViewModel( 34 | id: comment.id, 35 | comment: commentVM, 36 | answers: comment.kids.count == .zero ? nil : L10n.Comment.expandBranch(comment.kids.count) 37 | ) 38 | } 39 | 40 | // MARK: Private 41 | 42 | private func makeViewModel(from comment: Comment) -> CommentView.ViewModel? { 43 | guard let text = comment.text, !comment.text.isNilOrEmpty else { 44 | return nil 45 | } 46 | return CommentView.ViewModel( 47 | username: comment.author ?? L10n.Comment.User.unknown, 48 | date: dateTimeFormatter.localizedString( 49 | for: Date(timeIntervalSince1970: TimeInterval(comment.time)), 50 | relativeTo: Date() 51 | ), 52 | text: text 53 | ) 54 | } 55 | } 56 | 57 | extension RelativeDateTimeFormatter { 58 | static let standard: RelativeDateTimeFormatter = { 59 | let formatter = RelativeDateTimeFormatter() 60 | formatter.unitsStyle = .full 61 | return formatter 62 | }() 63 | } 64 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - IRepliesViewModelFactory 9 | 10 | protocol IRepliesViewModelFactory { 11 | func makeViewModel(from reply: ReplyComment) -> [RepliesCommentView.ViewModel] 12 | } 13 | 14 | // MARK: - RepliesViewModelFactory 15 | 16 | final class RepliesViewModelFactory: IRepliesViewModelFactory { 17 | // MARK: Properties 18 | 19 | private let dateTimeFormatter: RelativeDateTimeFormatter 20 | 21 | // MARK: Initialization 22 | 23 | init(dateTimeFormatter: RelativeDateTimeFormatter) { 24 | self.dateTimeFormatter = dateTimeFormatter 25 | } 26 | 27 | // MARK: IRepliesViewModelFactory 28 | 29 | func makeViewModel(from reply: ReplyComment) -> [RepliesCommentView.ViewModel] { 30 | makeViewModel(from: reply, level: .zero) 31 | } 32 | 33 | // MARK: Private 34 | 35 | private func makeViewModel(from reply: ReplyComment, level: Int) -> [RepliesCommentView.ViewModel] { 36 | let comment = makeViewModel(from: reply.comment) 37 | 38 | var comments: [RepliesCommentView.ViewModel] = [RepliesCommentView.ViewModel(comment: comment, level: level)] 39 | 40 | if reply.replies.isEmpty { 41 | return comments 42 | } 43 | 44 | for reply in reply.replies where !reply.comment.text.isNilOrEmpty { 45 | let replies = makeViewModel(from: reply, level: level + 1) 46 | comments.append(contentsOf: replies) 47 | } 48 | 49 | return comments 50 | } 51 | 52 | private func makeViewModel(from comment: Comment) -> CommentView.ViewModel { 53 | CommentView.ViewModel( 54 | username: comment.author ?? "", 55 | date: dateTimeFormatter.localizedString( 56 | for: Date(timeIntervalSince1970: TimeInterval(comment.time)), 57 | relativeTo: Date() 58 | ), 59 | text: comment.text ?? "" 60 | ) 61 | } 62 | } 63 | 64 | extension String? { 65 | var isNilOrEmpty: Bool { 66 | self == nil || self?.isEmpty == true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /HackerNews/Classes/ApplicationRoot/View/TabBar/RootTabBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import SwiftUI 8 | 9 | // MARK: - RootTabBarView 10 | 11 | struct RootTabBarView: View { 12 | // MARK: Properties 13 | 14 | private let store: StoreOf 15 | private let viewFactory: RootTabBarViewFactory 16 | 17 | // MARK: Initialization 18 | 19 | init(store: StoreOf, viewFactory: RootTabBarViewFactory) { 20 | self.store = store 21 | self.viewFactory = viewFactory 22 | } 23 | 24 | // MARK: View 25 | 26 | var body: some View { 27 | WithViewStore(store, observe: { $0 }) { viewStore in 28 | TabView(selection: viewStore.binding(send: { .tab($0.tab) })) { 29 | tabs(in: viewStore) 30 | } 31 | .tint(.orange) 32 | } 33 | } 34 | 35 | // MARK: Private 36 | 37 | @ViewBuilder 38 | private func tabItemView(_ tab: Tab, @ViewBuilder content: @escaping () -> some View) -> some View { 39 | content() 40 | .tabItem { 41 | Label(title: { Text(tab.name) }, icon: { Image(systemName: tab.iconSystemName) }) 42 | } 43 | } 44 | 45 | private func tabs(in viewStore: ViewStore) -> some View { 46 | ForEach(viewStore.tabs) { tab in 47 | tabItemView(tab) { 48 | viewFactory.view(for: tab) 49 | } 50 | } 51 | } 52 | } 53 | 54 | // MARK: - ContentView_Previews 55 | 56 | // #if DEBUG 57 | // 58 | // import Home 59 | // 60 | // struct ContentView_Previews: PreviewProvider { 61 | // static var previews: some View { 62 | // RootTabBarView( 63 | // store: Store( 64 | // initialState: RootTabBarViewStore.State(tabs: [.home, .search, .settings], tab: .home) 65 | // ) { 66 | // RootTabBarViewStore() 67 | // }, 68 | // viewFactory: RootTabBarViewFactory(homePublicAssembly: HomePublicAssembly(requestProcessor: <#IRequestProcessor#>)) 69 | // ) 70 | // } 71 | // } 72 | // 73 | // #endif 74 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/CommentsPager/CommentsPaginatorService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import Foundation 8 | 9 | // MARK: - CommentsPaginatorService 10 | 11 | actor CommentsPaginatorService { 12 | // MARK: Properties 13 | 14 | private let commentsService: ICommentsService 15 | private let postsService: IPostsService 16 | 17 | private var ids: [Int] = [] 18 | 19 | // MARK: Initialization 20 | 21 | init(commentsService: ICommentsService, postsService: IPostsService) { 22 | self.commentsService = commentsService 23 | self.postsService = postsService 24 | } 25 | 26 | // MARK: IOffsetPageLoader 27 | 28 | func loadPage(request: OffsetPaginationRequest, behaviour: LoadBehaviour, postID: Int) async throws -> Page { 29 | ids = try await loadCommentsIDs(postID: postID, behaviour: behaviour) 30 | let comments = try await loadComments(ids: Array(ids[safe: request.offset ... request.limit + request.offset - 1])) 31 | 32 | return Page(items: comments, hasMoreData: comments.count < ids.count) 33 | } 34 | 35 | // MARK: Private 36 | 37 | private func loadCommentsIDs(postID: Int, behaviour: LoadBehaviour) async throws -> [Int] { 38 | var ids: [Int] = [] 39 | 40 | switch behaviour { 41 | case .reload: 42 | let post = try await postsService.loadPosts(with: [postID]).first 43 | ids = post?.kids ?? [] 44 | case .useCache: 45 | if !ids.isEmpty { return ids } 46 | return try await loadCommentsIDs(postID: postID, behaviour: .reload) 47 | } 48 | 49 | return ids 50 | } 51 | 52 | private func loadComments(ids: [Int]) async throws -> [Comment] { 53 | try await ids.asyncMap { id in 54 | try await self.commentsService.loadComment(id: id) 55 | } 56 | } 57 | } 58 | 59 | extension Sequence { 60 | func asyncMap( 61 | _ transform: (Element) async throws -> T 62 | ) async rethrows -> [T] { 63 | var values = [T]() 64 | 65 | for element in self { 66 | try await values.append(transform(element)) 67 | } 68 | 69 | return values 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import HackerNewsLocalization 8 | 9 | // MARK: - IPostViewModelFactory 10 | 11 | protocol IPostViewModelFactory { 12 | func makeViewModel(from post: Post) -> ArticleView.ViewModel 13 | } 14 | 15 | // MARK: - PostViewModelFactory 16 | 17 | final class PostViewModelFactory: IPostViewModelFactory { 18 | // MARK: Properties 19 | 20 | private let dateTimeFormatter: RelativeDateTimeFormatter 21 | 22 | // MARK: Initialization 23 | 24 | init(dateTimeFormatter: RelativeDateTimeFormatter) { 25 | self.dateTimeFormatter = dateTimeFormatter 26 | } 27 | 28 | // MARK: IPostViewModelFactory 29 | 30 | func makeViewModel(from post: Post) -> ArticleView.ViewModel { 31 | ArticleView.ViewModel( 32 | articleID: post.id, 33 | title: post.title ?? "", 34 | author: post.author ?? L10n.Comment.User.unknown, 35 | link: makeLink(post.url), 36 | rating: String(post.score ?? 0), 37 | numberOfComments: post.kids.count, 38 | date: dateTimeFormatter.localizedString( 39 | for: Date( 40 | timeIntervalSince1970: TimeInterval(post.time) 41 | ), 42 | relativeTo: Date() 43 | ), 44 | imageURL: makeImageURL(post.url), 45 | url: makeURL(post.url) 46 | ) 47 | } 48 | 49 | // MARK: Private 50 | 51 | private func makeLink(_ link: String?) -> String? { 52 | guard let link, let url = URL(string: link) else { return nil } 53 | return url.host() 54 | } 55 | 56 | private func makeImageURL(_ urlString: String?) -> URL? { 57 | guard let urlString, let url = URL( 58 | string: .extractURL + urlString 59 | ) else { return nil } 60 | return url 61 | } 62 | 63 | private func makeURL(_ urlString: String?) -> URL? { 64 | guard let urlString, let url = URL(string: urlString) else { 65 | return nil 66 | } 67 | return url 68 | } 69 | } 70 | 71 | // MARK: - Constants 72 | 73 | private extension String { 74 | static let extractURL = "http://www.google.com/s2/favicons?sz=64&domain=" 75 | } 76 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/Views/ShortCommentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | // MARK: - ShortCommentView 9 | 10 | struct ShortCommentView: View { 11 | // MARK: Properties 12 | 13 | private let viewModel: ViewModel 14 | private let action: () -> Void 15 | 16 | // MARK: Initialization 17 | 18 | init(viewModel: ViewModel, action: @escaping () -> Void) { 19 | self.viewModel = viewModel 20 | self.action = action 21 | } 22 | 23 | // MARK: View 24 | 25 | var body: some View { 26 | VStack(alignment: .center) { 27 | CommentView(viewModel: viewModel.comment) 28 | .frame(maxWidth: .infinity) 29 | 30 | if viewModel.answers != nil { 31 | Divider() 32 | } 33 | 34 | answersButton 35 | .padding(.top, 4.0) 36 | .buttonStyle(RepliesButtonStyle()) 37 | } 38 | // .padding() 39 | // .background( 40 | // RoundedRectangle(cornerRadius: .cornerRadius) 41 | // .foregroundStyle(Color(uiColor: UIColor.secondarySystemBackground)) 42 | // ) 43 | } 44 | 45 | // MARK: Private 46 | 47 | private var answersButton: some View { 48 | viewModel.answers.map { answer in 49 | Button(action: { 50 | action() 51 | }, label: { 52 | Text(answer) 53 | }) 54 | } 55 | } 56 | } 57 | 58 | // MARK: ShortCommentView.ViewModel 59 | 60 | extension ShortCommentView { 61 | struct ViewModel: Equatable, Identifiable { 62 | let id: Int 63 | let comment: CommentView.ViewModel 64 | let answers: String? 65 | } 66 | } 67 | 68 | // MARK: - Constants 69 | 70 | private extension CGFloat { 71 | static let cornerRadius = 16.0 72 | } 73 | 74 | // MARK: Preview 75 | 76 | // #Preview { 77 | // ShortCommentView( 78 | // viewModel: ShortCommentView.ViewModel( 79 | // id: 1, 80 | // comment: CommentView.ViewModel( 81 | // username: "nsvasilev", 82 | // date: "22 hours ago", 83 | // text: "Comment text" 84 | // ), 85 | // answers: "8 Replies" 86 | // ), 87 | // action: {} 88 | // ) 89 | // } 90 | -------------------------------------------------------------------------------- /Modules/Features/Home/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // swiftlint:disable prefixed_toplevel_constant 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "Home", 9 | platforms: [.iOS(.v17)], 10 | products: [ 11 | .library(name: "Home", targets: ["Home"]), 12 | .library(name: "HomeInterfaces", targets: ["HomeInterfaces"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")), 16 | .package(url: "https://github.com/space-code/network-layer.git", .upToNextMajor(from: "1.0.0")), 17 | .package(url: "https://github.com/onevcat/Kingfisher.git", .upToNextMajor(from: "7.10.1")), 18 | .package(url: "https://github.com/space-code/skeleton-ui.git", .upToNextMajor(from: "1.0.3")), 19 | .package(url: "https://github.com/space-code/blade.git", .upToNextMajor(from: "1.1.0")), 20 | .package(path: "../../Common/AppUtils"), 21 | .package(path: "../../Common/UIExtensions"), 22 | .package(path: "../../Common/HackerNewsLocalization"), 23 | .package(path: "../../Common/DesignKit"), 24 | .package(path: "../../Features/Settings"), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "Home", 29 | dependencies: [ 30 | "HomeInterfaces", 31 | .product(name: "Kingfisher", package: "Kingfisher"), 32 | .product(name: "AppUtils", package: "AppUtils"), 33 | .product(name: "NetworkLayer", package: "network-layer"), 34 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 35 | .product(name: "UIExtensions", package: "UIExtensions"), 36 | .product(name: "HackerNewsLocalization", package: "HackerNewsLocalization"), 37 | .product(name: "DesignKit", package: "DesignKit"), 38 | .product(name: "SkeletonUI", package: "skeleton-ui"), 39 | .product(name: "Blade", package: "blade"), 40 | .product(name: "BladeTCA", package: "blade"), 41 | .product(name: "SettingsInterfaces", package: "Settings"), 42 | ] 43 | ), 44 | .target(name: "HomeInterfaces", dependencies: []), 45 | .testTarget(name: "HomeTests", dependencies: ["Home"]), 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

HackerNews

2 | 3 |

4 | Licence 5 | CI 6 |

7 | 8 | ![HackerNews: A HackerNews Reader](https://github.com/user-attachments/assets/8a5ba7ae-1e1d-47de-80db-10ed725c5e5e) 9 | 10 |

11 | 12 | ## Description 13 | HackerNews is an app for readering Hacker News, built using TCA architecture. 14 | 15 | - [Features](#features) 16 | - [Requirements](#requirements) 17 | - [Usage](#usage) 18 | - [Contributing](#contributing) 19 | - [Communication](#communication) 20 | - [Have a Question](#have-a-question) 21 | - [Author](#author) 22 | - [License](#license) 23 | 24 | ## Features 25 | 26 | * View "Top," "Newest," "Best," "Ask," and "Show" posts from Hacker News 27 | * Read posts using the `SFSafariViewController` component 28 | * Full iPad multitasking support 29 | * Utilizes the official [Firebase-based Hacker News API](https://github.com/HackerNews/API) 30 | 31 | ## Usage 32 | 33 | 1) Download the repository: 34 | ``` 35 | $ git clone https://github.com/nik3212/HackerNews 36 | $ cd HackerNews 37 | ``` 38 | 39 | 2) Bootstrap the development environment: 40 | ``` 41 | make bootstrap 42 | ``` 43 | 44 | 3) Open the project in Xcode: 45 | ``` 46 | $ open HackerNews.xcodeproj 47 | ``` 48 | 49 | 4) Compile and run the app in your simulator. 50 | 51 | ## Communication 52 | - If you **found a bug**, open an issue. 53 | - If you **have a feature request**, open an issue. 54 | - If you **want to contribute**, submit a pull request. 55 | 56 | # Requirements 57 | - Xcode 15+ 58 | - iOS 17+ 59 | - Swift 5.9+ 60 | 61 | ## Contributing 62 | Please feel free to help out with this project! If you see something that could be made better or want a new feature, open up an issue or send a Pull Request! 63 | 64 | ## Have a Question? 65 | Contact us via [issues on GitHub](https://github.com/nik3212/HackerNews/issues). 66 | 67 | ## Author 68 | Nikita Vasilev, nv3212@gmail.com 69 | 70 | ## License 71 | 72 | HackerNews is available under the MIT license. See the LICENSE file for more info. 73 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Views/PostSidebarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import HackerNewsLocalization 8 | import SettingsInterfaces 9 | import SwiftUI 10 | 11 | // MARK: - PostSidebarView 12 | 13 | struct PostSidebarView: View { 14 | // MARK: Properties 15 | 16 | private let settingsAssembly: ISettingsPublicAssembly 17 | private let store: StoreOf 18 | 19 | @State private var feedExpanded = true 20 | @State private var isSettingsPresented = false 21 | 22 | // MARK: Initialization 23 | 24 | init(store: StoreOf, settingsAssembly: ISettingsPublicAssembly) { 25 | self.store = store 26 | self.settingsAssembly = settingsAssembly 27 | } 28 | 29 | // MARK: View 30 | 31 | var body: some View { 32 | WithViewStore(store, observe: { $0 }) { viewStore in 33 | VStack { 34 | sidebarView(viewStore: viewStore) 35 | settingsButton 36 | } 37 | .sheet(isPresented: $isSettingsPresented, content: { 38 | settingsAssembly.assemble() 39 | }) 40 | } 41 | } 42 | 43 | // MARK: Private 44 | 45 | private var settingsButton: some View { 46 | Button( 47 | action: { 48 | isSettingsPresented = true 49 | }, label: { 50 | HStack { 51 | Image(systemName: .gear) 52 | Text(L10n.Sidebar.Items.settings) 53 | } 54 | } 55 | ) 56 | .tint(.orange) 57 | .buttonStyle(.bordered) 58 | .buttonBorderShape(.capsule) 59 | .controlSize(.small) 60 | } 61 | 62 | private func sidebarView(viewStore: ViewStore) -> some View { 63 | List( 64 | selection: viewStore.binding( 65 | get: { $0.selectedItem }, 66 | send: { .binding($0?.id) } 67 | ) 68 | ) { 69 | DisclosureGroup( 70 | isExpanded: $feedExpanded, 71 | content: { 72 | ForEach(PostType.allCases) { postType in 73 | Label(postType.title, systemImage: postType.systemName) 74 | } 75 | }, 76 | label: { 77 | Text(L10n.Sidebar.Groups.Feeds.title) 78 | .font(.headline) 79 | } 80 | ) 81 | } 82 | .listStyle(.sidebar) 83 | .tint(.orange) 84 | .scrollContentBackground(.hidden) 85 | } 86 | } 87 | 88 | // MARK: - Constants 89 | 90 | private extension String { 91 | static let gear = "gear" 92 | } 93 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostsAssembly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import AppUtils 7 | import Blade 8 | import BladeTCA 9 | import ComposableArchitecture 10 | import SettingsInterfaces 11 | import SwiftUI 12 | 13 | // MARK: - IPostsAssembly 14 | 15 | protocol IPostsAssembly { 16 | func assemble() -> AnyView 17 | } 18 | 19 | // MARK: - PostsAssembly 20 | 21 | final class PostsAssembly: BootstrappableAssembly, IPostsAssembly { 22 | // MARK: Properties 23 | 24 | private let postsService: IPostsService 25 | private let appNameProvider: IAppNameProvider 26 | private let postDetailsAssembly: IPostDetailAssembly 27 | private let settingsAssembly: ISettingsPublicAssembly 28 | 29 | private lazy var store: StoreOf = Store( 30 | initialState: PostsViewStore.State( 31 | selectedItem: .new, 32 | paginator: PaginatorState(items: [], position: .zero) 33 | ) 34 | ) { 35 | PostsViewStore() 36 | } 37 | 38 | // MARK: Initialization 39 | 40 | init( 41 | postsService: IPostsService, 42 | appNameProvider: IAppNameProvider, 43 | postDetailsAssembly: IPostDetailAssembly, 44 | settingsAssembly: ISettingsPublicAssembly 45 | ) { 46 | self.postsService = postsService 47 | self.appNameProvider = appNameProvider 48 | self.postDetailsAssembly = postDetailsAssembly 49 | self.settingsAssembly = settingsAssembly 50 | super.init() 51 | } 52 | 53 | // MARK: Override 54 | 55 | override func bootstrap() { 56 | Locator.shared.register(.singleton) { 57 | let paginators = Dictionary(uniqueKeysWithValues: PostType.allCases.map { 58 | let paginatorService = PostsPaginatorService(postsService: self.postsService, postType: $0) 59 | let paginator = PageLoaderProvider(paginatorService: paginatorService) 60 | return ($0, paginator) 61 | }) 62 | 63 | return PostsPager(paginators: paginators) 64 | } 65 | } 66 | 67 | // MARK: Public 68 | 69 | func assemble() -> AnyView { 70 | PostsView( 71 | store: store, 72 | navigationTitleAssembly: navigationTitleAssembly, 73 | postDetailsAssembly: postDetailsAssembly, 74 | settingsAssembly: settingsAssembly 75 | ).eraseToAnyView() 76 | } 77 | 78 | // MARK: Private 79 | 80 | private var viewModelFactory: IPostViewModelFactory { 81 | PostViewModelFactory(dateTimeFormatter: RelativeDateTimeFormatter()) 82 | } 83 | 84 | private var navigationTitleAssembly: INavigationTitleAssembly { 85 | NavigationTitleAssembly( 86 | appNameProvider: appNameProvider, 87 | dateTimeFormatter: RelativeDateTimeFormatter() 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Replies/RepliesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import BladeTCA 7 | import ComposableArchitecture 8 | import HackerNewsLocalization 9 | import SkeletonUI 10 | import SwiftUI 11 | 12 | // MARK: - RepliesView 13 | 14 | struct RepliesView: View { 15 | // MARK: Properties 16 | 17 | let store: StoreOf 18 | 19 | // MARK: Initialization 20 | 21 | init(store: StoreOf) { 22 | self.store = store 23 | } 24 | 25 | // MARK: View 26 | 27 | var body: some View { 28 | WithViewStore(store, observe: { $0 }) { store in 29 | NavigationStack { 30 | contentView(with: store) 31 | .listStyle(.insetGrouped) 32 | .listRowSpacing(.listRowSpacing) 33 | .navigationTitle(L10n.Replies.NavigationBar.title) 34 | .toolbarTitleDisplayMode(.inline) 35 | } 36 | .onAppear { 37 | store.send(.refresh) 38 | } 39 | } 40 | } 41 | 42 | // MARK: Private 43 | 44 | private func contentView(with store: ViewStore) -> some View { 45 | SkeletonView( 46 | data: store.replies, 47 | quantity: .quantity, 48 | configuration: .configuration, 49 | builder: { reply, _ in 50 | reply.map { reply in 51 | RepliesCommentView(viewModel: reply) 52 | } 53 | }, 54 | skeletonBuilder: { index in 55 | reductedView(index: index) 56 | } 57 | ) 58 | } 59 | 60 | private func reductedView(index: Int) -> some View { 61 | VStack { 62 | if index == .zero { 63 | HStack { 64 | RoundedRectangle(cornerRadius: .cornerRadius) 65 | RoundedRectangle(cornerRadius: .cornerRadius) 66 | } 67 | } else { 68 | RoundedRectangle(cornerRadius: .cornerRadius) 69 | } 70 | } 71 | } 72 | } 73 | 74 | // MARK: - Constants 75 | 76 | private extension Int { 77 | static let quantity = 20 78 | static let numberOfLines = 4 79 | } 80 | 81 | private extension CGFloat { 82 | static let cornerRadius = 8.0 83 | static let inset = 8.0 84 | static let listRowSpacing = 8.0 85 | } 86 | 87 | private extension SkeletonConfiguration { 88 | static let configuration = SkeletonConfiguration( 89 | numberOfLines: .numberOfLines, 90 | scales: [0.25, 1.0, 0.8], 91 | insets: .init(top: .inset, leading: .zero, bottom: .inset, trailing: .zero), 92 | gradient: Gradient(stops: [ 93 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 0.8), 94 | .init(color: Color(uiColor: .gray).opacity(0.5), location: 0.9), 95 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 1.0), 96 | ]) 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Modules 93 | 94 | Modules/Common/HackerNewsLocalization/Sources/HackerNewsLocalization/Classes/Strings.swift 95 | Modules/Common/DesignKit/Sources/DesignKit/Classes/Design/Generated/Colors.swift 96 | Modules/Common/DesignKit/Sources/DesignKit/Classes/Design/Generated/Fonts.swift 97 | Modules/Features/Settings/Sources/Settings/Classes/Generated/Assets.swift 98 | 99 | # Others 100 | 101 | .cache 102 | *.xcodeproj 103 | *.env.default 104 | *.p8 105 | fastlane/api-key.json 106 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostsViewStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import BladeTCA 8 | import ComposableArchitecture 9 | import Foundation 10 | 11 | // MARK: - PostsViewStore 12 | 13 | @Reducer 14 | struct PostsViewStore { 15 | // MARK: Types 16 | 17 | struct State: Equatable { 18 | @BindingState var selectedItem: PostType 19 | var selectedPostID: ArticleView.ViewModel.ID? 20 | @PresentationState var postDetail: PostDetailFeature.State? 21 | 22 | var paginator: PaginatorState 23 | } 24 | 25 | enum Action { 26 | case refresh 27 | case binding(PostType?) 28 | 29 | case selectItem(ArticleView.ViewModel) 30 | case postDetail(PresentationAction) 31 | case child(PaginatorAction) 32 | } 33 | 34 | // MARK: Properties 35 | 36 | @Dependency(\.postsViewModelFactory) var viewModelFactory 37 | @Dependency(\.postsPager) var pager 38 | 39 | // MARK: Reducer 40 | 41 | var body: some ReducerOf { 42 | Reduce { state, action in 43 | switch action { 44 | case .refresh: 45 | return .send(.child(.requestPage(.initial)), animation: .default) 46 | case let .binding(postType): 47 | guard let postType, postType != state.selectedItem else { 48 | return .none 49 | } 50 | state.paginator.items = [] 51 | state.selectedItem = postType 52 | state.postDetail = nil 53 | return .send(.refresh) 54 | case let .selectItem(viewModel): 55 | state.selectedPostID = viewModel.id 56 | state.postDetail = .init( 57 | viewModel: viewModel, 58 | paginator: .init(items: [], position: .zero) 59 | ) 60 | return .none 61 | case .child: 62 | return .none 63 | case .postDetail(.dismiss), .postDetail(.presented(.close)): 64 | state.selectedPostID = nil 65 | state.postDetail = nil 66 | return .none 67 | case .postDetail(.presented): 68 | return .none 69 | } 70 | } 71 | .paginator( 72 | state: \PostsViewStore.State.paginator, 73 | action: /PostsViewStore.Action.child, 74 | loadPage: { request, state in 75 | try await pager.load(request: request, postType: state.selectedItem) 76 | .map { viewModelFactory.makeViewModel(from: $0) } 77 | } 78 | ) 79 | .ifLet(\.$postDetail, action: \.postDetail) { 80 | PostDetailFeature() 81 | } 82 | } 83 | } 84 | 85 | // MARK: - Constants 86 | 87 | private extension OffsetPaginationRequest { 88 | static let initial = OffsetPaginationRequest(limit: 20, offset: .zero) 89 | } 90 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - .build 3 | - .swiftpm 4 | - .cache 5 | - "Modules/Common/HackerNewsLocalization" 6 | 7 | # Rules 8 | 9 | disabled_rules: 10 | - trailing_comma 11 | - todo 12 | - opening_brace 13 | - identifier_name 14 | - large_tuple 15 | - multiple_closures_with_trailing_closure 16 | - attributes 17 | - blanket_disable_command 18 | opt_in_rules: # some rules are only opt-in 19 | - anyobject_protocol 20 | - array_init 21 | - closure_body_length 22 | - closure_end_indentation 23 | - closure_spacing 24 | - collection_alignment 25 | - contains_over_filter_count 26 | - contains_over_filter_is_empty 27 | - contains_over_first_not_nil 28 | - contains_over_range_nil_comparison 29 | - convenience_type 30 | - discouraged_object_literal 31 | - empty_collection_literal 32 | - empty_count 33 | - empty_string 34 | - empty_xctest_method 35 | - enum_case_associated_values_count 36 | - explicit_init 37 | - fallthrough 38 | - fatal_error_message 39 | - file_name 40 | - first_where 41 | - flatmap_over_map_reduce 42 | - force_unwrapping 43 | - ibinspectable_in_extension 44 | - identical_operands 45 | - implicit_return 46 | - inert_defer 47 | - joined_default_parameter 48 | - last_where 49 | - legacy_multiple 50 | - legacy_random 51 | - literal_expression_end_indentation 52 | - lower_acl_than_parent 53 | - multiline_arguments 54 | - multiline_function_chains 55 | - multiline_literal_brackets 56 | - multiline_parameters 57 | - multiline_parameters_brackets 58 | - no_space_in_method_call 59 | - operator_usage_whitespace 60 | - optional_enum_case_matching 61 | - orphaned_doc_comment 62 | - overridden_super_call 63 | - pattern_matching_keywords 64 | - prefer_self_type_over_type_of_self 65 | - prefer_zero_over_explicit_init 66 | - prefixed_toplevel_constant 67 | - private_action 68 | - prohibited_super_call 69 | - quick_discouraged_call 70 | - quick_discouraged_focused_test 71 | - quick_discouraged_pending_test 72 | - reduce_into 73 | - redundant_nil_coalescing 74 | - redundant_objc_attribute 75 | - redundant_type_annotation 76 | - required_enum_case 77 | - single_test_class 78 | - sorted_first_last 79 | - sorted_imports 80 | - static_operator 81 | - strict_fileprivate 82 | - switch_case_on_newline 83 | - toggle_bool 84 | - unavailable_function 85 | - unneeded_parentheses_in_closure_argument 86 | - unowned_variable_capture 87 | - untyped_error_in_catch 88 | - vertical_parameter_alignment_on_call 89 | - vertical_whitespace_closing_braces 90 | - vertical_whitespace_opening_braces 91 | - xct_specific_matcher 92 | - yoda_condition 93 | 94 | force_cast: warning 95 | force_try: warning 96 | 97 | analyzer_rules: 98 | - unused_import 99 | - unused_declaration 100 | 101 | line_length: 102 | warning: 130 103 | error: 200 104 | 105 | type_body_length: 106 | warning: 300 107 | error: 400 108 | 109 | file_length: 110 | warning: 500 111 | error: 1200 112 | 113 | function_body_length: 114 | warning: 30 115 | error: 50 116 | 117 | nesting: 118 | type_level: 119 | warning: 2 120 | statement_level: 121 | warning: 10 122 | 123 | type_name: 124 | max_length: 125 | warning: 50 126 | error: 60 127 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/PostDetailFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import BladeTCA 8 | import ComposableArchitecture 9 | import Foundation 10 | 11 | // MARK: - PostDetailFeature 12 | 13 | @Reducer 14 | struct PostDetailFeature { 15 | // MARK: Types 16 | 17 | struct State: Equatable { 18 | var viewModel: ArticleView.ViewModel 19 | var paginator: PaginatorState 20 | var isSafariViewPresented: Bool = false 21 | var safariURL: URL? 22 | var hasComments: Bool = true 23 | 24 | @PresentationState var replies: RepliesFeature.State? 25 | } 26 | 27 | enum Action { 28 | case presentSafariView(URL?) 29 | case dismissSafariView 30 | case refresh 31 | case postLoaded(post: Post?) 32 | case replyButtonTapped(commentID: Int) 33 | case child(PaginatorAction) 34 | case replies(PresentationAction) 35 | case close 36 | } 37 | 38 | // MARK: Dependencies 39 | 40 | @Dependency(\.postsService) var postsService 41 | @Dependency(\.commentsPager) var pager 42 | @Dependency(\.postDetailViewModelFactory) var viewModelFactory 43 | @Dependency(\.postsViewModelFactory) var postsViewModelFactory 44 | 45 | // MARK: Reducer 46 | 47 | var body: some ReducerOf { 48 | Reduce { state, action in 49 | switch action { 50 | case .refresh: 51 | return .run { [state] send in 52 | let post = try await postsService.loadPosts(with: [state.viewModel.articleID]) 53 | return await send(.postLoaded(post: post.first)) 54 | } 55 | case let .presentSafariView(url): 56 | state.safariURL = url 57 | state.isSafariViewPresented = true 58 | return .none 59 | case .dismissSafariView: 60 | state.isSafariViewPresented = false 61 | state.safariURL = nil 62 | return .none 63 | case let .postLoaded(post): 64 | if let post { 65 | state.viewModel = postsViewModelFactory.makeViewModel(from: post) 66 | state.hasComments = !post.kids.isEmpty 67 | } 68 | return .send(.child(.requestPage(.initial)), animation: .default) 69 | case .child: 70 | return .none 71 | case .replies: 72 | return .none 73 | case let .replyButtonTapped(commentID): 74 | state.replies = RepliesFeature.State(commentID: commentID, replies: []) 75 | return .none 76 | case .close: 77 | return .none 78 | } 79 | } 80 | .paginator( 81 | state: \PostDetailFeature.State.paginator, 82 | action: /PostDetailFeature.Action.child, 83 | loadPage: { request, state in 84 | try await pager.load(request: request, postID: state.viewModel.articleID) 85 | .compactMap { viewModelFactory.makeViewModel(from: $0) } 86 | } 87 | ) 88 | .ifLet(\.$replies, action: \.replies) { 89 | RepliesFeature() 90 | } 91 | } 92 | } 93 | 94 | // MARK: - Constants 95 | 96 | private extension OffsetPaginationRequest { 97 | static let initial = OffsetPaginationRequest(limit: 5, offset: .zero) 98 | } 99 | -------------------------------------------------------------------------------- /Modules/Features/Settings/Sources/Settings/Classes/UI/Presentation/UserStories/RootSettings/RootSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import DesignKit 8 | import HackerNewsLocalization 9 | import SwiftUI 10 | import UIExtensions 11 | 12 | // MARK: - RootSettingsView 13 | 14 | struct RootSettingsView: View { 15 | // MARK: Properties 16 | 17 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 18 | @Environment(\.presentationMode) private var presentationMode 19 | 20 | let store: StoreOf 21 | 22 | // MARK: Initialization 23 | 24 | init(store: StoreOf) { 25 | self.store = store 26 | } 27 | 28 | // MARK: View 29 | 30 | var body: some View { 31 | WithViewStore(store, observe: { $0 }) { viewStore in 32 | NavigationView { 33 | contentView(viewStore) 34 | .navigationTitle(L10n.Settings.Navbar.title) 35 | .toolbar { 36 | if horizontalSizeClass != .compact { 37 | ToolbarItem(placement: .navigationBarLeading) { 38 | Button(L10n.Common.Actions.cancel) { 39 | presentationMode.wrappedValue.dismiss() 40 | } 41 | .tint(.orange) 42 | } 43 | } 44 | } 45 | } 46 | .listStyle(.insetGrouped) 47 | .tint(Color(uiColor: .label)) 48 | } 49 | } 50 | 51 | private var headerView: some View { 52 | HStack { 53 | Asset.Settings.headerIcon.swiftUIImage 54 | 55 | VStack { 56 | Text(String.appName) 57 | .font(FontFamily.Montserrat.semiBold.font(size: .size17).sui) 58 | Text(L10n.Settings.author(String.author)) 59 | .font(FontFamily.Montserrat.semiBold.font(size: .size13).sui) 60 | } 61 | } 62 | } 63 | 64 | private func contentView(_ viewStore: ViewStore) -> some View { 65 | List { 66 | headerView 67 | 68 | Button(action: { 69 | viewStore.send(.sendFeedback) 70 | }) { 71 | NavigationLink(L10n.Settings.Menu.sendFeedback, destination: EmptyView()) 72 | } 73 | .sheet(isPresented: viewStore.binding( 74 | get: \.isEmailSheetPresented, 75 | send: RootSettingsFeature.Action.setEmailSheetPresented 76 | )) { 77 | MailView(isPresented: viewStore.binding( 78 | get: \.isEmailSheetPresented, 79 | send: RootSettingsFeature.Action.setEmailSheetPresented 80 | )) 81 | } 82 | 83 | Button(action: { 84 | viewStore.send(.rateUs) 85 | }) { 86 | NavigationLink(L10n.Settings.Menu.rateUs, destination: EmptyView()) 87 | } 88 | 89 | Button(action: { 90 | viewStore.send(.openGitHub) 91 | }) { 92 | NavigationLink(L10n.Settings.Menu.github, destination: EmptyView()) 93 | } 94 | } 95 | } 96 | } 97 | 98 | // MARK: - Constants 99 | 100 | private extension String { 101 | static let author = "Nikita Vasilev" 102 | static let appName = "HackerNews" 103 | } 104 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/Views/PostListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import BladeTCA 8 | import ComposableArchitecture 9 | import SkeletonUI 10 | import SwiftUI 11 | 12 | // MARK: - PostListView 13 | 14 | struct PostListView: View { 15 | // MARK: Properties 16 | 17 | @State private var selectedID: ArticleView.ViewModel.ID? 18 | 19 | private let store: StoreOf 20 | 21 | // MARK: Initialization 22 | 23 | init(store: StoreOf) { 24 | self.store = store 25 | } 26 | 27 | // MARK: View 28 | 29 | var body: some View { 30 | PaginatorView( 31 | store: store.scope(state: \.paginator, action: \.child), 32 | content: { state, handler in 33 | ScrollViewReader { reader in 34 | SkeletonView( 35 | data: state, 36 | quantity: .quantity, 37 | configuration: .configuration, 38 | builder: { article, index in 39 | WithViewStore(store, observe: { $0 }) { store in 40 | article.map { article in 41 | handler(article) 42 | .id(index) 43 | .onTapGesture { store.send(.selectItem(article)) } 44 | } 45 | } 46 | }, skeletonBuilder: { index in 47 | reductedView(index: index) 48 | } 49 | ) 50 | .onChange(of: state.isEmpty) { _, _ in 51 | withAnimation { 52 | reader.scrollTo(Int.zero) 53 | } 54 | } 55 | } 56 | }, 57 | rowContent: { item -> ArticleView in 58 | ArticleView(viewModel: item) 59 | } 60 | ) 61 | } 62 | 63 | // MARK: Private 64 | 65 | private func reductedView(index: Int) -> some View { 66 | VStack { 67 | if index == 3 { 68 | HStack { 69 | RoundedRectangle(cornerRadius: .cornerRadius) 70 | RoundedRectangle(cornerRadius: .cornerRadius) 71 | RoundedRectangle(cornerRadius: .cornerRadius) 72 | RoundedRectangle(cornerRadius: .cornerRadius) 73 | } 74 | } else { 75 | RoundedRectangle(cornerRadius: .cornerRadius) 76 | } 77 | } 78 | } 79 | } 80 | 81 | // MARK: - Constants 82 | 83 | private extension Int { 84 | static let quantity = 20 85 | static let numberOfLines = 4 86 | } 87 | 88 | private extension CGFloat { 89 | static let cornerRadius = 8.0 90 | static let inset = 8.0 91 | } 92 | 93 | private extension SkeletonConfiguration { 94 | static let configuration = SkeletonConfiguration( 95 | numberOfLines: .numberOfLines, 96 | scales: [0.5, 1.0, 0.3, 1], 97 | insets: .init(top: .inset, leading: .zero, bottom: .inset, trailing: .zero), 98 | gradient: Gradient(stops: [ 99 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 0.8), 100 | .init(color: Color(uiColor: .gray).opacity(0.5), location: 0.9), 101 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 1.0), 102 | ]) 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /Modules/Common/UIExtensions/Sources/UIExtensions/Classes/Helpers/NSAttributedString+HTML.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | // swiftlint:disable function_body_length force_unwrapping non_optional_string_data_conversion 9 | public extension NSAttributedString { 10 | static func html(withBody body: String) -> NSAttributedString { 11 | // Match the HTML `lang` attribute to current localisation used by the app (aka Bundle.main). 12 | let bundle = Bundle.main 13 | let lang = bundle.preferredLocalizations.first 14 | ?? bundle.developmentLocalization 15 | ?? "en" 16 | 17 | return (try? NSAttributedString( 18 | data: """ 19 | 20 | 21 | 22 | 23 | 46 | 47 | 48 | 49 | \(body) 50 | 51 | 52 | 53 | """.data(using: .utf8)!, 54 | options: [ 55 | .documentType: NSAttributedString.DocumentType.html, 56 | .characterEncoding: NSUTF8StringEncoding, 57 | ], 58 | documentAttributes: nil 59 | )) ?? NSAttributedString(string: body) 60 | } 61 | } 62 | 63 | // swiftlint:enable function_body_length force_unwrapping non_optional_string_data_conversion 64 | 65 | // MARK: Converting UIColors into CSS friendly color hex string 66 | 67 | private extension UIColor { 68 | var hex: String { 69 | var red: CGFloat = 0 70 | var green: CGFloat = 0 71 | var blue: CGFloat = 0 72 | var alpha: CGFloat = 0 73 | 74 | getRed(&red, green: &green, blue: &blue, alpha: &alpha) 75 | 76 | return String( 77 | format: "#%02lX%02lX%02lX%02lX", 78 | lroundf(Float(red * 255)), 79 | lroundf(Float(green * 255)), 80 | lroundf(Float(blue * 255)), 81 | lroundf(Float(alpha * 255)) 82 | ) 83 | } 84 | } 85 | 86 | // extension NSAttributedString { 87 | // func trimmedAttributedString() -> NSAttributedString { 88 | // let nonNewlines = CharacterSet.whitespacesAndNewlines.inverted 89 | // 90 | // // Find first non-whitespace character and new line character 91 | // let startRange = string.rangeOfCharacter(from: nonNewlines) 92 | // 93 | // // Find last non-whitespace character and new line character. 94 | // let endRange = string.rangeOfCharacter(from: nonNewlines, options: .backwards) 95 | // guard let startLocation = startRange?.lowerBound, let endLocation = endRange?.lowerBound else { 96 | // return self 97 | // } 98 | // // Getting range out of locations. This trim out leading and trailing whitespaces and new line characters. 99 | // let range = NSRange(startLocation...endLocation, in: string) 100 | // return attributedSubstring(from: range) 101 | // } 102 | // } 103 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/SharedComponents/UIComponents/ArticleView/ArticleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import DesignKit 7 | import HackerNewsLocalization 8 | import SwiftUI 9 | 10 | // MARK: - ArticleView 11 | 12 | struct ArticleView: View { 13 | // MARK: Properties 14 | 15 | private let viewModel: ViewModel 16 | 17 | // MARK: Initialization 18 | 19 | init(viewModel: ViewModel) { 20 | self.viewModel = viewModel 21 | } 22 | 23 | // MARK: View 24 | 25 | var body: some View { 26 | VStack(alignment: .leading) { 27 | headerView 28 | 29 | titleView 30 | .padding(.vertical, 4.0) 31 | 32 | Divider() 33 | 34 | footerView 35 | } 36 | .fixedSize(horizontal: false, vertical: true) 37 | } 38 | 39 | // MARK: Private 40 | 41 | private var headerView: some View { 42 | HStack(alignment: .center) { 43 | imageView 44 | 45 | viewModel.link.map { 46 | Text($0) 47 | .font(FontFamily.Montserrat.medium.font(size: .size12).sui) 48 | .foregroundColor(Color.orange) 49 | .lineLimit(1) 50 | } 51 | } 52 | } 53 | 54 | private var titleView: some View { 55 | Text(viewModel.title) 56 | .font(FontFamily.Montserrat.semiBold.font(size: .size17).sui) 57 | .lineLimit(.lineLimit) 58 | } 59 | 60 | private var imageView: some View { 61 | viewModel.imageURL.map { 62 | ImageView(url: $0) 63 | .frame(width: .headerImageSize, height: .headerImageSize) 64 | .background(Color.white) 65 | .clipShape(Circle()) 66 | } 67 | } 68 | 69 | private var footerView: some View { 70 | HStack { 71 | IndicatorView(viewModel: IndicatorView.ViewModel(imageName: .arrowUp, text: viewModel.rating)) 72 | Divider() 73 | IndicatorView(viewModel: IndicatorView.ViewModel(imageName: .person, text: viewModel.author)) 74 | Divider() 75 | IndicatorView(viewModel: IndicatorView.ViewModel(imageName: .comments, text: "\(viewModel.numberOfComments)")) 76 | Divider() 77 | IndicatorView(viewModel: IndicatorView.ViewModel(imageName: .calendar, text: viewModel.date)) 78 | } 79 | } 80 | } 81 | 82 | // MARK: ArticleView.ViewModel 83 | 84 | extension ArticleView { 85 | struct ViewModel: Equatable, Identifiable { 86 | let id = UUID() 87 | let articleID: Int 88 | let title: String 89 | let author: String 90 | let link: String? 91 | let rating: String 92 | let numberOfComments: Int 93 | let date: String 94 | let imageURL: URL? 95 | let url: URL? 96 | } 97 | } 98 | 99 | // MARK: - Constants 100 | 101 | private extension Int { 102 | static let lineLimit = 2 103 | } 104 | 105 | // MARK: Constants 106 | 107 | private extension CGFloat { 108 | static let headerSpacing = 4.0 109 | static let headerImageSize = 12.0 110 | } 111 | 112 | private extension String { 113 | static let arrowUp = "arrow.up.circle.fill" 114 | static let person = "person.circle.fill" 115 | static let calendar = "calendar.circle.fill" 116 | static let comments = "line.3.horizontal.circle.fill" 117 | static let dot = "•" 118 | } 119 | 120 | // MARK: - Preview 121 | 122 | #if DEBUG 123 | 124 | private var viewModel = ArticleView.ViewModel( 125 | articleID: .zero, 126 | title: "Proton Mail Rewrites Your Emails", 127 | author: "jamesik", 128 | link: "x64.sh", 129 | rating: "64", 130 | numberOfComments: 30, 131 | date: "22 hours ago", 132 | imageURL: nil, 133 | url: nil 134 | ) 135 | 136 | struct ArticleView_Previews: PreviewProvider { 137 | static var previews: some View { 138 | ArticleView(viewModel: viewModel) 139 | } 140 | } 141 | 142 | #endif 143 | -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: HackerNews 2 | options: 3 | developmentLanguage: en 4 | createIntermediateGroups: true 5 | deploymentTarget: 6 | iOS: 17.0 7 | xcodeVersion: 15.2 8 | configs: 9 | Debug: debug 10 | Beta: beta 11 | Release: release 12 | configFiles: 13 | Debug: ./HackerNews/Resources/Configurations/Debug.xcconfig 14 | Beta: ./HackerNews/Resources/Configurations/Beta.xcconfig 15 | Release: ./HackerNews/Resources/Configurations/Release.xcconfig 16 | settings: 17 | base: 18 | SWIFT_VERSION: "5.9" 19 | configs: 20 | Debug: 21 | DEVELOPMENT_TEAM: A8WE5LL2GU 22 | OTHER_SWIFT_FLAGS: -D DEBUG 23 | SWIFT_OPTIMIZATION_LEVEL: -Onone 24 | GCC_OPTIMIZATION_LEVEL: 1 25 | SWIFT_COMPILATION_MODE: "incremental" 26 | Beta: 27 | DEVELOPMENT_TEAM: A8WE5LL2GU 28 | OTHER_SWIFT_FLAGS: -D DEBUG 29 | SWIFT_OPTIMIZATION_LEVEL: -O 30 | GCC_OPTIMIZATION_LEVEL: 1 31 | SWIFT_COMPILATION_MODE: "incremental" 32 | Release: 33 | DEVELOPMENT_TEAM: A8WE5LL2GU 34 | OTHER_SWIFT_FLAGS: -D RELEASE 35 | OTHER_LDFLAGS: -Objc 36 | SWIFT_COMPILATION_MODE: wholemodule 37 | packages: 38 | # Common 39 | 40 | HackerNewsLocalization: 41 | path: Modules/Common/HackerNewsLocalization 42 | UIExtensions: 43 | path: Modules/Common/UIExtensions 44 | AppUtils: 45 | path: Modules/Common/AppUtils 46 | DesignKit: 47 | path: Modules/Common/DesignKit 48 | 49 | # Feature 50 | 51 | Home: 52 | path: Modules/Features/Home 53 | Settings: 54 | path: Modules/Features/Settings 55 | 56 | # External 57 | 58 | ComposableArchitecture: 59 | url: https://github.com/pointfreeco/swift-composable-architecture.git 60 | from: 1.13.1 61 | SwiftCollections: 62 | url: https://github.com/apple/swift-collections.git 63 | from: 1.0.5 64 | Pulse: 65 | url: https://github.com/kean/Pulse.git 66 | from: 4.0.5 67 | 68 | attributes: 69 | ORGANIZATIONNAME: Nikita Vasilev 70 | schemes: 71 | Debug: 72 | build: 73 | targets: 74 | HackerNews: all 75 | run: 76 | config: Debug 77 | test: 78 | gatherCoverageData: true 79 | targets: 80 | - HackerNewsTests 81 | - package: Home/HomeTests 82 | - package: Settings/SettingsTests 83 | coverageTargets: 84 | - HackerNews 85 | - package: Home/Home 86 | - package: Settings/Settings 87 | Release: 88 | build: 89 | targets: 90 | HackerNews: all 91 | run: 92 | config: Release 93 | Beta: 94 | build: 95 | targets: 96 | HackerNews: all 97 | targets: 98 | HackerNews: 99 | type: application 100 | platform: iOS 101 | dependencies: 102 | - package: HackerNewsLocalization 103 | - package: ComposableArchitecture 104 | - package: SwiftCollections 105 | product: OrderedCollections 106 | - package: Home 107 | - package: Settings 108 | - package: UIExtensions 109 | - package: DesignKit 110 | - package: Pulse 111 | product: PulseUI 112 | sources: 113 | - path: HackerNews 114 | settings: 115 | base: 116 | MARKETING_VERSION: 3.0.0 117 | CURRENT_PROJECT_VERSION: 1 118 | TARGETED_DEVICE_FAMILY: "1,2" 119 | configs: 120 | Beta: 121 | PRODUCT_NAME: HackerNews 122 | PRODUCT_BUNDLE_IDENTIFIER: com.nikitavasilev.HackerNews.beta 123 | CODE_SIGN_IDENTITY: "iPhone Developer" 124 | PROVISIONING_PROFILE_SPECIFIER: match Development com.nikitavasilev.HackerNews.beta 125 | Debug: 126 | PRODUCT_NAME: HackerNews 127 | PRODUCT_BUNDLE_IDENTIFIER: com.nikitavasilev.HackerNews.debug 128 | CODE_SIGN_IDENTITY: "iPhone Developer" 129 | PROVISIONING_PROFILE_SPECIFIER: match Development com.nikitavasilev.HackerNews.debug 130 | Release: 131 | PRODUCT_NAME: HackerNews 132 | PRODUCT_BUNDLE_IDENTIFIER: com.nikitavasilev.HackerNews 133 | CODE_SIGN_IDENTITY: "iPhone Distribution" 134 | PROVISIONING_PROFILE_SPECIFIER: match AppStore com.nikitavasilev.HackerNews 135 | prebuildScripts: 136 | - script: | 137 | make swiftgen 138 | name: SwiftGen 139 | - script: | 140 | if [[ "${CONFIGURATION}" == "Debug" || "${CONFIGURATION}" == "Beta" ]]; then 141 | export PATH="$PATH:/opt/homebrew/bin" 142 | 143 | echo "[Swiftlint] Run" 144 | 145 | if which mint >/dev/null; then 146 | xcrun --sdk macosx make lint 147 | 148 | echo "[Swiftlint] Complete" 149 | else 150 | echo "[Swiftlint] Bootstrap mint" 151 | fi 152 | fi 153 | name: SwiftLint 154 | HackerNewsTests: 155 | type: bundle.unit-test 156 | platform: iOS 157 | settings: 158 | GENERATE_INFOPLIST_FILE: YES 159 | BUNDLE_LOADER: $(BUILT_PRODUCTS_DIR)/HackerNews.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HackerNews 160 | sources: 161 | - HackerNewsTests 162 | dependencies: 163 | - target: HackerNews 164 | -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/Posts/PostsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2023 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import ComposableArchitecture 7 | import DesignKit 8 | import SettingsInterfaces 9 | import SwiftUI 10 | 11 | // MARK: - PostsView 12 | 13 | struct PostsView: View { 14 | // MARK: Properties 15 | 16 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 17 | 18 | @State private var columnVisibility = NavigationSplitViewVisibility.doubleColumn 19 | @State private var isLoading = false 20 | @State private var isPresented = false 21 | 22 | private let store: StoreOf 23 | private let navigationTitleAssembly: INavigationTitleAssembly 24 | private let postDetailsAssembly: IPostDetailAssembly 25 | private let settingsAssembly: ISettingsPublicAssembly 26 | 27 | // MARK: Initialization 28 | 29 | init( 30 | store: StoreOf, 31 | navigationTitleAssembly: INavigationTitleAssembly, 32 | postDetailsAssembly: IPostDetailAssembly, 33 | settingsAssembly: ISettingsPublicAssembly 34 | ) { 35 | self.store = store 36 | self.navigationTitleAssembly = navigationTitleAssembly 37 | self.postDetailsAssembly = postDetailsAssembly 38 | self.settingsAssembly = settingsAssembly 39 | } 40 | 41 | // MARK: View 42 | 43 | var body: some View { 44 | VStack { 45 | if horizontalSizeClass == .compact { 46 | compactView 47 | } else { 48 | splitView 49 | } 50 | } 51 | .onAppear { 52 | Task { 53 | await refresh() 54 | } 55 | } 56 | } 57 | 58 | // MARK: Private 59 | 60 | private var postListView: some View { 61 | PostListView(store: store) 62 | .scrollDisabled(isLoading) 63 | .listStyle(.insetGrouped) 64 | .listRowSpacing(.listRowSpacing) 65 | .refreshable { 66 | await refresh() 67 | } 68 | } 69 | 70 | private var compactView: some View { 71 | WithViewStore(store, observe: { $0 }) { viewStore in 72 | NavigationStack { 73 | VStack(spacing: .compactSpacing) { 74 | segmentedControlView(viewStore: viewStore) 75 | postListView 76 | .navigationDestination(store: store.scope(state: \.$postDetail, action: \.postDetail)) { store in 77 | postDetailsAssembly.assemble(store: store) 78 | } 79 | } 80 | .navigationBarTitleDisplayMode(.inline) 81 | .toolbar { toolbarView } 82 | } 83 | } 84 | } 85 | 86 | private var splitView: some View { 87 | NavigationSplitView( 88 | columnVisibility: $columnVisibility, 89 | sidebar: { 90 | PostSidebarView(store: store, settingsAssembly: settingsAssembly) 91 | .toolbar { toolbarView } 92 | }, content: { 93 | WithViewStore(store, observe: { $0 }) { viewStore in 94 | postListView 95 | .navigationTitle(viewStore.selectedItem.title) 96 | } 97 | }, detail: { 98 | NavigationStack { 99 | IfLetStore(store.scope(state: \.$postDetail, action: \.postDetail)) { store in 100 | postDetailsAssembly.assemble(store: store) 101 | } 102 | } 103 | } 104 | ) 105 | .listStyle(.sidebar) 106 | .navigationSplitViewStyle(.balanced) 107 | .tint(.orange) 108 | } 109 | 110 | private var toolbarView: some ToolbarContent { 111 | ToolbarItem(placement: .topBarLeading) { 112 | navigationTitleAssembly.assemble() 113 | .padding(.bottom, 8.0) 114 | } 115 | } 116 | 117 | private func segmentedControlView( 118 | viewStore: ViewStore 119 | ) -> some View { 120 | SegmentControlView( 121 | segments: PostType.allCases, 122 | selection: viewStore.binding( 123 | get: \.selectedItem, 124 | send: { .binding($0) } 125 | ), 126 | content: { segment in 127 | HStack(alignment: .center, spacing: .spacing) { 128 | Image(systemName: segment.systemName) 129 | Text(segment.title) 130 | .font(FontFamily.Montserrat.semiBold.font(size: .size17).sui) 131 | } 132 | .padding(.insets) 133 | .foregroundColor(segment == viewStore.selectedItem ? Color(uiColor: .white) : Asset.dynamicLightGray.swiftUIColor) 134 | .background(segment == viewStore.selectedItem ? Color(uiColor: .systemOrange) : Asset.dynamicGray.swiftUIColor) 135 | }, 136 | background: { 137 | RoundedRectangle(cornerRadius: .cornerRadius, style: .continuous) 138 | } 139 | ) 140 | } 141 | 142 | @MainActor 143 | private func refresh() async { 144 | defer { isLoading = false } 145 | isLoading = true 146 | await store.send(.refresh).finish() 147 | } 148 | } 149 | 150 | // MARK: - Constants 151 | 152 | private extension CGFloat { 153 | static let cornerRadius = 20.0 154 | static let spacing = 4.0 155 | static let listRowSpacing = 8.0 156 | static let compactSpacing = 8.0 157 | } 158 | 159 | private extension EdgeInsets { 160 | static let insets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) 161 | } 162 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | #!usr/bin/ruby 2 | 3 | fastlane_version '2.0' 4 | 5 | default_platform :ios 6 | 7 | api_key = "fastlane/api-key.json" 8 | xcodeproj = "HackerNews.xcodeproj" 9 | 10 | before_all do 11 | ENV['FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT'] = '120' 12 | build_number = increment_build_number 13 | version_number = get_version_number(xcodeproj: xcodeproj) 14 | end 15 | 16 | after_all do |lane| 17 | # Discard all uncommitted changes(the new app icon images) 18 | clean_icons 19 | end 20 | 21 | platform :ios do 22 | ## General 23 | 24 | desc "Generate new certificates" 25 | lane :generate_new_certificates do 26 | sync_code_signing( 27 | type: "development", 28 | app_identifier: ['com.nikitavasilev.HackerNews.beta', 'com.nikitavasilev.HackerNews.debug', 'com.nikitavasilev.HackerNews'], 29 | force_for_new_devices: true, 30 | readonly: false 31 | ) 32 | sync_code_signing( 33 | type: "appstore", 34 | app_identifier: ['com.nikitavasilev.HackerNews.beta', 'com.nikitavasilev.HackerNews.debug', 'com.nikitavasilev.HackerNews'], 35 | force_for_new_devices: true, 36 | readonly: false 37 | ) 38 | end 39 | 40 | desc "Regester devices on apple portal" 41 | lane :register do 42 | register_devices( 43 | devices_file: "./fastlane/devices.txt" 44 | ) 45 | match( 46 | type: "development", 47 | force_for_new_devices: true, 48 | ) 49 | end 50 | 51 | ## Builing apps 52 | 53 | desc "Create a HackerNews Beta build for TestFlight" 54 | lane :beta do 55 | overrideParams = { 56 | scheme: "Debug", 57 | export_method: "app-store" 58 | } 59 | 60 | add_badge_to_icon(release: "beta") 61 | 62 | testflight_build({overrideParams: overrideParams}) 63 | end 64 | 65 | desc "Create a HackerNews Production build for TestFlight" 66 | lane :production do 67 | overrideParams = { 68 | scheme: "Release", 69 | export_method: "app-store" 70 | } 71 | 72 | testflight_build({overrideParams: overrideParams}) 73 | end 74 | 75 | ## Tests 76 | 77 | desc "Run Unit Tests" 78 | lane :test do |options| 79 | scan( 80 | scheme: "Debug", 81 | cloned_source_packages_path: ".cache/spm", 82 | clean: true, 83 | device: "iPhone 14 Pro", 84 | reset_simulator: true, 85 | reinstall_app: true, 86 | skip_detect_devices: true, 87 | parallel_testing: false, 88 | code_coverage: true, 89 | use_system_scm: true, 90 | result_bundle: true, 91 | output_directory: "./build/test", 92 | output_files: "hackernews.unit.test.html,hackernews.unit.test.report.junit.xml", 93 | xcargs: "-skipMacroValidation", 94 | ) 95 | 96 | slather( 97 | cobertura_xml: true, 98 | output_directory: "./build/test", 99 | proj: "HackerNews.xcodeproj", 100 | scheme: "Debug", 101 | binary_basename: "HackerNews", 102 | ) 103 | end 104 | 105 | # Private Methods 106 | 107 | desc "Upload build to TestFlight" 108 | private_lane :testflight_build do |options| 109 | overrideParams = options[:overrideParams] 110 | defaultParams = gym_params() 111 | gym(defaultParams.merge!(overrideParams)) 112 | 113 | changelog = changelog_from_git_commits( 114 | between: ["dev", "HEAD"], 115 | pretty: "- (%ae) %s",# Optional, lets you provide a custom format to apply to each commit when generating the changelog text 116 | date_format: "short",# Optional, lets you provide an additional date format to dates within the pretty-formatted string 117 | match_lightweight_tag: false, # Optional, lets you ignore lightweight (non-annotated) tags when searching for the last tag 118 | merge_commit_filtering: "exclude_merges" # Optional, lets you filter out merge commits 119 | ) 120 | 121 | pilot( 122 | api_key_path: api_key, 123 | skip_submission: true, 124 | skip_waiting_for_build_processing: false, 125 | changelog: changelog, 126 | groups: "Internal" 127 | ) 128 | 129 | send_message_to_slack( 130 | message: "🎉 The new version released on TestFlight", 131 | environment: overrideParams[:scheme] 132 | ) 133 | end 134 | 135 | desc "Returns the parameters that should be used in any fastlane build" 136 | lane :gym_params do 137 | { 138 | project: xcodeproj, 139 | sdk: "iphoneos", 140 | clean: true, 141 | output_directory: "build" 142 | } 143 | end 144 | 145 | private_lane :add_badge_to_icon do |options| 146 | version_number = lane_context[SharedValues::VERSION_NUMBER] 147 | build_number = lane_context[SharedValues::BUILD_NUMBER] 148 | if options[:release] == "beta" 149 | add_badge( 150 | shield: "#{version_number}-#{build_number}-orange", 151 | no_badge: false, 152 | ) 153 | end 154 | end 155 | 156 | private_lane :clean_icons do 157 | # Regardless of git flags, always want to forcefully reset icon changes 158 | reset_git_repo(files: ["HackerNews/Resources/Assets.xcassets/AppIcon*"], force: true) 159 | end 160 | 161 | private_lane :send_message_to_slack do |options| 162 | version_number = lane_context[SharedValues::VERSION_NUMBER] 163 | build_number = lane_context[SharedValues::BUILD_NUMBER] 164 | 165 | slack( 166 | message: options[:message], 167 | payload: { 168 | "Build Date" => Time.new.to_s, 169 | "Environment": options[:environment], 170 | "Version": version_number, 171 | "Build Number": build_number 172 | } 173 | ) 174 | end 175 | end -------------------------------------------------------------------------------- /Modules/Features/Home/Sources/Home/Classes/UI/Presentation/UserStories/PostDetail/PostDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HackerNews 3 | // Copyright © 2024 Nikita Vasilev. All rights reserved. 4 | // 5 | 6 | import Blade 7 | import BladeTCA 8 | import ComposableArchitecture 9 | import HackerNewsLocalization 10 | import SkeletonUI 11 | import SwiftUI 12 | 13 | // MARK: - PostDetailView 14 | 15 | struct PostDetailView: View { 16 | // MARK: Properties 17 | 18 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 19 | 20 | @State private var isLoading = false 21 | 22 | private let repliesAssembly: IRepliesAssembly 23 | 24 | let store: StoreOf 25 | 26 | // MARK: Initialization 27 | 28 | init(store: StoreOf, repliesAssembly: IRepliesAssembly) { 29 | self.repliesAssembly = repliesAssembly 30 | self.store = store 31 | } 32 | 33 | // MARK: View 34 | 35 | var body: some View { 36 | contentView 37 | .listStyle(.insetGrouped) 38 | .listRowSpacing(.inset) 39 | .navigationBarTitleDisplayMode(.inline) 40 | .navigationTitle(L10n.PostDetails.NavigationBar.title) 41 | .navigationDestination(store: store.scope(state: \.$replies, action: \.replies)) { store in 42 | repliesAssembly.assemble(store: store) 43 | } 44 | .refreshable { await refresh() } 45 | .onAppear { store.send(.refresh) } 46 | } 47 | 48 | // MARK: Private 49 | 50 | private var contentView: some View { 51 | WithViewStore(store, observe: { $0 }) { store in 52 | toolbar(with: store) { 53 | if store.hasComments { 54 | commentsView(with: store) 55 | } else { 56 | emptyView(with: store) 57 | } 58 | } 59 | } 60 | } 61 | 62 | private func toolbar( 63 | with store: ViewStore, 64 | @ViewBuilder content: () -> some View 65 | ) -> some View { 66 | content() 67 | .toolbar { 68 | if horizontalSizeClass != .compact { 69 | ToolbarItem(placement: .navigationBarLeading) { 70 | Button(L10n.Common.Actions.close) { 71 | store.send(.close) 72 | } 73 | .tint(.orange) 74 | } 75 | } 76 | } 77 | } 78 | 79 | private func commentsView(with store: ViewStore) -> some View { 80 | PaginatorView( 81 | store: self.store.scope(state: \.paginator, action: \.child), 82 | content: { state, handler in 83 | SkeletonView( 84 | data: state, 85 | quantity: .quantity, 86 | configuration: .configuration, 87 | builder: { comment, index in 88 | if index == .zero { 89 | articleView(with: store) 90 | } 91 | 92 | comment.map { comment in 93 | handler(comment) 94 | } 95 | }, 96 | skeletonBuilder: { index in 97 | reductedView(index: index) 98 | } 99 | ) 100 | }, 101 | rowContent: { item -> AnyView in 102 | ShortCommentView( 103 | viewModel: item, 104 | action: { 105 | store.send(.replyButtonTapped(commentID: item.id)) 106 | } 107 | ) 108 | .eraseToAnyView() 109 | } 110 | ) 111 | } 112 | 113 | private func emptyView(with store: ViewStore) -> some View { 114 | List { 115 | articleView(with: store) 116 | } 117 | } 118 | 119 | private func articleView(with store: ViewStore) -> some View { 120 | ArticleView(viewModel: store.viewModel) 121 | .onTapGesture { store.send(.presentSafariView(store.viewModel.url)) } 122 | .fullScreenCover( 123 | isPresented: store.binding( 124 | get: \.isSafariViewPresented, 125 | send: PostDetailFeature.Action.dismissSafariView 126 | ) 127 | ) { 128 | if let url = store.safariURL { 129 | SafariView(url: url, onDismiss: { 130 | store.send(.dismissSafariView) 131 | }) 132 | } 133 | } 134 | } 135 | 136 | private func reductedView(index: Int) -> some View { 137 | VStack { 138 | if index == 0 { 139 | HStack { 140 | RoundedRectangle(cornerRadius: .cornerRadius) 141 | RoundedRectangle(cornerRadius: .cornerRadius) 142 | } 143 | } else { 144 | RoundedRectangle(cornerRadius: .cornerRadius) 145 | } 146 | } 147 | } 148 | 149 | @MainActor 150 | private func refresh() async { 151 | defer { isLoading = false } 152 | isLoading = true 153 | await store.send(.refresh).finish() 154 | } 155 | } 156 | 157 | // MARK: - Constants 158 | 159 | private extension Int { 160 | static let quantity = 20 161 | static let numberOfLines = 4 162 | } 163 | 164 | private extension CGFloat { 165 | static let cornerRadius = 8.0 166 | static let inset = 8.0 167 | } 168 | 169 | private extension SkeletonConfiguration { 170 | static let configuration = SkeletonConfiguration( 171 | numberOfLines: .numberOfLines, 172 | scales: [0.25, 1.0, 0.8], 173 | insets: .init(top: .inset, leading: .zero, bottom: .inset, trailing: .zero), 174 | gradient: Gradient(stops: [ 175 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 0.8), 176 | .init(color: Color(uiColor: .gray).opacity(0.5), location: 0.9), 177 | .init(color: Color(uiColor: .gray).opacity(0.3), location: 1.0), 178 | ]) 179 | ) 180 | } 181 | --------------------------------------------------------------------------------