├── .gitignore ├── .swiftformat ├── .swiftlint.yml ├── App ├── App.project.yml └── Sources │ ├── AppDelegate.swift │ ├── Info.plist │ ├── Resolver │ ├── Resolver+InfrastructureServices.swift │ ├── Resolver+RepositoryServices.swift │ ├── Resolver+ResolverRegistering.swift │ ├── Resolver+UIServices.swift │ └── Views │ │ ├── Resolver+AppSalesView.swift │ │ ├── Resolver+FeedLanguageSettingView.swift │ │ ├── Resolver+FeedListView.swift │ │ ├── Resolver+FeedView.swift │ │ ├── Resolver+FullScreenModalView.swift │ │ ├── Resolver+KeyboardTestView.swift │ │ ├── Resolver+LicensesView.swift │ │ ├── Resolver+MainView.swift │ │ ├── Resolver+ModalTransitionTestView.swift │ │ ├── Resolver+NoticeAlertView.swift │ │ ├── Resolver+PageSheetModalView.swift │ │ ├── Resolver+PushTransitionTestView.swift │ │ ├── Resolver+RootView.swift │ │ ├── Resolver+SettingsMenuView.swift │ │ ├── Resolver+SettingsView.swift │ │ ├── Resolver+ToolbarTestView.swift │ │ ├── Resolver+UserNameSettingView.swift │ │ ├── Resolver+WalkthroughFinishView.swift │ │ ├── Resolver+WalkthroughIntroView.swift │ │ ├── Resolver+WalkthroughSettingsView.swift │ │ └── Resolver+WalkthroughView.swift │ └── SceneDelegate.swift ├── Entities ├── Entities.project.yml └── Sources │ ├── Data │ ├── AppInformation.swift │ ├── AppSegment.swift │ ├── FeedLanguage.swift │ ├── Navigation.swift │ ├── News.swift │ └── SettingsMenu.swift │ ├── Errors │ └── FeedClientError.swift │ └── PreviewContents │ ├── AppInformation+PreviewContents.swift │ └── News+PreviewContents.swift ├── Infrastructures ├── FeedClient │ ├── FeedClient.project.yml │ ├── Sources │ │ ├── Extensions │ │ │ ├── Date+Formatter.swift │ │ │ └── URL+EndPoints.swift │ │ └── FeedClient.swift │ └── Tests │ │ ├── FetchFreeAppRankingTests.swift │ │ ├── FetchNewsTests.swift │ │ ├── FetchPaidAppRankingTests.swift │ │ └── Resources │ │ ├── stub_free_app_ranking_feed_15_en.xml │ │ ├── stub_free_app_ranking_feed_15_jp.xml │ │ ├── stub_free_app_ranking_feed_1_en.xml │ │ ├── stub_free_app_ranking_feed_1_jp.xml │ │ ├── stub_news_feed_1_en.xml │ │ ├── stub_news_feed_1_jp.xml │ │ ├── stub_news_feed_20_en.xml │ │ ├── stub_news_feed_20_jp.xml │ │ ├── stub_paid_app_ranking_feed_15_en.xml │ │ ├── stub_paid_app_ranking_feed_15_jp.xml │ │ ├── stub_paid_app_ranking_feed_1_en.xml │ │ └── stub_paid_app_ranking_feed_1_jp.xml ├── InfrastructureServices │ ├── InfrastructureServices.project.yml │ └── Sources │ │ ├── FeedClientService.swift │ │ └── UserDefaultsManagerService.swift └── UserDefaultsManager │ ├── Sources │ ├── UserDefaultsKey.swift │ └── UserDefaultsManager.swift │ └── UserDefaultsManager.project.yml ├── LICENSE ├── Makefile ├── MockingbirdSupport ├── AppKit │ ├── NSResponder.swift │ ├── NSView.swift │ └── NSViewController.swift ├── Foundation │ └── ObjectiveC │ │ └── NSObject.swift ├── LICENSE.md ├── Swift │ ├── Codable.swift │ ├── Comparable.swift │ ├── Equatable.swift │ ├── Hashable.swift │ └── Misc.swift └── UIKit │ ├── UIControl.swift │ ├── UILabel.swift │ ├── UIResponder.swift │ ├── UIView.swift │ └── UIViewController.swift ├── README.md ├── Repositories ├── AppStateRepository │ ├── AppStateRepository.project.yml │ ├── Sources │ │ └── AppStateRepository.swift │ └── Tests │ │ ├── AppStateRepositoryTests.swift │ │ └── Generated │ │ └── .gitkeep ├── FeedRepository │ ├── FeedRepository.project.yml │ └── Sources │ │ └── FeedRepository.swift ├── PreferencesRepository │ ├── PreferencesRepository.project.yml │ └── Sources │ │ └── PreferencesRepository.swift └── RepositoryServices │ ├── RepositoryServices.project.yml │ └── Sources │ ├── AppStateRepositoryService.swift │ ├── FeedRepositoryService.swift │ ├── PreferencesRepositoryService.swift │ └── PreviewContents │ ├── PreviewFeedRepository.swift │ └── PreviewPreferencesRepository.swift ├── Resources ├── Resources.project.yml ├── Resources.swiftgen.yml └── Sources │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Colors │ │ ├── Contents.json │ │ └── background.colorset │ │ │ └── Contents.json │ └── Contents.json │ ├── Licenses.md │ └── Settings.bundle │ ├── Root.plist │ └── en.lproj │ └── Root.strings ├── UI ├── UIComponents │ ├── Sources │ │ ├── AppIcon.swift │ │ ├── AppRankingButton.swift │ │ ├── AppRankingStack.swift │ │ ├── BulletPointText.swift │ │ ├── NavigationLinkButton.swift │ │ ├── NewsLargeCard.swift │ │ ├── NewsSmallCard.swift │ │ ├── RadioButton.swift │ │ └── RadioButtonGroup.swift │ └── UIComponents.project.yml ├── UIServices │ ├── Sources │ │ ├── AppSales │ │ │ └── AppSalesFlowControllerService.swift │ │ ├── Feed │ │ │ └── FeedFlowControllerService.swift │ │ ├── FeedLanguageSetting │ │ │ └── FeedLanguageSettingFlowControllerService.swift │ │ ├── FeedList │ │ │ ├── FeedListFlowControllerDelegate.swift │ │ │ └── FeedListFlowControllerService.swift │ │ ├── FullScreenModal │ │ │ ├── FullScreenModalFlowControllerDelegate.swift │ │ │ └── FullScreenModalFlowControllerService.swift │ │ ├── KeyboardTest │ │ │ └── KeyboardTestFlowControllerService.swift │ │ ├── Licenses │ │ │ └── LicensesFlowControllerService.swift │ │ ├── Main │ │ │ └── MainFlowControllerService.swift │ │ ├── ModalTransitionTest │ │ │ └── ModalTransitionTestFlowControllerService.swift │ │ ├── NoticeAlert │ │ │ ├── NoticeAlertFlowControllerDelegate.swift │ │ │ └── NoticeAlertFlowControllerService.swift │ │ ├── PageSheetModal │ │ │ ├── PageSheetModalFlowControllerDelegate.swift │ │ │ └── PageSheetModalFlowControllerService.swift │ │ ├── PushTransitionTest │ │ │ ├── PushTransitionTestFlowControllerDelegate.swift │ │ │ └── PushTransitionTestFlowControllerService.swift │ │ ├── Root │ │ │ └── RootFlowControllerService.swift │ │ ├── Settings │ │ │ └── SettingsFlowControllerService.swift │ │ ├── SettingsMenu │ │ │ ├── SettingsMenuFlowControllerDelegate.swift │ │ │ └── SettingsMenuFlowControllerService.swift │ │ ├── ToolbarTest │ │ │ └── ToolbarTestFlowControllerService.swift │ │ ├── UIViewController+Content.swift │ │ ├── UIViewController+OpenLink.swift │ │ ├── UserNameSetting │ │ │ └── UserNameSettingFlowControllerService.swift │ │ ├── Walkthrough │ │ │ └── WalkthroughFlowControllerService.swift │ │ ├── WalkthroughFinish │ │ │ ├── WalkthroughFinishFlowControllerDelegate.swift │ │ │ └── WalkthroughFinishFlowControllerService.swift │ │ ├── WalkthroughIntro │ │ │ └── WalkthroughIntroFlowControllerService.swift │ │ └── WalkthroughSettings │ │ │ ├── WalkthroughSettingsFlowControllerDelegate.swift │ │ │ └── WalkthroughSettingsFlowControllerService.swift │ └── UIServices.project.yml └── Views │ ├── AppSalesView │ ├── AppSalesView.project.yml │ ├── Sources │ │ ├── AppSalesFlowController.swift │ │ ├── View │ │ │ └── AppSalesView.swift │ │ └── ViewModel │ │ │ ├── AppSalesViewModel+Navigation.swift │ │ │ └── AppSalesViewModel.swift │ └── Tests │ │ ├── AppSalesFlowControllerTests.swift │ │ ├── AppSalesViewModelTests.swift │ │ └── Generated │ │ └── .gitkeep │ ├── FeedLanguageSettingView │ ├── FeedLanguageSettingView.project.yml │ └── Sources │ │ ├── FeedLanguageSettingFlowController.swift │ │ ├── View │ │ └── FeedLanguageSettingView.swift │ │ └── ViewModel │ │ └── FeedLanguageSettingViewModel.swift │ ├── FeedListView │ ├── FeedListView.project.yml │ └── Sources │ │ ├── FeedListFlowController.swift │ │ ├── View │ │ ├── FeedListView.swift │ │ └── Section │ │ │ ├── AllNewsSection.swift │ │ │ ├── AppRankingSection.swift │ │ │ └── LatestNewsSection.swift │ │ └── ViewModel │ │ ├── FeedListViewModel+Navigation.swift │ │ └── FeedListViewModel.swift │ ├── FeedView │ ├── FeedView.project.yml │ └── Sources │ │ ├── FeedFlowController.swift │ │ └── ViewModel │ │ └── FeedViewModel.swift │ ├── FullScreenModalView │ ├── FullScreenModalView.project.yml │ └── Sources │ │ ├── FullScreenModalFlowController.swift │ │ ├── View │ │ └── FullScreenModalView.swift │ │ └── ViewModel │ │ ├── FullScreenModalViewModel+Navigation.swift │ │ └── FullScreenModalViewModel.swift │ ├── KeyboardTestView │ ├── KeyboardTestView.project.yml │ └── Sources │ │ ├── KeyboardTestFlowController.swift │ │ ├── View │ │ └── KeyboardTestView.swift │ │ └── ViewModel │ │ └── KeyboardTestViewModel.swift │ ├── LicensesView │ ├── LicensesView.project.yml │ └── Sources │ │ ├── LicensesFlowController.swift │ │ ├── View │ │ └── LicensesView.swift │ │ └── ViewModel │ │ └── LicensesViewModel.swift │ ├── MainView │ ├── MainView.project.yml │ └── Sources │ │ ├── MainFlowController.swift │ │ └── ViewModel │ │ └── MainViewModel.swift │ ├── ModalTransitionTestView │ ├── ModalTransitionTestView.project.yml │ └── Sources │ │ ├── ModalTransitionTestFlowController.swift │ │ ├── View │ │ └── ModalTransitionTestView.swift │ │ └── ViewModel │ │ ├── ModalTransitionTestViewModel+Navigation.swift │ │ └── ModalTransitionTestViewModel.swift │ ├── NoticeAlertView │ ├── NoticeAlertView.project.yml │ └── Sources │ │ └── NoticeAlertFlowController.swift │ ├── PageSheetModalView │ ├── PageSheetModalView.project.yml │ └── Sources │ │ ├── PageSheetModalFlowController.swift │ │ ├── View │ │ └── PageSheetModalView.swift │ │ └── ViewModel │ │ ├── PageSheetModalViewModel+Navigation.swift │ │ └── PageSheetModalViewModel.swift │ ├── PushTransitionTestView │ ├── PushTransitionTestView.project.yml │ └── Sources │ │ ├── PushTransitionTestFlowController.swift │ │ ├── View │ │ └── PushTransitionTestView.swift │ │ └── ViewModel │ │ ├── PushTransitionTestViewModel+Navigation.swift │ │ └── PushTransitionTestViewModel.swift │ ├── RootView │ ├── RootView.project.yml │ ├── Sources │ │ ├── RootFlowController.swift │ │ └── ViewModel │ │ │ ├── RootViewModel+Navigation.swift │ │ │ └── RootViewModel.swift │ └── Tests │ │ ├── Generated │ │ └── .gitkeep │ │ ├── RootViewControllerTests.swift │ │ └── RootViewModelTests.swift │ ├── SettingsMenuView │ ├── SettingsMenuView.project.yml │ └── Sources │ │ ├── SettingsMenuFlowController.swift │ │ ├── View │ │ └── SettingsMenuView.swift │ │ └── ViewModel │ │ ├── SettingsMenuViewModel+Navigation.swift │ │ └── SettingsMenuViewModel.swift │ ├── SettingsView │ ├── SettingsView.project.yml │ └── Sources │ │ ├── SettingsFlowController.swift │ │ └── ViewModel │ │ └── SettingsViewModel.swift │ ├── ToolbarTestView │ ├── Sources │ │ ├── ToolbarTestFlowController.swift │ │ ├── View │ │ │ └── ToolbarTestView.swift │ │ └── ViewModel │ │ │ └── ToolbarTestViewModel.swift │ └── ToolbarTestView.project.yml │ ├── UserNameSettingView │ ├── Sources │ │ ├── UserNameSettingFlowController.swift │ │ ├── View │ │ │ └── UserNameSettingView.swift │ │ └── ViewModel │ │ │ └── UserNameSettingViewModel.swift │ └── UserNameSettingView.project.yml │ ├── WalkthroughFinishView │ ├── Sources │ │ ├── View │ │ │ └── WalkthroughFinishView.swift │ │ ├── ViewModel │ │ │ ├── WalkthroughFinishViewModel+Navigation.swift │ │ │ └── WalkthroughFinishViewModel.swift │ │ └── WalkthroughFinishFlowController.swift │ └── WalkthroughFinishView.project.yml │ ├── WalkthroughIntroView │ ├── Sources │ │ ├── View │ │ │ └── WalkthroughIntroView.swift │ │ ├── ViewModel │ │ │ └── WalkthroughIntroViewModel.swift │ │ └── WalkthroughIntroFlowController.swift │ └── WalkthroughIntroView.project.yml │ ├── WalkthroughSettingsView │ ├── Sources │ │ ├── View │ │ │ └── WalkthroughSettingsView.swift │ │ ├── ViewModel │ │ │ └── WalkthroughSettingsViewModel.swift │ │ └── WalkthroughSettingsFlowController.swift │ └── WalkthroughSettingsView.project.yml │ └── WalkthroughView │ ├── Sources │ ├── ViewModel │ │ └── WalkthroughViewModel.swift │ └── WalkthroughFlowController.swift │ └── WalkthroughView.project.yml ├── bin └── .gitkeep ├── images ├── architecture │ ├── concept.png │ ├── data_flow.png │ ├── data_flow2.png │ ├── event_flow.png │ └── transition.png └── screenshots │ ├── app_sales.png │ ├── feed_language_setting.png │ ├── feed_list_1.png │ ├── feed_list_2.png │ ├── feed_list_3.png │ ├── keyboard_test.png │ ├── licenses.png │ ├── modal_transition_test.png │ ├── push_transition_test.png │ ├── settings_menu.png │ ├── toolbar_test.png │ ├── user_name_setting.png │ ├── walkthrough_finish.png │ ├── walkthrough_intro.png │ └── walkthrough_settings.png ├── lib └── .gitkeep ├── project.yml ├── scripts ├── local-install-licenseplist.sh ├── local-install-swiftformat.sh ├── local-install-swiftgen.sh ├── local-install-swiftlint.sh └── local-install-xcodegen.sh └── swiftui-flowcontroller-pattern.xcodeproj ├── project.xcworkspace └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved └── xcshareddata └── IDETemplateMacros.plist /App/App.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | App: 3 | type: application 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontrollerpattern 8 | TARGETED_DEVICE_FAMILY: 1 9 | MARKETING_VERSION: "1.0.0" 10 | CURRENT_PROJECT_VERSION: 1 11 | PRODUCT_NAME: "SwiftUI-FlowController Pattern" 12 | PRODUCT_MODULE_NAME: App 13 | GENERATE_INFOPLIST_FILE: YES 14 | INFOPLIST_FILE: "App/Sources/Info.plist" 15 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES 16 | INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen 17 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: 18 | UIInterfaceOrientationPortrait 19 | sources: 20 | - path: Sources 21 | group: App 22 | preBuildScripts: 23 | - name: Format and linting 24 | script: | 25 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 App/Sources 26 | ${SRCROOT}/bin/swiftlint App/Sources 27 | dependencies: 28 | - target: Entities 29 | - target: InfrastructureServices 30 | - target: FeedClient 31 | - target: UserDefaultsManager 32 | - target: RepositoryServices 33 | - target: AppStateRepository 34 | - target: FeedRepository 35 | - target: PreferencesRepository 36 | - target: Resources 37 | - target: UIServices 38 | - target: UIComponents 39 | - target: AppSalesView 40 | - target: FeedLanguageSettingView 41 | - target: FeedListView 42 | - target: FeedView 43 | - target: FullScreenModalView 44 | - target: KeyboardTestView 45 | - target: LicensesView 46 | - target: MainView 47 | - target: ModalTransitionTestView 48 | - target: NoticeAlertView 49 | - target: PageSheetModalView 50 | - target: PushTransitionTestView 51 | - target: RootView 52 | - target: SettingsMenuView 53 | - target: SettingsView 54 | - target: ToolbarTestView 55 | - target: UserNameSettingView 56 | - target: WalkthroughFinishView 57 | - target: WalkthroughIntroView 58 | - target: WalkthroughSettingsView 59 | - target: WalkthroughView 60 | - package: Resolver 61 | -------------------------------------------------------------------------------- /App/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application( 6 | _: UIApplication, 7 | // swiftlint:disable:next discouraged_optional_collection 8 | didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | true 11 | } 12 | 13 | func application( 14 | _: UIApplication, 15 | configurationForConnecting connectingSceneSession: UISceneSession, 16 | options _: UIScene.ConnectionOptions 17 | ) -> UISceneConfiguration { 18 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /App/Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Resolver+InfrastructureServices.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import Resolver 4 | 5 | import FeedClient 6 | import InfrastructureServices 7 | import UserDefaultsManager 8 | 9 | extension Resolver { 10 | static func registerInfrastructureServices() { 11 | register { FeedClient() as FeedClientService } 12 | .scope(.application) 13 | register { UserDefaultsManager(userDefaults: UserDefaults.standard) as UserDefaultsManagerService } 14 | .scope(.application) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Resolver+RepositoryServices.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import AppStateRepository 4 | import FeedRepository 5 | import PreferencesRepository 6 | import RepositoryServices 7 | 8 | extension Resolver { 9 | static func registerRepositoryServices() { 10 | register { AppStateRepository(userDefaultsManager: resolve()) as AppStateRepositoryService } 11 | .scope(.application) 12 | register { FeedRepository(feedClient: resolve()) as FeedRepositoryService } 13 | .scope(.application) 14 | register { PreferencesRepository(userDefaultsManager: resolve()) as PreferencesRepositoryService } 15 | .scope(.application) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Resolver+ResolverRegistering.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import Resolver 4 | 5 | extension Resolver: ResolverRegistering { 6 | public static func registerAllServices() { 7 | registerInfrastructureServices() 8 | registerRepositoryServices() 9 | registerUIServices() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Resolver+UIServices.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import AppSalesView 4 | import FeedLanguageSettingView 5 | import FeedListView 6 | import UIServices 7 | 8 | extension Resolver { 9 | static func registerUIServices() { 10 | registerAppSalesView() 11 | registerFeedLanguageSettingView() 12 | registerFeedListView() 13 | registerFeedView() 14 | registerFullScreenModalView() 15 | registerKeyboardTestView() 16 | registerLicensesView() 17 | registerMainView() 18 | registerModalTransitionTestView() 19 | registerNoticeAlertView() 20 | registerPageSheetModalView() 21 | registerPushTransitionTestView() 22 | registerRootView() 23 | registerSettingsMenuView() 24 | registerSettingsView() 25 | registerToolbarTestView() 26 | registerUserNameSettingView() 27 | registerWalkthroughFinishView() 28 | registerWalkthroughIntroView() 29 | registerWalkthroughSettingsView() 30 | registerWalkthroughView() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+AppSalesView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import AppSalesView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerAppSalesView() { 8 | register { AppSalesViewModel(feedRepository: resolve(), preferencesRepository: resolve()) } 9 | register { AppSalesView(viewModel: resolve()) } 10 | register { AppSalesFlowController(rootView: resolve()) as AppSalesFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+FeedLanguageSettingView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import FeedLanguageSettingView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerFeedLanguageSettingView() { 8 | register { FeedLanguageSettingViewModel(preferencesRepository: resolve()) } 9 | register { FeedLanguageSettingView(viewModel: resolve()) } 10 | register { FeedLanguageSettingFlowController(rootView: resolve()) as FeedLanguageSettingFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+FeedListView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import FeedListView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerFeedListView() { 8 | register { FeedListViewModel(feedRepository: resolve(), preferencesRepository: resolve()) } 9 | register { FeedListView(viewModel: resolve()) } 10 | register { FeedListFlowController(rootView: resolve()) as FeedListFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+FeedView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import FeedView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerFeedView() { 8 | register { FeedViewModel() } 9 | register { 10 | FeedFlowController( 11 | viewModel: resolve(), 12 | appSalesProvider: resolve(), 13 | feedListProvider: resolve() 14 | ) as FeedFlowControllerService 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+FullScreenModalView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import FullScreenModalView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerFullScreenModalView() { 8 | register { FullScreenModalViewModel() } 9 | register { FullScreenModalView(viewModel: resolve()) } 10 | register { FullScreenModalFlowController(rootView: resolve()) as FullScreenModalFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+KeyboardTestView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import KeyboardTestView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerKeyboardTestView() { 8 | register { KeyboardTestViewModel() } 9 | register { KeyboardTestView(viewModel: resolve()) } 10 | register { KeyboardTestFlowController(rootView: resolve()) as KeyboardTestFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+LicensesView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import LicensesView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerLicensesView() { 8 | register { LicensesViewModel() } 9 | register { LicensesView(viewModel: resolve()) } 10 | register { LicensesFlowController(rootView: resolve()) as LicensesFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+MainView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import MainView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerMainView() { 8 | register { MainViewModel() } 9 | register { 10 | MainFlowController( 11 | viewModel: resolve(), 12 | feedProvider: resolve(), 13 | settingsProvider: resolve() 14 | ) as MainFlowControllerService 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+ModalTransitionTestView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import ModalTransitionTestView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerModalTransitionTestView() { 8 | register { ModalTransitionTestViewModel() } 9 | register { ModalTransitionTestView(viewModel: resolve()) } 10 | register { 11 | ModalTransitionTestFlowController( 12 | rootView: resolve(), 13 | fullScreenModalProvider: resolve(), 14 | noticeAlertProvider: resolve(), 15 | pageSheetModalProvider: resolve() 16 | ) as ModalTransitionTestFlowControllerService 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+NoticeAlertView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import NoticeAlertView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerNoticeAlertView() { 8 | register { NoticeAlertFlowController.instantiate() as NoticeAlertFlowControllerService } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+PageSheetModalView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import PageSheetModalView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerPageSheetModalView() { 8 | register { PageSheetModalViewModel() } 9 | register { PageSheetModalView(viewModel: resolve()) } 10 | register { PageSheetModalFlowController(rootView: resolve()) as PageSheetModalFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+PushTransitionTestView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import PushTransitionTestView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerPushTransitionTestView() { 8 | register { PushTransitionTestViewModel() } 9 | register { PushTransitionTestView(viewModel: resolve()) } 10 | register { PushTransitionTestFlowController(rootView: resolve()) as PushTransitionTestFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+RootView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import RootView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerRootView() { 8 | register { RootViewModel(appStateRepository: resolve()) } 9 | register { 10 | RootFlowController( 11 | rootViewModel: resolve(), 12 | walkthroughProvider: resolve(), 13 | mainProvider: resolve() 14 | ) as RootFlowControllerService 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+SettingsMenuView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import SettingsMenuView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerSettingsMenuView() { 8 | register { SettingsMenuViewModel(preferenceRepository: resolve()) } 9 | register { SettingsMenuView(viewModel: resolve()) } 10 | register { SettingsMenuFlowController(rootView: resolve()) as SettingsMenuFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+SettingsView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import SettingsView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerSettingsView() { 8 | register { SettingsViewModel() } 9 | register { 10 | SettingsFlowController( 11 | viewModel: resolve(), 12 | settingsMenuProvider: resolve(), 13 | userNameSettingProvider: resolve(), 14 | feedLanguageSettingProvider: resolve(), 15 | keyboardTestProvider: resolve(), 16 | toolbarTestProvider: resolve(), 17 | pushTransitionTestProvider: resolve(), 18 | modalTransitionTestProvider: resolve(), 19 | licensesProvider: resolve() 20 | ) as SettingsFlowControllerService 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+ToolbarTestView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import ToolbarTestView 4 | import UIServices 5 | 6 | extension Resolver { 7 | static func registerToolbarTestView() { 8 | register { ToolbarTestViewModel() } 9 | register { ToolbarTestView(viewModel: resolve()) } 10 | register { ToolbarTestFlowController(rootView: resolve()) as ToolbarTestFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+UserNameSettingView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import UIServices 4 | import UserNameSettingView 5 | 6 | extension Resolver { 7 | static func registerUserNameSettingView() { 8 | register { UserNameSettingViewModel(preferencesRepository: resolve()) } 9 | register { UserNameSettingView(viewModel: resolve()) } 10 | register { UserNameSettingFlowController(rootView: resolve()) as UserNameSettingFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+WalkthroughFinishView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import UIServices 4 | import WalkthroughFinishView 5 | 6 | extension Resolver { 7 | static func registerWalkthroughFinishView() { 8 | register { WalkthroughFinishViewModel() } 9 | register { WalkthroughFinishView(viewModel: resolve()) } 10 | register { WalkthroughFinishFlowController(rootView: resolve()) as WalkthroughFinishFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+WalkthroughIntroView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import UIServices 4 | import WalkthroughIntroView 5 | 6 | extension Resolver { 7 | static func registerWalkthroughIntroView() { 8 | register { WalkthroughIntroViewModel() } 9 | register { WalkthroughIntroView(viewModel: resolve()) } 10 | register { WalkthroughIntroFlowController(rootView: resolve()) as WalkthroughIntroFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+WalkthroughSettingsView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import UIServices 4 | import WalkthroughSettingsView 5 | 6 | extension Resolver { 7 | static func registerWalkthroughSettingsView() { 8 | register { WalkthroughSettingsViewModel() } 9 | register { WalkthroughSettingsView(viewModel: resolve()) } 10 | register { WalkthroughSettingsFlowController(rootView: resolve()) as WalkthroughSettingsFlowControllerService } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /App/Sources/Resolver/Views/Resolver+WalkthroughView.swift: -------------------------------------------------------------------------------- 1 | import Resolver 2 | 3 | import UIServices 4 | import WalkthroughView 5 | 6 | extension Resolver { 7 | static func registerWalkthroughView() { 8 | register { WalkthroughViewModel(appStateRepository: resolve(), preferencesRepository: resolve()) } 9 | register { 10 | WalkthroughFlowController( 11 | viewModel: resolve(), 12 | walkthroughIntroProvider: resolve(), 13 | walkthroughSettingsProvider: resolve(), 14 | walkthroughFinishProvider: resolve() 15 | ) as WalkthroughFlowControllerService 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /App/Sources/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import Resolver 4 | 5 | import UIServices 6 | 7 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 8 | var window: UIWindow? 9 | 10 | func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { 11 | if let scene = scene as? UIWindowScene { 12 | let rootViewController = Resolver.resolve(RootFlowControllerService.self) 13 | 14 | let window = UIWindow(windowScene: scene) 15 | window.rootViewController = rootViewController 16 | window.makeKeyAndVisible() 17 | 18 | rootViewController.start() 19 | 20 | self.window = window 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Entities/Entities.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | Entities: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.entities 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | DEVELOPMENT_ASSET_PATHS: Entities/Sources/PreviewContents 11 | sources: 12 | - path: Sources 13 | group: Entities 14 | preBuildScripts: 15 | - name: Format and linting 16 | script: | 17 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Entities/Sources 18 | ${SRCROOT}/bin/swiftlint Entities/Sources -------------------------------------------------------------------------------- /Entities/Sources/Data/AppInformation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AppInformation: Hashable { 4 | public let name: String 5 | public let seller: String 6 | public let iconURL: URL 7 | public let storeURL: URL 8 | 9 | public init(name: String, seller: String, iconURL: URL, storeURL: URL) { 10 | self.name = name 11 | self.seller = seller 12 | self.iconURL = iconURL 13 | self.storeURL = storeURL 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Entities/Sources/Data/AppSegment.swift: -------------------------------------------------------------------------------- 1 | public enum AppSegment { 2 | case freeApp 3 | case paidApp 4 | 5 | public var title: String { 6 | switch self { 7 | case .freeApp: 8 | return "Free App" 9 | 10 | case .paidApp: 11 | return "Paid App" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Entities/Sources/Data/FeedLanguage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FeedLanguage: String { 4 | case english 5 | case japanese 6 | 7 | public var title: String { 8 | switch self { 9 | case .english: 10 | return "English" 11 | 12 | case .japanese: 13 | return "Japanese" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Entities/Sources/Data/Navigation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Navigation: Hashable { 4 | public enum AppSalesView: Hashable { 5 | case appStore(url: URL) 6 | } 7 | 8 | public enum FeedListView: Hashable { 9 | case news(url: URL) 10 | case appStore(url: URL) 11 | case freeAppRanking 12 | case paidAppRanking 13 | } 14 | 15 | public enum FullScreenModalView { 16 | case dismiss 17 | } 18 | 19 | public enum ModalTransitionTestView { 20 | case alert 21 | case fullScreen 22 | case pageSheet 23 | } 24 | 25 | public enum PageSheetModalView { 26 | case dismiss 27 | } 28 | 29 | public enum PushTransitionTestView { 30 | case next 31 | case popToRoot 32 | } 33 | 34 | public enum RootView { 35 | case main 36 | case walkthrough 37 | } 38 | 39 | public enum SettingsMenuView: Hashable { 40 | case menu(row: SettingsMenu.Row) 41 | } 42 | 43 | public enum WalkthroughFinishView { 44 | case finish 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Entities/Sources/Data/News.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct News: Hashable { 4 | public let title: String 5 | public let category: String 6 | public let date: Date 7 | public let imageURL: URL 8 | public let linkURL: URL 9 | 10 | public init( 11 | title: String, 12 | category: String, 13 | date: Date, 14 | imageURL: URL, 15 | linkURL: URL 16 | ) { 17 | self.title = title 18 | self.category = category 19 | self.date = date 20 | self.imageURL = imageURL 21 | self.linkURL = linkURL 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Entities/Sources/Data/SettingsMenu.swift: -------------------------------------------------------------------------------- 1 | public enum SettingsMenu { 2 | public enum Section: CaseIterable { 3 | case user 4 | case preferences 5 | case test 6 | case app 7 | 8 | public var title: String? { 9 | switch self { 10 | case .user: 11 | return nil 12 | 13 | case .preferences: 14 | return "Preferences" 15 | 16 | case .test: 17 | return "Test" 18 | 19 | case .app: 20 | return "App" 21 | } 22 | } 23 | 24 | public var rows: [Row] { 25 | switch self { 26 | case .user: 27 | return [.userName, .userNameSetting] 28 | 29 | case .preferences: 30 | return [.feedLanguageSetting] 31 | 32 | case .test: 33 | return [.keyboardTest, .toolbarTest, .pushTransitionTest, .modalTransitionTest] 34 | 35 | case .app: 36 | return [.licenses, .version] 37 | } 38 | } 39 | } 40 | 41 | public enum RowType { 42 | case user 43 | case transition 44 | case version 45 | } 46 | 47 | public enum Row { 48 | case userName 49 | case userNameSetting 50 | case feedLanguageSetting 51 | case keyboardTest 52 | case toolbarTest 53 | case pushTransitionTest 54 | case modalTransitionTest 55 | case licenses 56 | case version 57 | 58 | public var title: String? { 59 | switch self { 60 | case .userName: 61 | return nil 62 | 63 | case .userNameSetting: 64 | return "Edit" 65 | 66 | case .feedLanguageSetting: 67 | return "Feed language" 68 | 69 | case .keyboardTest: 70 | return "Keyboard" 71 | 72 | case .toolbarTest: 73 | return "Toolbar" 74 | 75 | case .pushTransitionTest: 76 | return "Push transition" 77 | 78 | case .modalTransitionTest: 79 | return "Modal transition" 80 | 81 | case .licenses: 82 | return "Licenses" 83 | 84 | case .version: 85 | return "Version" 86 | } 87 | } 88 | 89 | public var type: RowType { 90 | switch self { 91 | case .userName: 92 | return .user 93 | 94 | case .userNameSetting, 95 | .feedLanguageSetting, 96 | .keyboardTest, 97 | .toolbarTest, 98 | .pushTransitionTest, 99 | .modalTransitionTest, 100 | .licenses: 101 | return .transition 102 | 103 | case .version: 104 | return .version 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Entities/Sources/Errors/FeedClientError.swift: -------------------------------------------------------------------------------- 1 | public enum FeedClientError: Error { 2 | case connection 3 | case httpStatus 4 | case format 5 | } 6 | -------------------------------------------------------------------------------- /Entities/Sources/PreviewContents/AppInformation+PreviewContents.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension AppInformation { // swiftlint:disable:this extension_access_modifier 4 | // swiftlint:disable force_unwrapping line_length 5 | public static let youtube = AppInformation( 6 | name: "YouTube: Watch, Listen, Stream", 7 | seller: "Google LLC", 8 | iconURL: URL( 9 | string: "https://is3-ssl.mzstatic.com/image/thumb/Purple116/v4/d0/8d/34/d08d342b-c581-bc37-e6d5-79ba90a8d0de/logo_youtube_color-0-0-1x_U007emarketing-0-0-0-6-0-0-sRGB-0-0-0-GLES2_U002c0-512MB-85-220-0-0.png/100x100bb.png" 10 | )!, 11 | storeURL: URL(string: "https://apps.apple.com/us/app/youtube-watch-listen-stream/id544007664?uo=2")! 12 | ) 13 | public static let facebook = AppInformation( 14 | name: "Facebook", 15 | seller: "Meta Platforms, Inc.", 16 | iconURL: URL( 17 | string: "https://is4-ssl.mzstatic.com/image/thumb/Purple116/v4/8b/f6/6f/8bf66f1e-9de5-583e-27b6-58f3180ccf6b/Icon-Production-0-0-1x_U007emarketing-0-0-0-7-0-0-sRGB-0-0-0-GLES2_U002c0-512MB-85-220-0-0.png/100x100bb.png" 18 | )!, 19 | storeURL: URL(string: "https://apps.apple.com/us/app/facebook/id284882215?uo=2")! 20 | ) 21 | public static let tiktok = AppInformation( 22 | name: "TikTok", 23 | seller: "TikTok Ltd.", 24 | iconURL: URL( 25 | string: "https://is5-ssl.mzstatic.com/image/thumb/Purple126/v4/3f/68/f2/3f68f20b-fd7d-f518-0fd0-4d20a6e1c8d2/AppIcon_TikTok-0-0-1x_U007emarketing-0-0-0-7-0-0-sRGB-0-0-0-GLES2_U002c0-512MB-85-220-0-0.png/100x100bb.png" 26 | )!, 27 | storeURL: URL(string: "https://apps.apple.com/us/app/tiktok/id835599320?uo=2")! 28 | ) 29 | public static let snapchat = AppInformation( 30 | name: "Snapchat", 31 | seller: "Snap, Inc.", 32 | iconURL: URL( 33 | string: "https://is2-ssl.mzstatic.com/image/thumb/Purple116/v4/a8/94/16/a8941680-17cb-e9ff-df64-2b3341608cf4/AppIcon-0-0-1x_U007emarketing-0-0-0-5-0-0-sRGB-0-0-0-GLES2_U002c0-512MB-85-220-0-0.png/100x100bb.png" 34 | )!, 35 | storeURL: URL(string: "https://apps.apple.com/us/app/snapchat/id447188370?uo=2")! 36 | ) 37 | public static let zoom = AppInformation( 38 | name: "ZOOM Cloud Meetings", 39 | seller: "Zoom Video Communications, Inc.", 40 | iconURL: URL( 41 | string: "https://is5-ssl.mzstatic.com/image/thumb/Purple126/v4/e2/2d/57/e22d57ae-015e-1247-9d19-d421effdad91/AppIcon-0-1x_U007emarketing-0-9-0-85-220.png/100x100bb.png" 42 | )!, 43 | storeURL: URL(string: "https://apps.apple.com/us/app/zoom-cloud-meetings/id546505307?uo=2")! 44 | ) 45 | // swiftlint:enable force_unwrapping line_length 46 | 47 | public static let previewContentList: [AppInformation] = [ 48 | youtube, 49 | facebook, 50 | tiktok, 51 | snapchat, 52 | zoom, 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /Infrastructures/FeedClient/FeedClient.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FeedClient: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.feedclient 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Infrastructures/FeedClient 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Infrastructures/FeedClient/Sources 17 | ${SRCROOT}/bin/swiftlint Infrastructures/FeedClient/Sources 18 | dependencies: 19 | - target: InfrastructureServices 20 | - target: Entities 21 | - package: Kanna 22 | FeedClientTests: 23 | type: bundle.unit-test 24 | platform: iOS 25 | sources: 26 | - path: Tests 27 | group: Infrastructures/FeedClient 28 | preBuildScripts: 29 | - name: Format and linting 30 | script: | 31 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Infrastructures/FeedClient/Tests 32 | ${SRCROOT}/bin/swiftlint Infrastructures/FeedClient/Tests 33 | dependencies: 34 | - target: FeedClient 35 | - package: OHHTTPStubs 36 | product: OHHTTPStubs 37 | - package: OHHTTPStubs 38 | product: OHHTTPStubsSwift 39 | -------------------------------------------------------------------------------- /Infrastructures/FeedClient/Sources/Extensions/Date+Formatter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Date { 4 | init?(fromNewsFeedFormat string: String) { 5 | let formatter = ISO8601DateFormatter() 6 | formatter.formatOptions.insert(.withFractionalSeconds) 7 | 8 | guard let date = formatter.date(from: string) else { return nil } 9 | 10 | self = date 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Infrastructures/FeedClient/Sources/Extensions/URL+EndPoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import Entities 4 | 5 | extension URL { 6 | static func newsFeed(language: FeedLanguage) -> URL { 7 | switch language { 8 | case .english: 9 | // swiftlint:disable:next force_unwrapping 10 | return URL(string: "https://www.apple.com/newsroom/rss-feed.rss")! 11 | 12 | case .japanese: 13 | // swiftlint:disable:next force_unwrapping 14 | return URL(string: "https://www.apple.com/jp/newsroom/rss-feed.rss")! 15 | } 16 | } 17 | 18 | static func freeAppRankingFeed(language: FeedLanguage, limit: Int) -> URL { 19 | switch language { 20 | case .english: 21 | // swiftlint:disable:next force_unwrapping 22 | return URL(string: "https://itunes.apple.com/rss/topfreeapplications/limit=\(limit)/xml")! 23 | 24 | case .japanese: 25 | // swiftlint:disable:next force_unwrapping 26 | return URL(string: "https://itunes.apple.com/jp/rss/topfreeapplications/limit=\(limit)/xml")! 27 | } 28 | } 29 | 30 | static func paidAppRankingFeed(language: FeedLanguage, limit: Int) -> URL { 31 | switch language { 32 | case .english: 33 | // swiftlint:disable:next force_unwrapping 34 | return URL(string: "https://itunes.apple.com/rss/toppaidapplications/limit=\(limit)/xml")! 35 | 36 | case .japanese: 37 | // swiftlint:disable:next force_unwrapping 38 | return URL(string: "https://itunes.apple.com/jp/rss/toppaidapplications/limit=\(limit)/xml")! 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Infrastructures/FeedClient/Tests/Resources/stub_news_feed_1_en.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | <![CDATA[Apple Newsroom]]> 4 | 5 | 6 | 7 | 8 | 9 | 10 | <![CDATA[Apple services enrich peoples’ lives throughout the year]]> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Infrastructures/FeedClient/Tests/Resources/stub_news_feed_1_jp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | <![CDATA[Apple Newsroom]]> 4 | 5 | 6 | 7 | 8 | 9 | 10 | <![CDATA[Appleのサービス、年間を通して人々の暮らしを豊かに]]> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Infrastructures/InfrastructureServices/InfrastructureServices.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | InfrastructureServices: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.infrastructureservices 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Infrastructures/InfrastructureServices 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Infrastructures/InfrastructureServices/Sources 17 | ${SRCROOT}/bin/swiftlint Infrastructures/InfrastructureServices/Sources 18 | dependencies: 19 | - target: Entities 20 | -------------------------------------------------------------------------------- /Infrastructures/InfrastructureServices/Sources/FeedClientService.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol FeedClientService { 4 | func fetchNews(language: FeedLanguage) async throws -> [News] 5 | func fetchFreeAppRanking(language: FeedLanguage, limit: Int) async throws -> [AppInformation] 6 | func fetchPaidAppRanking(language: FeedLanguage, limit: Int) async throws -> [AppInformation] 7 | } 8 | -------------------------------------------------------------------------------- /Infrastructures/InfrastructureServices/Sources/UserDefaultsManagerService.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol UserDefaultsManagerService { 4 | func fetchFeedLanguage() async -> FeedLanguage? 5 | func fetchUserName() async -> String? 6 | func fetchIsWalkthroughFinished() async -> Bool 7 | func saveFeedLanguage(_ language: FeedLanguage) async 8 | func saveUserName(_ name: String) async 9 | func saveIsWalkthroughFinished(_ flag: Bool) async 10 | } 11 | -------------------------------------------------------------------------------- /Infrastructures/UserDefaultsManager/Sources/UserDefaultsKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum UserDefaultsKey: String { 4 | case isWalkthroughFinished 5 | case feedLanguage 6 | case userName 7 | } 8 | -------------------------------------------------------------------------------- /Infrastructures/UserDefaultsManager/Sources/UserDefaultsManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import Entities 4 | import InfrastructureServices 5 | 6 | public final class UserDefaultsManager: UserDefaultsManagerService { 7 | private let _userDefaults: UserDefaults 8 | 9 | public init(userDefaults: UserDefaults) { 10 | self._userDefaults = userDefaults 11 | } 12 | 13 | public func fetchFeedLanguage() async -> FeedLanguage? { 14 | await withCheckedContinuation { continuation in 15 | let rawValue = _userDefaults.value(forKey: UserDefaultsKey.feedLanguage.rawValue) as? String 16 | let language = rawValue.flatMap { FeedLanguage(rawValue: $0) } 17 | continuation.resume(returning: language) 18 | } 19 | } 20 | 21 | public func fetchUserName() async -> String? { 22 | await withCheckedContinuation { continuation in 23 | let value = _userDefaults.value(forKey: UserDefaultsKey.userName.rawValue) as? String 24 | continuation.resume(returning: value) 25 | } 26 | } 27 | 28 | public func fetchIsWalkthroughFinished() async -> Bool { 29 | await withCheckedContinuation { continuation in 30 | let value = _userDefaults.bool(forKey: UserDefaultsKey.isWalkthroughFinished.rawValue) 31 | continuation.resume(returning: value) 32 | } 33 | } 34 | 35 | public func saveFeedLanguage(_ language: FeedLanguage) async { 36 | await withCheckedContinuation { (continuation: CheckedContinuation) in 37 | _userDefaults.set(language.rawValue, forKey: UserDefaultsKey.feedLanguage.rawValue) 38 | continuation.resume() 39 | } 40 | } 41 | 42 | public func saveUserName(_ name: String) async { 43 | await withCheckedContinuation { (continuation: CheckedContinuation) in 44 | _userDefaults.set(name, forKey: UserDefaultsKey.userName.rawValue) 45 | continuation.resume() 46 | } 47 | } 48 | 49 | public func saveIsWalkthroughFinished(_ flag: Bool) async { 50 | await withCheckedContinuation { (continuation: CheckedContinuation) in 51 | _userDefaults.set(flag, forKey: UserDefaultsKey.isWalkthroughFinished.rawValue) 52 | continuation.resume() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Infrastructures/UserDefaultsManager/UserDefaultsManager.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | UserDefaultsManager: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.userdefaultsmanager 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Infrastructures/UserDefaultsManager 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Infrastructures/UserDefaultsManager/Sources 17 | ${SRCROOT}/bin/swiftlint Infrastructures/UserDefaultsManager/Sources 18 | dependencies: 19 | - target: InfrastructureServices 20 | - target: Entities 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hugehoge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = bash 2 | 3 | MAKEFILE_DIR := $(dir $(realpath $(firstword ${MAKEFILE_LIST}))) 4 | BIN_DIR := ${MAKEFILE_DIR}/bin 5 | LIB_DIR := ${MAKEFILE_DIR}/lib 6 | 7 | .PHONY: open \ 8 | bootstrap \ 9 | xcodeproj \ 10 | lint \ 11 | format \ 12 | local-install-xcodegen \ 13 | local-install-swiftgen \ 14 | local-install-swiftlint \ 15 | local-install-swiftformat \ 16 | local-install-licenseplist 17 | 18 | open: 19 | @open swiftui-flowcontroller-pattern.xcodeproj 20 | 21 | bootstrap: 22 | @echo "*** Install build tools ***" 23 | @$(MAKE) local-install-xcodegen 24 | @$(MAKE) local-install-swiftgen 25 | @$(MAKE) local-install-swiftlint 26 | @$(MAKE) local-install-swiftformat 27 | @${MAKE} local-install-licenseplist 28 | @echo "" 29 | @echo "*** Generate Xcode project ***" 30 | @$(MAKE) xcodeproj 31 | @echo "" 32 | @echo "Complete bootstrap." 33 | 34 | xcodeproj: local-install-xcodegen 35 | @echo -n "Xcode project generating..." 36 | @${XCODEGEN_BIN_PATH} -q 37 | @echo "OK!" 38 | 39 | lint: local-install-swiftlint 40 | @echo "Linting..." 41 | @${BIN_DIR}/swiftlint --quiet 42 | 43 | format: local-install-swiftformat 44 | @echo "Formatting..." 45 | @${BIN_DIR}/swiftformat --swiftversion 5.5 App Infrastructures Repositories 46 | 47 | local-install-xcodegen: 48 | @echo -n "XcodeGen..." 49 | # It won't work properly with symbolic link. 50 | # see https://github.com/yonaskolb/XcodeGen/issues/247, https://github.com/yonaskolb/XcodeGen/pull/248 51 | $(eval XCODEGEN_BIN_PATH := $(shell ${MAKEFILE_DIR}/scripts/local-install-xcodegen.sh -s "${LIB_DIR}/xcodegen")) 52 | @echo "OK!" 53 | 54 | local-install-swiftgen: 55 | @echo -n "SwiftGen..." 56 | @SWIFTGEN_PATH=$$(${MAKEFILE_DIR}/scripts/local-install-swiftgen.sh -s "${LIB_DIR}/swiftgen"); \ 57 | ln -sf "$${SWIFTGEN_PATH}" "${BIN_DIR}/swiftgen" 58 | @echo "OK!" 59 | 60 | local-install-swiftlint: 61 | @echo -n "SwiftLint..." 62 | @SWIFTLINT_PATH=$$(${MAKEFILE_DIR}/scripts/local-install-swiftlint.sh -s "${LIB_DIR}/swiftlint"); \ 63 | ln -sf "$${SWIFTLINT_PATH}" "${BIN_DIR}/swiftlint" 64 | @echo "OK!" 65 | 66 | local-install-swiftformat: 67 | @echo -n "SwiftFormat..." 68 | @SWIFTFORMAT_PATH=$$(${MAKEFILE_DIR}/scripts/local-install-swiftformat.sh -s "${LIB_DIR}/swiftformat"); \ 69 | ln -sf "$${SWIFTFORMAT_PATH}" "${BIN_DIR}/swiftformat" 70 | @echo "OK!" 71 | 72 | local-install-licenseplist: 73 | @echo -n "LicensePlist..." 74 | @LICENSEPLIST_PATH=$$(${MAKEFILE_DIR}/scripts/local-install-licenseplist.sh -s "${LIB_DIR}/licenseplist"); \ 75 | ln -sf "$${LICENSEPLIST_PATH}" "${BIN_DIR}/license-plist" 76 | @echo "OK!" 77 | -------------------------------------------------------------------------------- /MockingbirdSupport/AppKit/NSResponder.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | 4 | open class NSResponder : NSObject, NSCoding { 5 | public init() 6 | public required init?(coder: NSCoder) 7 | } 8 | -------------------------------------------------------------------------------- /MockingbirdSupport/AppKit/NSView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | 4 | open class NSView : NSResponder { 5 | public init(frame frameRect: NSRect) 6 | public required init?(coder: NSCoder) 7 | } 8 | -------------------------------------------------------------------------------- /MockingbirdSupport/AppKit/NSViewController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | 4 | @available(OSX 10.5, *) 5 | open class NSViewController : NSResponder { 6 | public init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) 7 | public required init?(coder: NSCoder) 8 | } 9 | -------------------------------------------------------------------------------- /MockingbirdSupport/Foundation/ObjectiveC/NSObject.swift: -------------------------------------------------------------------------------- 1 | import ObjectiveC 2 | 3 | public protocol NSObjectProtocol {} 4 | 5 | open class NSObject : NSObjectProtocol { 6 | public init() {} 7 | } 8 | 9 | public protocol NSCopying { 10 | func copy(with zone: NSZone?) -> Any 11 | } 12 | 13 | public protocol NSMutableCopying { 14 | func mutableCopy(with zone: NSZone?) -> Any 15 | } 16 | 17 | public protocol NSCoding { 18 | func encode(with coder: NSCoder) 19 | init?(coder: NSCoder) 20 | } 21 | -------------------------------------------------------------------------------- /MockingbirdSupport/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bird Rides, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MockingbirdSupport/Swift/Codable.swift: -------------------------------------------------------------------------------- 1 | public protocol Encodable { 2 | func encode(to encoder: Encoder) throws 3 | } 4 | 5 | public protocol Decodable { 6 | @objc(mkb_implicit) init(from decoder: Decoder) throws 7 | } 8 | 9 | public typealias Codable = Decodable & Encodable 10 | -------------------------------------------------------------------------------- /MockingbirdSupport/Swift/Comparable.swift: -------------------------------------------------------------------------------- 1 | public protocol Comparable : Equatable { 2 | static func < (lhs: Self, rhs: Self) -> Bool 3 | static func <= (lhs: Self, rhs: Self) -> Bool 4 | static func >= (lhs: Self, rhs: Self) -> Bool 5 | static func > (lhs: Self, rhs: Self) -> Bool 6 | } 7 | -------------------------------------------------------------------------------- /MockingbirdSupport/Swift/Equatable.swift: -------------------------------------------------------------------------------- 1 | public protocol Equatable { 2 | static func == (lhs: Self, rhs: Self) -> Bool 3 | } 4 | -------------------------------------------------------------------------------- /MockingbirdSupport/Swift/Hashable.swift: -------------------------------------------------------------------------------- 1 | public protocol Hashable : Equatable { 2 | var hashValue: Int { get } 3 | func hash(into hasher: inout Hasher) 4 | } 5 | -------------------------------------------------------------------------------- /MockingbirdSupport/Swift/Misc.swift: -------------------------------------------------------------------------------- 1 | public protocol AnyObject {} 2 | 3 | public typealias `class` = AnyObject 4 | 5 | public typealias AnyClass = AnyObject.Type 6 | 7 | public protocol CustomStringConvertible { 8 | var description: String { get } 9 | } 10 | 11 | public protocol CustomDebugStringConvertible { 12 | var debugDescription: String { get } 13 | } 14 | -------------------------------------------------------------------------------- /MockingbirdSupport/UIKit/UIControl.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOS 2.0, *) 5 | open class UIControl : UIView {} 6 | -------------------------------------------------------------------------------- /MockingbirdSupport/UIKit/UILabel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOS 2.0, *) 5 | open class UILabel : UIView, NSCoding {} 6 | -------------------------------------------------------------------------------- /MockingbirdSupport/UIKit/UIResponder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOS 2.0, *) 5 | open class UIResponder : NSObject, NSCoding { 6 | public init() 7 | public required init?(coder: NSCoder) 8 | } 9 | -------------------------------------------------------------------------------- /MockingbirdSupport/UIKit/UIView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOS 2.0, *) 5 | open class UIView : NSCoding { 6 | public init(frame: CGRect) 7 | public required init?(coder: NSCoder) 8 | } 9 | -------------------------------------------------------------------------------- /MockingbirdSupport/UIKit/UIViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @available(iOS 2.0, *) 5 | open class UIViewController : UIResponder, NSCoding { 6 | public init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) 7 | public required init?(coder: NSCoder) 8 | } 9 | -------------------------------------------------------------------------------- /Repositories/AppStateRepository/AppStateRepository.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | AppStateRepository: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.appstaterepository 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Repositories/AppStateRepository 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Repositories/AppStateRepository/Sources 17 | ${SRCROOT}/bin/swiftlint Repositories/AppStateRepository/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: InfrastructureServices 21 | - target: Entities 22 | AppStateRepositoryTests: 23 | type: bundle.unit-test 24 | platform: iOS 25 | sources: 26 | - path: Tests 27 | group: Repositories/AppStateRepository 28 | excludes: 29 | - "**/.gitkeep" 30 | - path: Tests/Generated/InfrastructureServicesMocks.generated.swift 31 | group: Repositories/AppStateRepository/Tests/Generated 32 | optional: true 33 | preBuildScripts: 34 | - name: Mockingbird 35 | script: | 36 | DERIVED_DATA="$(xcodebuild -showBuildSettings | sed -n 's|.*BUILD_ROOT = \(.*\)/Build/.*|\1|p')" 37 | "${DERIVED_DATA}/SourcePackages/checkouts/mockingbird/mockingbird" generate \ 38 | --targets InfrastructureServices \ 39 | --testbundle AppStateRepositoryTests \ 40 | --output-dir Repositories/AppStateRepository/Tests/Generated \ 41 | --only-protocols \ 42 | --disable-swiftlint 43 | outputFiles: 44 | - ${SRCROOT}/Repositories/AppStateRepository/Tests/Generated/InfrastructureServicesMocks.generated.swift 45 | basedOnDependencyAnalysis: no 46 | - name: Format and linting 47 | script: | 48 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Repositories/AppStateRepository/Tests 49 | ${SRCROOT}/bin/swiftlint Repositories/AppStateRepository/Tests 50 | dependencies: 51 | - target: AppStateRepository 52 | - package: CombineExpectations 53 | - package: Mockingbird 54 | -------------------------------------------------------------------------------- /Repositories/AppStateRepository/Sources/AppStateRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | import InfrastructureServices 5 | import RepositoryServices 6 | 7 | public final class AppStateRepository: AppStateRepositoryService { 8 | private let _userDefaultsManager: UserDefaultsManagerService 9 | 10 | // swiftlint:disable:next discouraged_optional_boolean 11 | private let _isWalkthroughFinishedSubject = CurrentValueSubject(nil) 12 | 13 | public init(userDefaultsManager: UserDefaultsManagerService) { 14 | self._userDefaultsManager = userDefaultsManager 15 | 16 | Task { 17 | let isWalkthroughFinished = await userDefaultsManager.fetchIsWalkthroughFinished() 18 | _isWalkthroughFinishedSubject.send(isWalkthroughFinished) 19 | } 20 | } 21 | 22 | public func isWalkthroughFinishedStream() -> AnyPublisher { 23 | _isWalkthroughFinishedSubject 24 | .compactMap { $0 } 25 | .removeDuplicates() 26 | .eraseToAnyPublisher() 27 | } 28 | 29 | public func finishWalkthrough() async { 30 | await _userDefaultsManager.saveIsWalkthroughFinished(true) 31 | _isWalkthroughFinishedSubject.send(true) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Repositories/AppStateRepository/Tests/AppStateRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import CombineExpectations 4 | import Mockingbird 5 | 6 | @testable import AppStateRepository 7 | @testable import InfrastructureServices 8 | 9 | final class AppStateRepositoryTests: XCTestCase { 10 | override func setUpWithError() throws { 11 | try super.setUpWithError() 12 | } 13 | 14 | override func tearDownWithError() throws { 15 | try super.tearDownWithError() 16 | } 17 | 18 | func test_isWalkthroughFinishedStream_PersistedAsFalse_InitialOutputIsFalse() async throws { 19 | // Arrange 20 | let mock = mock(UserDefaultsManagerService.self) 21 | given(await mock.fetchIsWalkthroughFinished()).willReturn(false) 22 | 23 | // Act 24 | let repository = AppStateRepository(userDefaultsManager: mock) 25 | let recorder = repository.isWalkthroughFinishedStream().record() 26 | 27 | let first = try wait(for: recorder.next(), timeout: 0.1) 28 | 29 | // Assert 30 | XCTAssertFalse(first) 31 | } 32 | 33 | func test_isWalkthroughFinishedStream_PersistedAsTrue_InitialOutputIsTrue() async throws { 34 | // Arrange 35 | let mock = mock(UserDefaultsManagerService.self) 36 | given(await mock.fetchIsWalkthroughFinished()).willReturn(true) 37 | 38 | // Act 39 | let repository = AppStateRepository(userDefaultsManager: mock) 40 | let recorder = repository.isWalkthroughFinishedStream().record() 41 | 42 | let first = try wait(for: recorder.next(), timeout: 0.1) 43 | 44 | // Assert 45 | XCTAssertTrue(first) 46 | } 47 | 48 | func test_finishWalkthrough_PersistedAsFalse_PersistTrueAndOutputTrue() async throws { 49 | // Arrange 50 | let mock = mock(UserDefaultsManagerService.self) 51 | given(await mock.fetchIsWalkthroughFinished()).willReturn(false) 52 | 53 | // Act 54 | let repository = AppStateRepository(userDefaultsManager: mock) 55 | let recorder = repository.isWalkthroughFinishedStream().record() 56 | 57 | await repository.finishWalkthrough() 58 | 59 | let output = try wait(for: recorder.availableElements, timeout: 0.1) 60 | 61 | // Assert 62 | XCTAssertEqual([false, true], output) 63 | verify(await mock.saveIsWalkthroughFinished(true)).wasCalled() 64 | } 65 | 66 | func test_finishWalkthrough_PersistedAsTrue_PersistTrueAndOutputTrueOnce() async throws { 67 | // Arrange 68 | let mock = mock(UserDefaultsManagerService.self) 69 | given(await mock.fetchIsWalkthroughFinished()).willReturn(true) 70 | 71 | // Act 72 | let repository = AppStateRepository(userDefaultsManager: mock) 73 | let recorder = repository.isWalkthroughFinishedStream().record() 74 | 75 | await repository.finishWalkthrough() 76 | 77 | let output = try wait(for: recorder.availableElements, timeout: 0.1) 78 | 79 | // Assert 80 | XCTAssertEqual([true], output) 81 | verify(await mock.saveIsWalkthroughFinished(true)).wasCalled() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Repositories/AppStateRepository/Tests/Generated/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/Repositories/AppStateRepository/Tests/Generated/.gitkeep -------------------------------------------------------------------------------- /Repositories/FeedRepository/FeedRepository.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FeedRepository: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.feedrepository 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Repositories/FeedRepository 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Repositories/FeedRepository/Sources 17 | ${SRCROOT}/bin/swiftlint Repositories/FeedRepository/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: InfrastructureServices 21 | - target: Entities 22 | -------------------------------------------------------------------------------- /Repositories/FeedRepository/Sources/FeedRepository.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | import InfrastructureServices 3 | import RepositoryServices 4 | 5 | public final class FeedRepository: FeedRepositoryService { 6 | private let _feedClient: FeedClientService 7 | 8 | public init(feedClient: FeedClientService) { 9 | self._feedClient = feedClient 10 | } 11 | 12 | public func fetchNews(language: FeedLanguage) async throws -> [News] { 13 | try await _feedClient.fetchNews(language: language) 14 | } 15 | 16 | public func fetchFreeAppTop15Ranking(language: FeedLanguage) async throws -> [AppInformation] { 17 | try await _feedClient.fetchFreeAppRanking(language: language, limit: 15) 18 | } 19 | 20 | public func fetchPaidAppTop15Ranking(language: FeedLanguage) async throws -> [AppInformation] { 21 | try await _feedClient.fetchPaidAppRanking(language: language, limit: 15) 22 | } 23 | 24 | public func fetchFreeAppAllRanking(language: FeedLanguage) async throws -> [AppInformation] { 25 | try await _feedClient.fetchFreeAppRanking(language: language, limit: 50) 26 | } 27 | 28 | public func fetchPaidAppAllRanking(language: FeedLanguage) async throws -> [AppInformation] { 29 | try await _feedClient.fetchPaidAppRanking(language: language, limit: 50) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Repositories/PreferencesRepository/PreferencesRepository.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | PreferencesRepository: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.preferencesrepository 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Repositories/PreferencesRepository 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Repositories/PreferencesRepository/Sources 17 | ${SRCROOT}/bin/swiftlint Repositories/PreferencesRepository/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: InfrastructureServices 21 | - target: Entities 22 | -------------------------------------------------------------------------------- /Repositories/PreferencesRepository/Sources/PreferencesRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | import Entities 5 | import InfrastructureServices 6 | import RepositoryServices 7 | 8 | public final class PreferencesRepository: PreferencesRepositoryService { 9 | private let _userDefaultsManager: UserDefaultsManagerService 10 | 11 | private let _feedLanguageSubject = CurrentValueSubject(nil) 12 | private let _userNameSubject = CurrentValueSubject(nil) 13 | 14 | public init(userDefaultsManager: UserDefaultsManagerService) { 15 | self._userDefaultsManager = userDefaultsManager 16 | 17 | Task { 18 | _feedLanguageSubject.send(await userDefaultsManager.fetchFeedLanguage()) 19 | } 20 | Task { 21 | _userNameSubject.send(await userDefaultsManager.fetchUserName()) 22 | } 23 | } 24 | 25 | public func feedLanguageStream() -> AnyPublisher { 26 | _feedLanguageSubject.compactMap { $0 } 27 | .removeDuplicates() 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | public func userNameStream() -> AnyPublisher { 32 | _userNameSubject.compactMap { $0 } 33 | .removeDuplicates() 34 | .eraseToAnyPublisher() 35 | } 36 | 37 | public func fetchFeedLanguage() async -> FeedLanguage? { 38 | await withCheckedContinuation { continuation in 39 | continuation.resume(returning: _feedLanguageSubject.value) 40 | } 41 | } 42 | 43 | public func fetchUserName() async -> String? { 44 | await withCheckedContinuation { continuation in 45 | continuation.resume(returning: _userNameSubject.value) 46 | } 47 | } 48 | 49 | public func saveFeedLanguage(_ language: FeedLanguage) async { 50 | await _userDefaultsManager.saveFeedLanguage(language) 51 | _feedLanguageSubject.send(language) 52 | } 53 | 54 | public func saveUserName(_ name: String) async { 55 | await _userDefaultsManager.saveUserName(name) 56 | _userNameSubject.send(name) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/RepositoryServices.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | RepositoryServices: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.repositoryservices 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | DEVELOPMENT_ASSET_PATHS: Repositories/RepositoryServices/Sources/PreviewContents 11 | sources: 12 | - path: Sources 13 | group: Repositories/RepositoryServices 14 | preBuildScripts: 15 | - name: Format and linting 16 | script: | 17 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 Repositories/RepositoryServices/Sources 18 | ${SRCROOT}/bin/swiftlint Repositories/RepositoryServices/Sources 19 | dependencies: 20 | - target: Entities 21 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/Sources/AppStateRepositoryService.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public protocol AppStateRepositoryService { 4 | func isWalkthroughFinishedStream() -> AnyPublisher 5 | func finishWalkthrough() async 6 | } 7 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/Sources/FeedRepositoryService.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol FeedRepositoryService { 4 | func fetchNews(language: FeedLanguage) async throws -> [News] 5 | func fetchFreeAppTop15Ranking(language: FeedLanguage) async throws -> [AppInformation] 6 | func fetchPaidAppTop15Ranking(language: FeedLanguage) async throws -> [AppInformation] 7 | func fetchFreeAppAllRanking(language: FeedLanguage) async throws -> [AppInformation] 8 | func fetchPaidAppAllRanking(language: FeedLanguage) async throws -> [AppInformation] 9 | } 10 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/Sources/PreferencesRepositoryService.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | 5 | public protocol PreferencesRepositoryService { 6 | func feedLanguageStream() -> AnyPublisher 7 | func userNameStream() -> AnyPublisher 8 | func fetchFeedLanguage() async -> FeedLanguage? 9 | func fetchUserName() async -> String? 10 | func saveFeedLanguage(_ language: FeedLanguage) async 11 | func saveUserName(_ name: String) async 12 | } 13 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/Sources/PreviewContents/PreviewFeedRepository.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public final class PreviewFeedRepository: FeedRepositoryService { 4 | public init() { } 5 | 6 | public func fetchNews(language _: FeedLanguage) async throws -> [News] { 7 | News.previewContentList 8 | } 9 | 10 | public func fetchFreeAppTop15Ranking(language _: FeedLanguage) async throws -> [AppInformation] { 11 | AppInformation.previewContentList 12 | } 13 | 14 | public func fetchPaidAppTop15Ranking(language _: FeedLanguage) async throws -> [AppInformation] { 15 | AppInformation.previewContentList 16 | } 17 | 18 | public func fetchFreeAppAllRanking(language _: FeedLanguage) async throws -> [AppInformation] { 19 | AppInformation.previewContentList 20 | } 21 | 22 | public func fetchPaidAppAllRanking(language _: FeedLanguage) async throws -> [AppInformation] { 23 | AppInformation.previewContentList 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Repositories/RepositoryServices/Sources/PreviewContents/PreviewPreferencesRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | 5 | public final class PreviewPreferencesRepository: PreferencesRepositoryService { 6 | public init() { } 7 | 8 | public func feedLanguageStream() -> AnyPublisher { 9 | Just(.english).eraseToAnyPublisher() 10 | } 11 | 12 | public func userNameStream() -> AnyPublisher { 13 | Just("John Doe").eraseToAnyPublisher() 14 | } 15 | 16 | public func fetchFeedLanguage() async -> FeedLanguage? { 17 | .english 18 | } 19 | 20 | public func fetchUserName() async -> String? { 21 | "John Doe" 22 | } 23 | 24 | public func saveFeedLanguage(_ language: FeedLanguage) async { 25 | print("Call PreferencesRepository.saveFeedLanguage(\(language)") 26 | } 27 | 28 | public func saveUserName(_ name: String) async { 29 | print("Call PreferencesRepository.saveUserName(\(name))") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Resources/Resources.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | Resources: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.resources 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: Resources 13 | - path: Sources/Generated/Assets+Generated.swift 14 | group: Resources/Sources/Generated 15 | optional: true 16 | - path: Sources/Generated/Files+Generated.swift 17 | group: Resources/Sources/Generated 18 | optional: true 19 | preBuildScripts: 20 | - name: SwiftGen 21 | script: | 22 | if [ $ACTION != "indexbuild" ]; then 23 | ${SRCROOT}/bin/swiftgen config run --config Resources/Resources.swiftgen.yml 24 | fi 25 | outputFiles: 26 | - ${SRCROOT}/Resources/Sources/Generated/Assets+Generated.swift 27 | - ${SRCROOT}/Resources/Sources/Generated/Files+Generated.swift 28 | basedOnDependencyAnalysis: no 29 | - name: LicensePlist 30 | script: | 31 | ${SRCROOT}/bin/license-plist \ 32 | --suppress-opening-directory \ 33 | --fail-if-missing-license \ 34 | --output-path "${SRCROOT}/Resources/Sources/Settings.bundle" \ 35 | --markdown-path "${SRCROOT}/Resources/Sources/Licenses.md" 36 | -------------------------------------------------------------------------------- /Resources/Resources.swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Sources 2 | output_dir: Sources/Generated 3 | 4 | files: 5 | inputs: 6 | - Licenses.md 7 | outputs: 8 | - templateName: structured-swift5 9 | output: Files+Generated.swift 10 | params: 11 | publicAccess: true 12 | 13 | xcassets: 14 | inputs: 15 | - Assets.xcassets 16 | outputs: 17 | - templateName: swift5 18 | output: Assets+Generated.swift 19 | params: 20 | publicAccess: true 21 | -------------------------------------------------------------------------------- /Resources/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Resources/Sources/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Resources/Sources/Assets.xcassets/Colors/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF7", 9 | "green" : "0xF2", 10 | "red" : "0xF2" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x00", 27 | "green" : "0x00", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Resources/Sources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Resources/Sources/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | StringsTable 6 | Root 7 | PreferenceSpecifiers 8 | 9 | 10 | Type 11 | PSChildPaneSpecifier 12 | Title 13 | Licenses 14 | File 15 | com.mono0926.LicensePlist 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Resources/Sources/Settings.bundle/en.lproj/Root.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/Resources/Sources/Settings.bundle/en.lproj/Root.strings -------------------------------------------------------------------------------- /UI/UIComponents/Sources/AppIcon.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Kingfisher 4 | 5 | import Entities 6 | 7 | // MARK: - AppIcon 8 | 9 | public struct AppIcon: View { 10 | private let _url: URL 11 | 12 | public var body: some View { 13 | KFImage(_url) 14 | .cacheMemoryOnly() 15 | .placeholder { 16 | Color.gray 17 | } 18 | .resizable() 19 | .frame(width: 72, height: 72) 20 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) 21 | .fixedSize() 22 | } 23 | 24 | public init(url: URL) { 25 | self._url = url 26 | } 27 | } 28 | 29 | // MARK: - AppIcon_Previews 30 | 31 | struct AppIcon_Previews: PreviewProvider { 32 | static var previews: some View { 33 | Group { 34 | ForEach(AppInformation.previewContentList, id: \.self) { content in 35 | AppIcon(url: content.iconURL) 36 | .previewLayout(.fixed(width: 200, height: 100)) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /UI/UIComponents/Sources/AppRankingButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Entities 4 | 5 | // MARK: - _AppRankingButtonStyle 6 | 7 | private struct _AppRankingButtonStyle: ButtonStyle { 8 | func makeBody(configuration: Configuration) -> some View { 9 | configuration.label 10 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 11 | .animation(.easeInOut, value: configuration.isPressed ? 0.95 : 1.0) 12 | } 13 | } 14 | 15 | // MARK: - AppRankingButton 16 | 17 | public struct AppRankingButton: View { 18 | private let _app: AppInformation 19 | private let _rank: Int 20 | private let _action: (() -> Void)? 21 | 22 | public var body: some View { 23 | Button { 24 | _action?() 25 | } label: { 26 | _buttonLabel 27 | } 28 | .buttonStyle(_AppRankingButtonStyle()) 29 | } 30 | 31 | private var _buttonLabel: some View { 32 | HStack { 33 | AppIcon(url: _app.iconURL) 34 | 35 | HStack(alignment: .firstTextBaseline) { 36 | Text("\(_rank)") 37 | .font(.headline) 38 | .foregroundColor(.primary) 39 | 40 | VStack(alignment: .leading) { 41 | Text(_app.name) 42 | .font(.headline) 43 | .foregroundColor(.primary) 44 | .lineLimit(2) 45 | Text(_app.seller) 46 | .font(.body) 47 | .foregroundColor(.secondary) 48 | .lineLimit(1) 49 | } 50 | } 51 | 52 | Spacer(minLength: 0) 53 | } 54 | .contentShape(Rectangle()) // For tap area 55 | } 56 | 57 | public init( 58 | _ app: AppInformation, 59 | rank: Int, 60 | action: (() -> Void)? = nil 61 | ) { 62 | self._app = app 63 | self._rank = rank 64 | self._action = action 65 | } 66 | } 67 | 68 | // MARK: - AppRankingButton_Previews 69 | 70 | struct AppRankingButton_Previews: PreviewProvider { 71 | static var previews: some View { 72 | Group { 73 | ForEach(0.. Void)? 13 | 14 | public var body: some View { 15 | VStack(alignment: .leading) { 16 | AppRankingButton( 17 | _firstApp, 18 | rank: _rankOffset + 0 19 | ) { 20 | _openStoreAction?(_firstApp.storeURL) 21 | } 22 | if let secondApp = _secondApp { 23 | Divider().padding(.leading, 72) 24 | AppRankingButton( 25 | secondApp, 26 | rank: _rankOffset + 1 27 | ) { 28 | _openStoreAction?(secondApp.storeURL) 29 | } 30 | } 31 | if let thirdApp = _thirdApp { 32 | Divider().padding(.leading, 72) 33 | AppRankingButton( 34 | thirdApp, 35 | rank: _rankOffset + 2 36 | ) { 37 | _openStoreAction?(thirdApp.storeURL) 38 | } 39 | } 40 | Spacer(minLength: 0) 41 | } 42 | } 43 | 44 | public init( 45 | rankOffset: Int, 46 | firstApp: AppInformation, 47 | secondApp: AppInformation? = nil, 48 | thirdApp: AppInformation? = nil, 49 | openStoreAction: ((URL) -> Void)? = nil 50 | ) { 51 | self._rankOffset = rankOffset 52 | self._firstApp = firstApp 53 | self._secondApp = secondApp 54 | self._thirdApp = thirdApp 55 | self._openStoreAction = openStoreAction 56 | } 57 | } 58 | 59 | // MARK: - AppRankingStack_Previews 60 | 61 | struct AppRankingStack_Previews: PreviewProvider { 62 | static var previews: some View { 63 | AppRankingStack( 64 | rankOffset: 1, 65 | firstApp: AppInformation.previewContentList[0], 66 | secondApp: AppInformation.previewContentList[1], 67 | thirdApp: AppInformation.previewContentList[2] 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /UI/UIComponents/Sources/BulletPointText.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - BulletPointText 4 | 5 | public struct BulletPointText: View { 6 | private let _text: String 7 | private let _bulletPointMark: String 8 | 9 | public var body: some View { 10 | HStack(alignment: .top) { 11 | Text(_bulletPointMark) 12 | .padding(.trailing, 4) 13 | 14 | Text(_text) 15 | .multilineTextAlignment(.leading) 16 | } 17 | } 18 | 19 | public init(_ text: String, bulletPointMark: String = "-") { 20 | self._bulletPointMark = bulletPointMark 21 | self._text = text 22 | } 23 | } 24 | 25 | // MARK: - BulletPointText_Previews 26 | 27 | struct BulletPointText_Previews: PreviewProvider { 28 | static var previews: some View { 29 | Group { 30 | BulletPointText("-Test Text-") 31 | .padding() 32 | .previewLayout(.fixed(width: 400, height: 50)) 33 | .previewDisplayName("Default") 34 | 35 | BulletPointText("*Test Text*", bulletPointMark: "*") 36 | .padding() 37 | .previewLayout(.fixed(width: 400, height: 50)) 38 | .previewDisplayName("Other bullet point mark") 39 | 40 | VStack(alignment: .leading) { 41 | BulletPointText("Test Text") 42 | BulletPointText("") 43 | BulletPointText("Long long long long test text") 44 | BulletPointText("A") 45 | } 46 | .padding() 47 | .previewLayout(.fixed(width: 400, height: 100)) 48 | .previewDisplayName("Multi length") 49 | 50 | BulletPointText( 51 | // swiftlint:disable line_length 52 | """ 53 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 54 | """ 55 | // swiftlint:enable line_length 56 | ) 57 | .padding() 58 | .previewLayout(.fixed(width: 400, height: 100)) 59 | .previewDisplayName("Line break") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /UI/UIComponents/Sources/NavigationLinkButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - NavigationLinkButton 4 | 5 | /// A Button in NavigationView simulating NavigationLink 6 | /// 7 | /// - seealso: https://ideal-reality.com/programing/swiftui-list-navlink-design/ 8 | public struct NavigationLinkButton: View { 9 | private let _title: String? 10 | private let _action: () -> Void 11 | 12 | public var body: some View { 13 | Button(action: _action) { 14 | HStack { 15 | if let title = _title { 16 | Text(title) 17 | .foregroundColor(.primary) 18 | } 19 | Spacer() 20 | Image(systemName: "chevron.right") 21 | .font(Font.system(size: 14, weight: .semibold)) 22 | .foregroundColor(.secondary) 23 | .opacity(0.5) 24 | } 25 | } 26 | } 27 | 28 | public init(_ title: String?, action: @escaping () -> Void) { 29 | self._title = title 30 | self._action = action 31 | } 32 | } 33 | 34 | // MARK: - NavigationLinkButton_Previews 35 | 36 | struct NavigationLinkButton_Previews: PreviewProvider { 37 | static var previews: some View { 38 | Group { 39 | NavigationView { 40 | List { 41 | NavigationLink("Original", destination: EmptyView()) 42 | NavigationLinkButton("NavigationLinkButton") { } 43 | } 44 | .listStyle(.plain) 45 | } 46 | .preferredColorScheme(.light) 47 | .previewLayout(.fixed(width: 300, height: 200)) 48 | .previewDisplayName("Light plain") 49 | 50 | NavigationView { 51 | List { 52 | NavigationLink("Original", destination: EmptyView()) 53 | NavigationLinkButton("NavigationLinkButton") { } 54 | } 55 | .listStyle(.insetGrouped) 56 | } 57 | .preferredColorScheme(.light) 58 | .previewLayout(.fixed(width: 300, height: 200)) 59 | .previewDisplayName("Light inset group") 60 | 61 | NavigationView { 62 | List { 63 | NavigationLink("Original", destination: EmptyView()) 64 | NavigationLinkButton("NavigationLinkButton") { } 65 | } 66 | .listStyle(.plain) 67 | } 68 | .preferredColorScheme(.dark) 69 | .previewLayout(.fixed(width: 300, height: 200)) 70 | .previewDisplayName("Dark plain") 71 | 72 | NavigationView { 73 | List { 74 | NavigationLink("Original", destination: EmptyView()) 75 | NavigationLinkButton("NavigationLinkButton") { } 76 | } 77 | .listStyle(.insetGrouped) 78 | } 79 | .preferredColorScheme(.dark) 80 | .previewLayout(.fixed(width: 300, height: 200)) 81 | .previewDisplayName("Dark inset group") 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /UI/UIComponents/Sources/NewsLargeCard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Kingfisher 4 | 5 | import Entities 6 | 7 | // MARK: - _NewsLargeCardButtonStyle 8 | 9 | private struct _NewsLargeCardButtonStyle: ButtonStyle { 10 | func makeBody(configuration: Configuration) -> some View { 11 | configuration.label 12 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 13 | .animation(.easeInOut, value: configuration.isPressed ? 0.95 : 1.0) 14 | } 15 | } 16 | 17 | // MARK: - NewsLargeCard 18 | 19 | public struct NewsLargeCard: View { 20 | private let _news: News 21 | private let _action: (() -> Void)? 22 | 23 | public var body: some View { 24 | Button { 25 | _action?() 26 | } label: { 27 | _buttonLabel 28 | } 29 | .buttonStyle(_NewsLargeCardButtonStyle()) 30 | } 31 | 32 | private var _buttonLabel: some View { 33 | VStack(spacing: 8) { 34 | KFImage(_news.imageURL) 35 | .cacheMemoryOnly() 36 | .placeholder { 37 | Color.gray 38 | } 39 | .resizable() 40 | .aspectRatio(1192 / 626, contentMode: .fit) 41 | 42 | HStack { 43 | Text(_news.category) 44 | .foregroundColor(.secondary) 45 | .font(.footnote) 46 | .fontWeight(.bold) 47 | .lineLimit(1) 48 | Spacer(minLength: 0) 49 | } 50 | .padding([.leading, .trailing], 16) 51 | 52 | HStack { 53 | Text(_news.title) 54 | .font(.headline) 55 | .lineLimit(3) 56 | Spacer(minLength: 0) 57 | } 58 | .padding([.leading, .trailing], 16) 59 | 60 | HStack { 61 | Spacer(minLength: 0) 62 | Text(_dateString) 63 | .foregroundColor(.secondary) 64 | .font(.footnote) 65 | .lineLimit(1) 66 | } 67 | .padding([.leading, .trailing], 16) 68 | .padding(.bottom, 8) 69 | } 70 | .background(Color(uiColor: .secondarySystemGroupedBackground)) 71 | .cornerRadius(16) 72 | } 73 | 74 | private var _dateString: String { 75 | let formatter = DateFormatter() 76 | formatter.dateStyle = .medium 77 | formatter.timeStyle = .none 78 | 79 | return formatter.string(from: _news.date) 80 | } 81 | 82 | public init(_ news: News, action: (() -> Void)? = nil) { 83 | self._news = news 84 | self._action = action 85 | } 86 | } 87 | 88 | // MARK: - NewsLargeCard_Previews 89 | 90 | struct NewsLargeCard_Previews: PreviewProvider { 91 | static var previews: some View { 92 | ScrollView { 93 | VStack { 94 | NewsLargeCard(News.appStoreAward) 95 | .padding() 96 | 97 | NewsLargeCard(News.appleMusicAward) 98 | .padding() 99 | } 100 | .listStyle(.plain) 101 | } 102 | .background(Color.gray) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /UI/UIComponents/Sources/RadioButtonGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - RadioButtonGroup 4 | 5 | public struct RadioButtonGroup: View { 6 | private let _titles: [String] 7 | 8 | @Binding private var _selectedIndex: Int? 9 | 10 | public var body: some View { 11 | VStack(alignment: .leading, spacing: 24) { 12 | ForEach(0..<_titles.count) { index in 13 | RadioButton( 14 | _titles[index], 15 | isSelected: Binding(get: { 16 | index == _selectedIndex 17 | }, set: { newValue in 18 | _selectedIndex = newValue ? index : nil 19 | }) 20 | ) 21 | } 22 | } 23 | .padding(4) 24 | } 25 | 26 | public init(_ titles: [String], selectedIndex: Binding) { 27 | self._titles = titles 28 | self.__selectedIndex = selectedIndex 29 | } 30 | } 31 | 32 | // MARK: - RadioButtonGroup_Previews 33 | 34 | struct RadioButtonGroup_Previews: PreviewProvider { 35 | struct RadioButtonGroupBindingHolder: View { 36 | let titles: [String] 37 | 38 | @State var selectedIndex: Int? 39 | 40 | var body: some View { 41 | RadioButtonGroup(titles, selectedIndex: $selectedIndex) 42 | } 43 | } 44 | 45 | static var previews: some View { 46 | Group { 47 | RadioButtonGroupBindingHolder( 48 | titles: [ 49 | "Item 1", 50 | "Item 22", 51 | "Item 333", 52 | "Item 4444", 53 | "Item 55555", 54 | "Item 1", 55 | ], 56 | selectedIndex: nil 57 | ) 58 | .previewLayout(.sizeThatFits) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /UI/UIComponents/UIComponents.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | UIComponents: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.uicomponents 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/UIComponents 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/UIComponents/Sources 17 | ${SRCROOT}/bin/swiftlint UI/UIComponents/Sources 18 | dependencies: 19 | - target: Entities 20 | - package: Kingfisher 21 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/AppSales/AppSalesFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import Entities 4 | 5 | public protocol AppSalesFlowControllerService: UIViewController { 6 | func start(segment: AppSegment) 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Feed/FeedFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol FeedFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/FeedLanguageSetting/FeedLanguageSettingFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol FeedLanguageSettingFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/FeedList/FeedListFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol FeedListFlowControllerDelegate: AnyObject { 4 | func feedListFlowController(_ flowController: FeedListFlowControllerService, didSelect appSales: AppSegment) 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/FeedList/FeedListFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol FeedListFlowControllerService: UIViewController { 4 | var delegate: FeedListFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/FullScreenModal/FullScreenModalFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | public protocol FullScreenModalFlowControllerDelegate: AnyObject { 2 | func fullScreenModalFlowControllerShouldDismiss(_ flowController: FullScreenModalFlowControllerService) 3 | } 4 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/FullScreenModal/FullScreenModalFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol FullScreenModalFlowControllerService: UIViewController { 4 | var delegate: FullScreenModalFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/KeyboardTest/KeyboardTestFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol KeyboardTestFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Licenses/LicensesFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol LicensesFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Main/MainFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol MainFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/ModalTransitionTest/ModalTransitionTestFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol ModalTransitionTestFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/NoticeAlert/NoticeAlertFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | public protocol NoticeAlertFlowControllerDelegate: AnyObject { 2 | func noticeAlertFlowControllerShouldDismiss(_ flowController: NoticeAlertFlowControllerService) 3 | } 4 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/NoticeAlert/NoticeAlertFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol NoticeAlertFlowControllerService: UIViewController { 4 | var delegate: NoticeAlertFlowControllerDelegate? { get set } 5 | 6 | func start(title: String?, message: String?) 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/PageSheetModal/PageSheetModalFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | public protocol PageSheetModalFlowControllerDelegate: AnyObject { 2 | func pageSheetModalFlowControllerShouldDismiss(_ flowController: PageSheetModalFlowControllerService) 3 | } 4 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/PageSheetModal/PageSheetModalFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol PageSheetModalFlowControllerService: UIViewController { 4 | var delegate: PageSheetModalFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/PushTransitionTest/PushTransitionTestFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | public protocol PushTransitionTestFlowControllerDelegate: AnyObject { 2 | func pushTransitionTestFlowController( 3 | _ flowController: PushTransitionTestFlowControllerService, 4 | nextWith forwardText: String 5 | ) 6 | func pushTransitionTestFlowControllerPopToRoot(_ flowController: PushTransitionTestFlowControllerService) 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/PushTransitionTest/PushTransitionTestFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol PushTransitionTestFlowControllerService: UIViewController { 4 | var delegate: PushTransitionTestFlowControllerDelegate? { get set } 5 | 6 | func start(forwardedText: String) 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Root/RootFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol RootFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Settings/SettingsFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol SettingsFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/SettingsMenu/SettingsMenuFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol SettingsMenuFlowControllerDelegate: AnyObject { 4 | func settingsMenuFlowController( 5 | _ flowController: SettingsMenuFlowControllerService, 6 | didSelect menuRow: SettingsMenu.Row 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/SettingsMenu/SettingsMenuFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol SettingsMenuFlowControllerService: UIViewController { 4 | var delegate: SettingsMenuFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/ToolbarTest/ToolbarTestFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol ToolbarTestFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/UIViewController+Content.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | public func addContent(_ viewController: UIViewController) { 5 | addChild(viewController) 6 | view.addSubview(viewController.view) 7 | viewController.view.translatesAutoresizingMaskIntoConstraints = false 8 | NSLayoutConstraint.activate([ 9 | viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), 10 | viewController.view.topAnchor.constraint(equalTo: view.topAnchor), 11 | viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), 12 | viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), 13 | ]) 14 | viewController.didMove(toParent: self) 15 | } 16 | 17 | public func removeContent(_ viewController: UIViewController) { 18 | viewController.willMove(toParent: nil) 19 | viewController.view.removeFromSuperview() 20 | viewController.removeFromParent() 21 | } 22 | 23 | public func removeAllContentViewController() { 24 | for child in children.reversed() { 25 | removeContent(child) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/UIViewController+OpenLink.swift: -------------------------------------------------------------------------------- 1 | import SafariServices 2 | 3 | extension UIViewController { 4 | public func openInternally(link url: URL) { 5 | let vc = SFSafariViewController(url: url) 6 | 7 | present(vc, animated: true) 8 | } 9 | 10 | public func openExternally(link url: URL) { 11 | UIApplication.shared.open(url, options: [:]) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/UserNameSetting/UserNameSettingFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol UserNameSettingFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/Walkthrough/WalkthroughFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol WalkthroughFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/WalkthroughFinish/WalkthroughFinishFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | public protocol WalkthroughFinishFlowControllerDelegate: AnyObject { 2 | func walkthroughFinishFlowControllerFinishWalkthrough(_ flowController: WalkthroughFinishFlowControllerService) 3 | } 4 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/WalkthroughFinish/WalkthroughFinishFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol WalkthroughFinishFlowControllerService: UIViewController { 4 | var delegate: WalkthroughFinishFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/WalkthroughIntro/WalkthroughIntroFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol WalkthroughIntroFlowControllerService: UIViewController { 4 | func start() 5 | } 6 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/WalkthroughSettings/WalkthroughSettingsFlowControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | public protocol WalkthroughSettingsFlowControllerDelegate: AnyObject { 4 | func walkthroughSettingsFlowController( 5 | _ flowController: WalkthroughSettingsFlowControllerService, 6 | update feedLanguage: FeedLanguage 7 | ) 8 | func walkthroughSettingsFlowController( 9 | _ flowController: WalkthroughSettingsFlowControllerService, 10 | update userName: String 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /UI/UIServices/Sources/WalkthroughSettings/WalkthroughSettingsFlowControllerService.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol WalkthroughSettingsFlowControllerService: UIViewController { 4 | var delegate: WalkthroughSettingsFlowControllerDelegate? { get set } 5 | 6 | func start() 7 | } 8 | -------------------------------------------------------------------------------- /UI/UIServices/UIServices.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | UIServices: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.uiservices 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/UIServices 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/UIServices 17 | ${SRCROOT}/bin/swiftlint UI/UIServices 18 | dependencies: 19 | - target: Entities 20 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/AppSalesView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | AppSalesView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.appsalesview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/AppSalesView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/AppSalesView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/AppSalesView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: RepositoryServices 21 | - target: Resources 22 | - target: UIServices 23 | - target: UIComponents 24 | AppSalesViewTests: 25 | type: bundle.unit-test 26 | platform: iOS 27 | sources: 28 | - path: Tests 29 | group: UI/Views/AppSalesView 30 | excludes: 31 | - "**/.gitkeep" 32 | - path: Tests/Generated/AppSalesViewMocks.generated.swift 33 | group: UI/Views/AppSalesView/Tests/Generated 34 | optional: true 35 | - path: Tests/Generated/RepositoryServicesMocks.generated.swift 36 | group: UI/Views/AppSalesView/Tests/Generated 37 | optional: true 38 | preBuildScripts: 39 | - name: Mockingbird 40 | script: | 41 | DERIVED_DATA="$(xcodebuild -showBuildSettings | sed -n 's|.*BUILD_ROOT = \(.*\)/Build/.*|\1|p')" 42 | "${DERIVED_DATA}/SourcePackages/checkouts/mockingbird/mockingbird" generate \ 43 | --targets AppSalesView RepositoryServices \ 44 | --testbundle AppSalesViewTests \ 45 | --output-dir UI/Views/AppSalesView/Tests/Generated \ 46 | --disable-swiftlint 47 | outputFiles: 48 | - ${SRCROOT}/UI/Views/AppSalesView/Tests/Generated/AppSalesViewMocks.generated.swift 49 | - ${SRCROOT}/UI/Views/AppSalesView/Tests/Generated/RepositoryServicesMocks.generated.swift 50 | basedOnDependencyAnalysis: no 51 | - name: Format and linting 52 | script: | 53 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/AppSalesView/Tests 54 | ${SRCROOT}/bin/swiftlint UI/Views/AppSalesView/Tests 55 | dependencies: 56 | - target: AppSalesView 57 | - package: CombineExpectations 58 | - package: Mockingbird 59 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/Sources/AppSalesFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import Entities 5 | import UIServices 6 | 7 | public final class AppSalesFlowController: UIHostingController, AppSalesFlowControllerService { 8 | private var _cancellable = Set() 9 | 10 | private var _viewModel: AppSalesViewModel { 11 | rootView.viewModel 12 | } 13 | 14 | override public init(rootView: AppSalesView) { 15 | super.init(rootView: rootView) 16 | 17 | // FIXME: Using only `View.navigationBarTitleDisplayMode(.inline)` does not be a smooth transition 18 | navigationItem.largeTitleDisplayMode = .never 19 | } 20 | 21 | @available(*, unavailable) 22 | required init?(coder _: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | 26 | public func start(segment: AppSegment) { 27 | _cancellable = Set() 28 | 29 | _viewModel.segment = segment 30 | 31 | _viewModel.navigationSignal 32 | .receive(on: DispatchQueue.main) 33 | .sink { [weak self] navigation in 34 | guard let self = self else { return } 35 | 36 | switch navigation { 37 | case let .appStore(url): 38 | self.openExternally(link: url) 39 | } 40 | } 41 | .store(in: &_cancellable) 42 | 43 | _viewModel.fetch() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/Sources/ViewModel/AppSalesViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension AppSalesViewModel { 4 | enum Navigation: Hashable { 5 | case appStore(url: URL) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/Sources/ViewModel/AppSalesViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | import RepositoryServices 5 | 6 | public class AppSalesViewModel: ObservableObject { 7 | private let _feedRepository: FeedRepositoryService 8 | private let _preferencesRepository: PreferencesRepositoryService 9 | 10 | private let _navigationSubject = PassthroughSubject() 11 | 12 | @Published public var segment: AppSegment? 13 | 14 | @Published private(set) var isInLoading = false 15 | @Published private(set) var hasError = false 16 | @Published private(set) var freeApps: [AppInformation] = [] 17 | @Published private(set) var paidApps: [AppInformation] = [] 18 | 19 | var navigationSignal: AnyPublisher { 20 | _navigationSubject.eraseToAnyPublisher() 21 | } 22 | 23 | public init(feedRepository: FeedRepositoryService, preferencesRepository: PreferencesRepositoryService) { 24 | self._feedRepository = feedRepository 25 | self._preferencesRepository = preferencesRepository 26 | } 27 | 28 | func fetch() { 29 | Task { @MainActor in 30 | isInLoading = true 31 | 32 | hasError = false 33 | freeApps = [] 34 | paidApps = [] 35 | 36 | let language = await _preferencesRepository.fetchFeedLanguage() ?? .english 37 | do { 38 | (freeApps, paidApps) = try await ( 39 | _feedRepository.fetchFreeAppAllRanking(language: language), 40 | _feedRepository.fetchPaidAppAllRanking(language: language) 41 | ) 42 | } catch { 43 | hasError = true 44 | } 45 | 46 | isInLoading = false 47 | } 48 | } 49 | 50 | func navigate(_ navigation: Navigation) { 51 | _navigationSubject.send(navigation) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/Tests/AppSalesFlowControllerTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import XCTest 3 | 4 | import Mockingbird 5 | 6 | @testable import AppSalesView 7 | @testable import Entities 8 | @testable import RepositoryServices 9 | 10 | class AppSalesFlowControllerTests: XCTestCase { 11 | override func setUpWithError() throws { 12 | try super.setUpWithError() 13 | } 14 | 15 | override func tearDownWithError() throws { 16 | try super.tearDownWithError() 17 | } 18 | 19 | func test_start_FreeApp_SetSegmentAndCallFetch() throws { 20 | // Arrange 21 | let mockFeedRepository = mock(FeedRepositoryService.self) 22 | let mockPreferencesRepository = mock(PreferencesRepositoryService.self) 23 | 24 | let mockViewModel = mock(AppSalesViewModel.self) 25 | .initialize(feedRepository: mockFeedRepository, preferencesRepository: mockPreferencesRepository) 26 | given(mockViewModel.navigationSignal).willReturn(Empty(completeImmediately: false).eraseToAnyPublisher()) 27 | 28 | // Act 29 | let viewController = AppSalesFlowController(rootView: AppSalesView(viewModel: mockViewModel)) 30 | 31 | viewController.start(segment: .freeApp) 32 | 33 | // Assert 34 | verify(mockViewModel.setSegment(.freeApp)).wasCalled() 35 | verify(mockViewModel.fetch()).wasCalled() 36 | } 37 | 38 | func test_start_PaidApp_SetSegmentAndCallFetch() throws { 39 | // Arrange 40 | let mockFeedRepository = mock(FeedRepositoryService.self) 41 | let mockPreferencesRepository = mock(PreferencesRepositoryService.self) 42 | 43 | let mockViewModel = mock(AppSalesViewModel.self) 44 | .initialize(feedRepository: mockFeedRepository, preferencesRepository: mockPreferencesRepository) 45 | given(mockViewModel.navigationSignal).willReturn(Empty(completeImmediately: false).eraseToAnyPublisher()) 46 | 47 | // Act 48 | let viewController = AppSalesFlowController(rootView: AppSalesView(viewModel: mockViewModel)) 49 | 50 | viewController.start(segment: .paidApp) 51 | 52 | // Assert 53 | verify(mockViewModel.setSegment(.paidApp)).wasCalled() 54 | verify(mockViewModel.fetch()).wasCalled() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /UI/Views/AppSalesView/Tests/Generated/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/UI/Views/AppSalesView/Tests/Generated/.gitkeep -------------------------------------------------------------------------------- /UI/Views/FeedLanguageSettingView/FeedLanguageSettingView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FeedLanguageSettingView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.feedlanguagesettingview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/FeedLanguageSettingView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/FeedLanguageSettingView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/FeedLanguageSettingView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: RepositoryServices 21 | - target: UIServices 22 | - target: UIComponents 23 | -------------------------------------------------------------------------------- /UI/Views/FeedLanguageSettingView/Sources/FeedLanguageSettingFlowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIServices 4 | 5 | // swiftformat:disable indent 6 | public final class FeedLanguageSettingFlowController: UIHostingController, 7 | FeedLanguageSettingFlowControllerService { 8 | // swiftformat:enable indent 9 | private var _viewModel: FeedLanguageSettingViewModel { 10 | rootView.viewModel 11 | } 12 | 13 | override public init(rootView: FeedLanguageSettingView) { 14 | super.init(rootView: rootView) 15 | 16 | // FIXME: Using only `View.navigationBarTitleDisplayMode(.inline)` does not be a smooth transition 17 | navigationItem.largeTitleDisplayMode = .never 18 | } 19 | 20 | @available(*, unavailable) 21 | required init?(coder _: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | public func start() { } 26 | } 27 | -------------------------------------------------------------------------------- /UI/Views/FeedLanguageSettingView/Sources/View/FeedLanguageSettingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Entities 4 | import RepositoryServices 5 | import UIComponents 6 | 7 | // MARK: - FeedLanguageSettingView 8 | 9 | public struct FeedLanguageSettingView: View { 10 | @ObservedObject private(set) var viewModel: FeedLanguageSettingViewModel 11 | 12 | private let _languages: [FeedLanguage] = [.english, .japanese] 13 | 14 | public var body: some View { 15 | List { 16 | Section { 17 | RadioButtonGroup( 18 | _languages.map(\.title), 19 | selectedIndex: Binding(get: { 20 | _languages.firstIndex(of: viewModel.language) 21 | }, set: { selectedIndex in 22 | if let selectedIndex = selectedIndex { 23 | viewModel.updateFeedLanguage(_languages[selectedIndex]) 24 | } 25 | }) 26 | ) 27 | .padding([.top, .bottom], 4) 28 | } header: { 29 | Text("Feed language") 30 | } footer: { 31 | Text("Language setting in the feed source.") 32 | } 33 | .textCase(nil) 34 | } 35 | .navigationBarTitleDisplayMode(.inline) 36 | .navigationTitle("Feed language") 37 | } 38 | 39 | public init(viewModel: FeedLanguageSettingViewModel) { 40 | self.viewModel = viewModel 41 | } 42 | } 43 | 44 | // MARK: - FeedLanguageSettingView_Previews 45 | 46 | struct FeedLanguageSettingView_Previews: PreviewProvider { 47 | private class PreviewViewModel: FeedLanguageSettingViewModel { 48 | init() { 49 | super.init(preferencesRepository: PreviewPreferencesRepository()) 50 | } 51 | } 52 | 53 | static var previews: some View { 54 | Group { 55 | NavigationView { 56 | FeedLanguageSettingView(viewModel: PreviewViewModel()) 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /UI/Views/FeedLanguageSettingView/Sources/ViewModel/FeedLanguageSettingViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | import Entities 5 | import RepositoryServices 6 | 7 | public class FeedLanguageSettingViewModel: ObservableObject { 8 | private let _preferencesRepository: PreferencesRepositoryService 9 | 10 | @Published private(set) var language: FeedLanguage = .english 11 | 12 | public init(preferencesRepository: PreferencesRepositoryService) { 13 | self._preferencesRepository = preferencesRepository 14 | 15 | _preferencesRepository.feedLanguageStream() 16 | .receive(on: DispatchQueue.main) 17 | .assign(to: &$language) 18 | } 19 | 20 | func updateFeedLanguage(_ language: FeedLanguage) { 21 | Task { 22 | await self._preferencesRepository.saveFeedLanguage(language) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/FeedListView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FeedListView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.feedlistview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/FeedListView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/FeedListView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/FeedListView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: RepositoryServices 21 | - target: Resources 22 | - target: UIServices 23 | - target: UIComponents 24 | - package: Snappable 25 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/FeedListFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import Entities 5 | import UIServices 6 | 7 | public final class FeedListFlowController: UIHostingController, FeedListFlowControllerService { 8 | public weak var delegate: FeedListFlowControllerDelegate? 9 | 10 | private var _cancellable = Set() 11 | 12 | private var _viewModel: FeedListViewModel { 13 | rootView.viewModel 14 | } 15 | 16 | override public init(rootView: FeedListView) { 17 | super.init(rootView: rootView) 18 | } 19 | 20 | @available(*, unavailable) 21 | required init?(coder _: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | public func start() { 26 | _cancellable = Set() 27 | 28 | _viewModel.navigationSignal 29 | .receive(on: DispatchQueue.main) 30 | .sink { [weak self] navigation in 31 | guard let self = self else { return } 32 | 33 | switch navigation { 34 | case let .news(url): 35 | // self._showBrowseView(url: url) 36 | self.openInternally(link: url) 37 | 38 | case let .appStore(url): 39 | self.openExternally(link: url) 40 | 41 | case .freeAppRanking: 42 | self.delegate?.feedListFlowController(self, didSelect: .freeApp) 43 | 44 | case .paidAppRanking: 45 | self.delegate?.feedListFlowController(self, didSelect: .paidApp) 46 | } 47 | } 48 | .store(in: &_cancellable) 49 | 50 | _viewModel.fetch() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/View/Section/AllNewsSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Snappable 4 | 5 | import Entities 6 | import UIComponents 7 | 8 | // MARK: - AllNewsSection 9 | 10 | struct AllNewsSection: View { 11 | let newsList: [News] 12 | let action: ((News) -> Void)? 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | Text("All News") 17 | .font(.headline) 18 | .padding([.leading, .trailing], 16) 19 | ScrollView(.horizontal, showsIndicators: false) { 20 | HStack(spacing: 0) { 21 | ForEach(0.. Void)? = nil) { 40 | self.newsList = newsList 41 | self.action = action 42 | } 43 | } 44 | 45 | // MARK: - AllNewsSection_Previews 46 | 47 | struct AllNewsSection_Previews: PreviewProvider { 48 | static var previews: some View { 49 | AllNewsSection(News.previewContentList) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/View/Section/AppRankingSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Snappable 4 | 5 | import Entities 6 | import UIComponents 7 | 8 | // MARK: - AppRankingSection 9 | 10 | struct AppRankingSection: View { 11 | let rankingName: String 12 | let apps: [AppInformation] 13 | private let showAllAction: (() -> Void)? 14 | private let openStoreAction: ((URL) -> Void)? 15 | 16 | var body: some View { 17 | VStack(alignment: .leading) { 18 | HStack { 19 | Text(rankingName) 20 | .font(.headline) 21 | .padding([.leading, .trailing], 16) 22 | Spacer() 23 | Button { 24 | showAllAction?() 25 | } label: { 26 | Text("Show All") 27 | .font(.body) 28 | .padding([.leading, .trailing], 16) 29 | } 30 | } 31 | 32 | _carousel 33 | .padding([.top, .bottom], 8) 34 | } 35 | } 36 | 37 | private var _carousel: some View { 38 | ScrollView(.horizontal, showsIndicators: false) { 39 | if !apps.isEmpty { 40 | HStack(spacing: 0) { 41 | ForEach(0..<(apps.count + 2) / 3) { index in 42 | let offset = index * 3 43 | 44 | HStack(spacing: 0) { 45 | Spacer(minLength: 16) 46 | AppRankingStack( 47 | rankOffset: offset + 1, 48 | firstApp: apps[offset], 49 | secondApp: apps.indices.contains(offset + 1) ? apps[offset + 1] : nil, 50 | thirdApp: apps.indices.contains(offset + 2) ? apps[offset + 2] : nil 51 | ) { url in 52 | openStoreAction?(url) 53 | } 54 | .frame(width: UIScreen.main.bounds.width - 48) // FIXME: Avoid using UIScreen.main.bounds 55 | } 56 | .snapID(index) 57 | } 58 | 59 | Spacer(minLength: 32) 60 | } 61 | } 62 | } 63 | .snappable(alignment: .leading) 64 | } 65 | 66 | init( 67 | rankingName: String, 68 | apps: [AppInformation], 69 | showAll: (() -> Void)? = nil, 70 | openLink: ((URL) -> Void)? = nil 71 | ) { 72 | self.rankingName = rankingName 73 | self.apps = apps 74 | self.showAllAction = showAll 75 | self.openStoreAction = openLink 76 | } 77 | } 78 | 79 | // MARK: - AppRankingSection_Previews 80 | 81 | struct AppRankingSection_Previews: PreviewProvider { 82 | static var previews: some View { 83 | AppRankingSection(rankingName: "Free App", apps: AppInformation.previewContentList) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/View/Section/LatestNewsSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Entities 4 | import UIComponents 5 | 6 | // MARK: - LatestNewsSection 7 | 8 | struct LatestNewsSection: View { 9 | let newsList: [News] 10 | private let action: ((News) -> Void)? 11 | 12 | var body: some View { 13 | VStack(alignment: .leading) { 14 | Text("Latest News") 15 | .font(.headline) 16 | .padding([.leading, .trailing], 16) 17 | ForEach(0.. Void)? = nil) { 29 | self.newsList = newsList 30 | self.action = action 31 | } 32 | } 33 | 34 | // MARK: - LatestNewsSection_Previews 35 | 36 | struct LatestNewsSection_Previews: PreviewProvider { 37 | static var previews: some View { 38 | ScrollView { 39 | LatestNewsSection(News.previewContentList) 40 | } 41 | .background(Color.gray) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/ViewModel/FeedListViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FeedListViewModel { 4 | enum Navigation: Hashable { 5 | case news(url: URL) 6 | case appStore(url: URL) 7 | case freeAppRanking 8 | case paidAppRanking 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /UI/Views/FeedListView/Sources/ViewModel/FeedListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | import RepositoryServices 5 | 6 | public class FeedListViewModel: ObservableObject { 7 | private let _feedRepository: FeedRepositoryService 8 | private let _preferencesRepository: PreferencesRepositoryService 9 | 10 | private let _navigationSubject = PassthroughSubject() 11 | 12 | @Published private(set) var isInLoading = false 13 | @Published private(set) var hasError = false 14 | @Published private(set) var newsList: [News] = [] 15 | @Published private(set) var freeAppRanking: [AppInformation] = [] 16 | @Published private(set) var paidAppRanking: [AppInformation] = [] 17 | 18 | var navigationSignal: AnyPublisher { 19 | _navigationSubject.eraseToAnyPublisher() 20 | } 21 | 22 | public init(feedRepository: FeedRepositoryService, preferencesRepository: PreferencesRepositoryService) { 23 | self._feedRepository = feedRepository 24 | self._preferencesRepository = preferencesRepository 25 | } 26 | 27 | func fetch() { 28 | Task { @MainActor in 29 | guard !isInLoading else { return } 30 | 31 | isInLoading = true 32 | 33 | hasError = false 34 | newsList = [] 35 | freeAppRanking = [] 36 | paidAppRanking = [] 37 | 38 | do { 39 | let language = await _preferencesRepository.fetchFeedLanguage() ?? .english 40 | 41 | (newsList, freeAppRanking, paidAppRanking) = try await ( 42 | _feedRepository.fetchNews(language: language), 43 | _feedRepository.fetchFreeAppTop15Ranking(language: language), 44 | _feedRepository.fetchPaidAppTop15Ranking(language: language) 45 | ) 46 | } catch { 47 | hasError = true 48 | } 49 | isInLoading = false 50 | } 51 | } 52 | 53 | func navigate(_ navigation: Navigation) { 54 | _navigationSubject.send(navigation) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /UI/Views/FeedView/FeedView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FeedView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.feedview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/FeedView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/FeedView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/FeedView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/FeedView/Sources/FeedFlowController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import Entities 4 | import UIServices 5 | 6 | public final class FeedFlowController: UIViewController, FeedFlowControllerService, FeedListFlowControllerDelegate { 7 | private let _viewModel: FeedViewModel 8 | private let _appSalesProvider: () -> AppSalesFlowControllerService 9 | private let _feedListProvider: () -> FeedListFlowControllerService 10 | 11 | private let _embeddedNavigationController = UINavigationController() 12 | 13 | public init( 14 | viewModel: FeedViewModel, 15 | appSalesProvider: @autoclosure @escaping () -> AppSalesFlowControllerService, 16 | feedListProvider: @autoclosure @escaping () -> FeedListFlowControllerService 17 | ) { 18 | self._viewModel = viewModel 19 | self._appSalesProvider = appSalesProvider 20 | self._feedListProvider = feedListProvider 21 | 22 | super.init(nibName: nil, bundle: nil) 23 | 24 | _embeddedNavigationController.navigationBar.prefersLargeTitles = true 25 | 26 | addContent(_embeddedNavigationController) 27 | } 28 | 29 | @available(*, unavailable) 30 | required init?(coder _: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | public func start() { 35 | let feedList = _feedListProvider() 36 | feedList.delegate = self 37 | 38 | _embeddedNavigationController.setViewControllers([feedList], animated: false) 39 | 40 | feedList.start() 41 | } 42 | 43 | public func feedListFlowController(_: FeedListFlowControllerService, didSelect appSales: AppSegment) { 44 | let sales = _appSalesProvider() 45 | 46 | _embeddedNavigationController.pushViewController(sales, animated: true) 47 | 48 | sales.start(segment: appSales) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /UI/Views/FeedView/Sources/ViewModel/FeedViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class FeedViewModel: ObservableObject { 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/FullScreenModalView/FullScreenModalView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | FullScreenModalView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.fullscreenmodalview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/FullScreenModalView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/FullScreenModalView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/FullScreenModalView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/FullScreenModalView/Sources/FullScreenModalFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class FullScreenModalFlowController: UIHostingController, 8 | FullScreenModalFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: FullScreenModalFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: FullScreenModalViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: FullScreenModalView) { 19 | super.init(rootView: rootView) 20 | 21 | modalPresentationStyle = .fullScreen 22 | } 23 | 24 | @available(*, unavailable) 25 | required init?(coder _: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | public func start() { 30 | _cancellable = Set() 31 | 32 | _viewModel.navigationSignal 33 | .receive(on: DispatchQueue.main) 34 | .sink { [weak self] navigation in 35 | guard let self = self else { return } 36 | 37 | switch navigation { 38 | case .dismiss: 39 | self.delegate?.fullScreenModalFlowControllerShouldDismiss(self) 40 | } 41 | } 42 | .store(in: &_cancellable) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /UI/Views/FullScreenModalView/Sources/View/FullScreenModalView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - FullScreenModalView 4 | 5 | public struct FullScreenModalView: View { 6 | @ObservedObject private(set) var viewModel: FullScreenModalViewModel 7 | 8 | public var body: some View { 9 | NavigationView { // Just for the layout 10 | Button { 11 | viewModel.navigate(.dismiss) 12 | } label: { 13 | Text("Dismiss") 14 | } 15 | .navigationBarTitleDisplayMode(.inline) 16 | .navigationTitle("Modal .fullScreen") 17 | } 18 | } 19 | 20 | public init(viewModel: FullScreenModalViewModel) { 21 | self.viewModel = viewModel 22 | } 23 | } 24 | 25 | // MARK: - FullScreenModalView_Previews 26 | 27 | struct FullScreenModalView_Previews: PreviewProvider { 28 | private class PreviewViewModel: FullScreenModalViewModel { } 29 | 30 | static var previews: some View { 31 | FullScreenModalView(viewModel: PreviewViewModel()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /UI/Views/FullScreenModalView/Sources/ViewModel/FullScreenModalViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension FullScreenModalViewModel { 2 | enum Navigation { 3 | case dismiss 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/FullScreenModalView/Sources/ViewModel/FullScreenModalViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class FullScreenModalViewModel: ObservableObject { 4 | private let _navigationSubject = PassthroughSubject() 5 | 6 | var navigationSignal: AnyPublisher { 7 | _navigationSubject.eraseToAnyPublisher() 8 | } 9 | 10 | public init() { } 11 | 12 | func navigate(_ navigation: Navigation) { 13 | _navigationSubject.send(navigation) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /UI/Views/KeyboardTestView/KeyboardTestView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | KeyboardTestView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.keyboardtestview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/KeyboardTestView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/KeyboardTestView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/KeyboardTestView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/KeyboardTestView/Sources/KeyboardTestFlowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIServices 4 | 5 | // swiftformat:disable indent 6 | public final class KeyboardTestFlowController: UIHostingController, 7 | KeyboardTestFlowControllerService { 8 | // swiftformat:enable indent 9 | private var _viewModel: KeyboardTestViewModel { 10 | rootView.viewModel 11 | } 12 | 13 | override public init(rootView: KeyboardTestView) { 14 | super.init(rootView: rootView) 15 | } 16 | 17 | @available(*, unavailable) 18 | required init?(coder _: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | 22 | public func start() { } 23 | } 24 | -------------------------------------------------------------------------------- /UI/Views/KeyboardTestView/Sources/View/KeyboardTestView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - KeyboardTestView 4 | 5 | public struct KeyboardTestView: View { 6 | @ObservedObject private(set) var viewModel: KeyboardTestViewModel 7 | 8 | public var body: some View { 9 | List { 10 | Section("TextField 1") { 11 | TextField("TextField 1", text: $viewModel.text1) 12 | } 13 | Section("TextField 2") { 14 | TextField("TextField 2", text: $viewModel.text2) 15 | } 16 | Section("TextField 3") { 17 | TextField("TextField 3", text: $viewModel.text3) 18 | } 19 | Section("TextField 4") { 20 | TextField("TextField 4", text: $viewModel.text4) 21 | } 22 | Section("TextField 5") { 23 | TextField("TextField 5", text: $viewModel.text5) 24 | } 25 | Section("TextField 6") { 26 | TextField("TextField 6", text: $viewModel.text6) 27 | } 28 | Section("TextField 7") { 29 | TextField("TextField 7", text: $viewModel.text7) 30 | } 31 | Section("TextField 8") { 32 | TextField("TextField 8", text: $viewModel.text8) 33 | } 34 | Section("TextField 9") { 35 | TextField("TextField 9", text: $viewModel.text9) 36 | } 37 | Section("TextField 10") { 38 | TextField("TextField 10", text: $viewModel.text10) 39 | } 40 | } 41 | .onTapGesture { 42 | UIApplication.shared.sendAction( 43 | #selector(UIResponder.resignFirstResponder), 44 | to: nil, 45 | from: nil, 46 | for: nil 47 | ) 48 | } 49 | .navigationBarTitleDisplayMode(.inline) 50 | .navigationTitle("Keyboard test") 51 | } 52 | 53 | public init(viewModel: KeyboardTestViewModel) { 54 | self.viewModel = viewModel 55 | } 56 | } 57 | 58 | // MARK: - KeyboardTestView_Previews 59 | 60 | struct KeyboardTestView_Previews: PreviewProvider { 61 | private class PreviewViewModel: KeyboardTestViewModel { } 62 | 63 | static var previews: some View { 64 | NavigationView { 65 | KeyboardTestView(viewModel: PreviewViewModel()) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /UI/Views/KeyboardTestView/Sources/ViewModel/KeyboardTestViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class KeyboardTestViewModel: ObservableObject { 4 | @Published var text1 = "" 5 | @Published var text2 = "" 6 | @Published var text3 = "" 7 | @Published var text4 = "" 8 | @Published var text5 = "" 9 | @Published var text6 = "" 10 | @Published var text7 = "" 11 | @Published var text8 = "" 12 | @Published var text9 = "" 13 | @Published var text10 = "" 14 | 15 | public init() { } 16 | } 17 | -------------------------------------------------------------------------------- /UI/Views/LicensesView/LicensesView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | LicensesView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.licensesview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/LicensesView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/LicensesView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/LicensesView/Sources 18 | dependencies: 19 | - target: Resources 20 | - target: UIServices 21 | - package: MarkdownView 22 | -------------------------------------------------------------------------------- /UI/Views/LicensesView/Sources/LicensesFlowController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import Resources 4 | import UIServices 5 | 6 | public final class LicensesFlowController: UIViewController, LicensesFlowControllerService { 7 | private let _rootView: LicensesView 8 | 9 | private var _viewModel: LicensesViewModel { 10 | _rootView.viewModel 11 | } 12 | 13 | public init(rootView: LicensesView) { 14 | self._rootView = rootView 15 | 16 | super.init(nibName: nil, bundle: nil) 17 | 18 | navigationItem.largeTitleDisplayMode = .never 19 | view.backgroundColor = Asset.background.color 20 | 21 | addContent(rootView) 22 | } 23 | 24 | @available(*, unavailable) 25 | required init?(coder _: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | public func start() { 30 | _viewModel.markdownText = (try? String(contentsOf: Files.licensesMd.url)) ?? "" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /UI/Views/LicensesView/Sources/View/LicensesView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | import MarkdownView 5 | 6 | public final class LicensesView: UIViewController { 7 | private let _markdownView = MarkdownView() 8 | 9 | private var _cancellable = Set() 10 | 11 | private(set) var viewModel: LicensesViewModel 12 | 13 | public init(viewModel: LicensesViewModel) { 14 | self.viewModel = viewModel 15 | 16 | super.init(nibName: nil, bundle: nil) 17 | 18 | view.addSubview(_markdownView) 19 | _markdownView.translatesAutoresizingMaskIntoConstraints = false 20 | NSLayoutConstraint.activate([ 21 | _markdownView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 22 | _markdownView.topAnchor.constraint(equalTo: view.topAnchor), 23 | _markdownView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 24 | _markdownView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 25 | ]) 26 | 27 | updateView() 28 | viewModel.objectWillChange 29 | .receive(on: DispatchQueue.main) 30 | .sink { [weak self] _ in 31 | self?.updateView() 32 | } 33 | .store(in: &_cancellable) 34 | } 35 | 36 | @available(*, unavailable) 37 | required init?(coder _: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | private func updateView() { 42 | _markdownView.load(markdown: viewModel.markdownText) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /UI/Views/LicensesView/Sources/ViewModel/LicensesViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class LicensesViewModel: ObservableObject { 4 | @Published var markdownText = "" 5 | 6 | public init() { } 7 | } 8 | -------------------------------------------------------------------------------- /UI/Views/MainView/MainView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | MainView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.mainview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/MainView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/MainView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/MainView/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/MainView/Sources/MainFlowController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import RepositoryServices 4 | import UIServices 5 | 6 | public final class MainFlowController: UIViewController, MainFlowControllerService { 7 | private let _viewModel: MainViewModel 8 | private let _feedProvider: () -> FeedFlowControllerService 9 | private let _settingsProvider: () -> SettingsFlowControllerService 10 | 11 | private let _embeddedTabBarController = UITabBarController() 12 | 13 | public init( 14 | viewModel: MainViewModel, 15 | feedProvider: @autoclosure @escaping () -> FeedFlowControllerService, 16 | settingsProvider: @autoclosure @escaping () -> SettingsFlowControllerService 17 | ) { 18 | self._viewModel = viewModel 19 | self._feedProvider = feedProvider 20 | self._settingsProvider = settingsProvider 21 | 22 | super.init(nibName: nil, bundle: nil) 23 | 24 | addContent(_embeddedTabBarController) 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | public func start() { 33 | let feed = _feedProvider() 34 | let settings = _settingsProvider() 35 | 36 | feed.tabBarItem = UITabBarItem( 37 | title: "Feed", 38 | image: UIImage(systemName: "doc.text.image"), // SFSymbol has no Feed icon 39 | tag: 0 40 | ) 41 | settings.tabBarItem = UITabBarItem( 42 | title: "Settings", 43 | image: UIImage(systemName: "wrench.and.screwdriver"), 44 | tag: 1 45 | ) 46 | 47 | _embeddedTabBarController.setViewControllers([feed, settings], animated: false) 48 | _embeddedTabBarController.selectedIndex = 0 49 | 50 | feed.start() 51 | settings.start() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /UI/Views/MainView/Sources/ViewModel/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class MainViewModel: ObservableObject { 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/ModalTransitionTestView/ModalTransitionTestView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | ModalTransitionTestView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.modaltransitiontestview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/ModalTransitionTestView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/ModalTransitionTestView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/ModalTransitionTestView/Sources 18 | dependencies: 19 | - target: Resources 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/ModalTransitionTestView/Sources/View/ModalTransitionTestView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Resources 4 | 5 | // MARK: - ModalTransitionTestView 6 | 7 | public struct ModalTransitionTestView: View { 8 | @ObservedObject private(set) var viewModel: ModalTransitionTestViewModel 9 | 10 | public var body: some View { 11 | VStack { 12 | Button { 13 | viewModel.navigate(.alert) 14 | } label: { 15 | Text("Alert") 16 | } 17 | .padding() 18 | 19 | Button { 20 | viewModel.navigate(.fullScreen) 21 | } label: { 22 | Text("Modal .fullScreen") 23 | } 24 | .padding() 25 | 26 | Button { 27 | viewModel.navigate(.pageSheet) 28 | } label: { 29 | Text("Modal .pageSheet") 30 | } 31 | .padding() 32 | } 33 | .frame(maxWidth: .infinity, maxHeight: .infinity) 34 | .background(Color(uiColor: Asset.background.color)) 35 | .navigationBarTitleDisplayMode(.inline) 36 | .navigationTitle("Modal transition") 37 | } 38 | 39 | public init(viewModel: ModalTransitionTestViewModel) { 40 | self.viewModel = viewModel 41 | } 42 | } 43 | 44 | // MARK: - ModalTransitionTestView_Previews 45 | 46 | struct ModalTransitionTestView_Previews: PreviewProvider { 47 | private class PreviewViewModel: ModalTransitionTestViewModel { } 48 | 49 | static var previews: some View { 50 | NavigationView { 51 | ModalTransitionTestView(viewModel: PreviewViewModel()) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /UI/Views/ModalTransitionTestView/Sources/ViewModel/ModalTransitionTestViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension ModalTransitionTestViewModel { 2 | enum Navigation { 3 | case alert 4 | case fullScreen 5 | case pageSheet 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /UI/Views/ModalTransitionTestView/Sources/ViewModel/ModalTransitionTestViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class ModalTransitionTestViewModel: ObservableObject { 4 | private let _navigationSubject = PassthroughSubject() 5 | 6 | var navigationSignal: AnyPublisher { 7 | _navigationSubject.eraseToAnyPublisher() 8 | } 9 | 10 | public init() { } 11 | 12 | func navigate(_ navigation: Navigation) { 13 | _navigationSubject.send(navigation) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /UI/Views/NoticeAlertView/NoticeAlertView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | NoticeAlertView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.noticealertview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/NoticeAlertView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/NoticeAlertView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/NoticeAlertView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/NoticeAlertView/Sources/NoticeAlertFlowController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | import UIServices 4 | 5 | public final class NoticeAlertFlowController: UIAlertController, NoticeAlertFlowControllerService { 6 | // This is an experimental implementation for using UIAlertController based on the FlowController pattern. 7 | // UIAlertController subclassing is known as a bad practice. 8 | // See also https://developer.apple.com/documentation/uikit/uialertcontroller 9 | 10 | public static func instantiate() -> NoticeAlertFlowController { 11 | let alert = NoticeAlertFlowController(title: nil, message: nil, preferredStyle: .alert) 12 | 13 | alert.addAction( 14 | UIAlertAction(title: "OK", style: .default) { _ in 15 | alert.delegate?.noticeAlertFlowControllerShouldDismiss(alert) 16 | } 17 | ) 18 | 19 | return alert 20 | } 21 | 22 | public weak var delegate: NoticeAlertFlowControllerDelegate? 23 | 24 | public func start(title: String?, message: String?) { 25 | self.title = title 26 | self.message = message 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /UI/Views/PageSheetModalView/PageSheetModalView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | PageSheetModalView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.pagesheetmodalview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/PageSheetModalView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/PageSheetModalView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/PageSheetModalView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/PageSheetModalView/Sources/PageSheetModalFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class PageSheetModalFlowController: UIHostingController, 8 | PageSheetModalFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: PageSheetModalFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: PageSheetModalViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: PageSheetModalView) { 19 | super.init(rootView: rootView) 20 | 21 | modalPresentationStyle = .pageSheet 22 | } 23 | 24 | @available(*, unavailable) 25 | required init?(coder _: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | public func start() { 30 | _cancellable = Set() 31 | 32 | _viewModel.navigationSignal 33 | .receive(on: DispatchQueue.main) 34 | .sink { [weak self] navigation in 35 | guard let self = self else { return } 36 | 37 | switch navigation { 38 | case .dismiss: 39 | self.delegate?.pageSheetModalFlowControllerShouldDismiss(self) 40 | } 41 | } 42 | .store(in: &_cancellable) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /UI/Views/PageSheetModalView/Sources/View/PageSheetModalView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - PageSheetModalView 4 | 5 | public struct PageSheetModalView: View { 6 | @ObservedObject private(set) var viewModel: PageSheetModalViewModel 7 | 8 | public var body: some View { 9 | NavigationView { // Just for the layout 10 | Button { 11 | viewModel.navigate(.dismiss) 12 | } label: { 13 | Text("Dismiss") 14 | } 15 | .navigationBarTitleDisplayMode(.inline) 16 | .navigationTitle("Modal .pageSheet") 17 | } 18 | } 19 | 20 | public init(viewModel: PageSheetModalViewModel) { 21 | self.viewModel = viewModel 22 | } 23 | } 24 | 25 | // MARK: - PageSheetModalView_Previews 26 | 27 | struct PageSheetModalView_Previews: PreviewProvider { 28 | private class PreviewViewModel: PageSheetModalViewModel { } 29 | 30 | static var previews: some View { 31 | PageSheetModalView(viewModel: PreviewViewModel()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /UI/Views/PageSheetModalView/Sources/ViewModel/PageSheetModalViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension PageSheetModalViewModel { 2 | enum Navigation { 3 | case dismiss 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/PageSheetModalView/Sources/ViewModel/PageSheetModalViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class PageSheetModalViewModel: ObservableObject { 4 | private let _navigationSubject = PassthroughSubject() 5 | 6 | var navigationSignal: AnyPublisher { 7 | _navigationSubject.eraseToAnyPublisher() 8 | } 9 | 10 | public init() { } 11 | 12 | func navigate(_ navigation: Navigation) { 13 | _navigationSubject.send(navigation) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /UI/Views/PushTransitionTestView/PushTransitionTestView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | PushTransitionTestView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.pushtransitiontestview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/PushTransitionTestView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/PushTransitionTestView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/PushTransitionTestView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/PushTransitionTestView/Sources/PushTransitionTestFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class PushTransitionTestFlowController: UIHostingController, 8 | PushTransitionTestFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: PushTransitionTestFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: PushTransitionTestViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: PushTransitionTestView) { 19 | super.init(rootView: rootView) 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder _: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | public func start(forwardedText: String) { 28 | _cancellable = Set() 29 | 30 | _viewModel.forwardText = forwardedText 31 | 32 | _viewModel.navigationSignal 33 | .receive(on: DispatchQueue.main) 34 | .sink { [weak self] navigation in 35 | guard let self = self else { return } 36 | 37 | switch navigation { 38 | case .next: 39 | self.delegate?.pushTransitionTestFlowController(self, nextWith: self._viewModel.forwardText) 40 | 41 | case .popToRoot: 42 | self.delegate?.pushTransitionTestFlowControllerPopToRoot(self) 43 | } 44 | } 45 | .store(in: &_cancellable) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /UI/Views/PushTransitionTestView/Sources/View/PushTransitionTestView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - PushTransitionTestView 4 | 5 | public struct PushTransitionTestView: View { 6 | @ObservedObject private(set) var viewModel: PushTransitionTestViewModel 7 | 8 | public var body: some View { 9 | List { 10 | Section { 11 | TextField("Forward text", text: $viewModel.forwardText) 12 | } header: { 13 | Text("Forward text") 14 | } 15 | .textCase(nil) 16 | } 17 | .navigationBarTitleDisplayMode(.inline) 18 | .navigationTitle("Push transition") 19 | .toolbar { 20 | ToolbarItemGroup(placement: .bottomBar) { 21 | Button { 22 | viewModel.navigate(.popToRoot) 23 | } label: { 24 | Text("Pop to root") 25 | } 26 | Spacer() 27 | Button { 28 | viewModel.navigate(.next) 29 | } label: { 30 | Text("Next") 31 | } 32 | Spacer() 33 | } 34 | } 35 | } 36 | 37 | public init(viewModel: PushTransitionTestViewModel) { 38 | self.viewModel = viewModel 39 | } 40 | } 41 | 42 | // MARK: - PushTransitionTestView_Previews 43 | 44 | struct PushTransitionTestView_Previews: PreviewProvider { 45 | private class PreviewViewModel: PushTransitionTestViewModel { } 46 | 47 | static var previews: some View { 48 | NavigationView { 49 | PushTransitionTestView(viewModel: PreviewViewModel()) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /UI/Views/PushTransitionTestView/Sources/ViewModel/PushTransitionTestViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension PushTransitionTestViewModel { 2 | enum Navigation { 3 | case next 4 | case popToRoot 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /UI/Views/PushTransitionTestView/Sources/ViewModel/PushTransitionTestViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class PushTransitionTestViewModel: ObservableObject { 4 | private let _navigationSubject = PassthroughSubject() 5 | 6 | @Published var forwardText = "" 7 | 8 | var navigationSignal: AnyPublisher { 9 | _navigationSubject.eraseToAnyPublisher() 10 | } 11 | 12 | public init() { } 13 | 14 | func navigate(_ navigation: Navigation) { 15 | _navigationSubject.send(navigation) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /UI/Views/RootView/RootView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | RootView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.rootview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/RootView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/RootView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/RootView/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: UIServices 21 | RootViewTests: 22 | type: bundle.unit-test 23 | platform: iOS 24 | sources: 25 | - path: Tests 26 | group: UI/Views/RootView 27 | excludes: 28 | - "**/.gitkeep" 29 | - path: Tests/Generated/RepositoryServicesMocks.generated.swift 30 | group: UI/Views/RootView/Tests/Generated 31 | optional: true 32 | - path: Tests/Generated/RootViewMocks.generated.swift 33 | group: UI/Views/RootView/Tests/Generated 34 | optional: true 35 | - path: Tests/Generated/UIServicesMocks.generated.swift 36 | group: UI/Views/RootView/Tests/Generated 37 | optional: true 38 | preBuildScripts: 39 | - name: Mockingbird 40 | script: | 41 | DERIVED_DATA="$(xcodebuild -showBuildSettings | sed -n 's|.*BUILD_ROOT = \(.*\)/Build/.*|\1|p')" 42 | "${DERIVED_DATA}/SourcePackages/checkouts/mockingbird/mockingbird" generate \ 43 | --targets RepositoryServices RootView UIServices \ 44 | --testbundle RootViewTests \ 45 | --output-dir UI/Views/RootView/Tests/Generated \ 46 | --disable-swiftlint 47 | outputFiles: 48 | - ${SRCROOT}/UI/Views/RootView/Tests/Generated/RepositoryServicesMocks.generated.swift 49 | - ${SRCROOT}/UI/Views/RootView/Tests/Generated/RootViewMocks.generated.swift 50 | - ${SRCROOT}/UI/Views/RootView/Tests/Generated/UIServicesMocks.generated.swift 51 | basedOnDependencyAnalysis: no 52 | - name: Format and linting 53 | script: | 54 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/RootView/Tests 55 | ${SRCROOT}/bin/swiftlint UI/Views/RootView/Tests 56 | dependencies: 57 | - target: RootView 58 | - package: CombineExpectations 59 | - package: Mockingbird 60 | -------------------------------------------------------------------------------- /UI/Views/RootView/Sources/RootFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | import UIKit 5 | 6 | import UIServices 7 | 8 | public final class RootFlowController: UIViewController, RootFlowControllerService { 9 | private let _rootViewModel: RootViewModel 10 | private let _walkthroughProvider: () -> WalkthroughFlowControllerService 11 | private let _mainProvider: () -> MainFlowControllerService 12 | 13 | private var _cancellable = Set() 14 | 15 | public init( 16 | rootViewModel: RootViewModel, 17 | walkthroughProvider: @autoclosure @escaping () -> WalkthroughFlowControllerService, 18 | mainProvider: @autoclosure @escaping () -> MainFlowControllerService 19 | ) { 20 | self._rootViewModel = rootViewModel 21 | self._walkthroughProvider = walkthroughProvider 22 | self._mainProvider = mainProvider 23 | 24 | super.init(nibName: nil, bundle: nil) 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | public func start() { 33 | _cancellable = Set() 34 | 35 | _rootViewModel.navigationStream 36 | .receive(on: DispatchQueue.main) 37 | .sink { [weak self] navigation in 38 | guard let self = self else { return } 39 | 40 | switch navigation { 41 | case .main: 42 | self.startMain() 43 | 44 | case .walkthrough: 45 | self.startWalkthrough() 46 | } 47 | } 48 | .store(in: &_cancellable) 49 | } 50 | 51 | private func startWalkthrough() { 52 | dismiss(animated: false) { [weak self] in 53 | guard let self = self else { return } 54 | 55 | let walkthrough = self._walkthroughProvider() 56 | 57 | self.removeAllContentViewController() 58 | self.addContent(walkthrough) 59 | 60 | walkthrough.start() 61 | } 62 | } 63 | 64 | private func startMain() { 65 | dismiss(animated: false) { [weak self] in 66 | guard let self = self else { return } 67 | 68 | let main = self._mainProvider() 69 | 70 | self.removeAllContentViewController() 71 | self.addContent(main) 72 | 73 | main.start() 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /UI/Views/RootView/Sources/ViewModel/RootViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension RootViewModel { 2 | enum Navigation { 3 | case main 4 | case walkthrough 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /UI/Views/RootView/Sources/ViewModel/RootViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import RepositoryServices 4 | 5 | public class RootViewModel: ObservableObject { 6 | private let _appStateRepository: AppStateRepositoryService 7 | 8 | private let _navigationSubject = CurrentValueSubject(nil) 9 | 10 | var navigationStream: AnyPublisher { 11 | _navigationSubject 12 | .compactMap { $0 } 13 | .eraseToAnyPublisher() 14 | } 15 | 16 | private var _cancellable = Set() 17 | 18 | public init(appStateRepository: AppStateRepositoryService) { 19 | self._appStateRepository = appStateRepository 20 | 21 | _appStateRepository.isWalkthroughFinishedStream() 22 | .sink { [weak self] isWalkthroughFinished in 23 | guard let self = self else { return } 24 | 25 | self._navigationSubject.send(isWalkthroughFinished ? .main : .walkthrough) 26 | } 27 | .store(in: &_cancellable) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /UI/Views/RootView/Tests/Generated/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/UI/Views/RootView/Tests/Generated/.gitkeep -------------------------------------------------------------------------------- /UI/Views/RootView/Tests/RootViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import XCTest 3 | 4 | import CombineExpectations 5 | import Mockingbird 6 | 7 | @testable import Entities 8 | @testable import RepositoryServices 9 | @testable import RootView 10 | 11 | class RootViewModelTests: XCTestCase { 12 | override func setUpWithError() throws { 13 | try super.setUpWithError() 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | try super.tearDownWithError() 18 | } 19 | 20 | func test_navigationStream_IsWalkthroughFinishedNever_OutputNever() throws { 21 | // Arrange 22 | let mockAppStateRepository = mock(AppStateRepositoryService.self) 23 | given(mockAppStateRepository.isWalkthroughFinishedStream()) 24 | .willReturn(Empty(completeImmediately: false).eraseToAnyPublisher()) 25 | 26 | // Act 27 | let viewModel = RootViewModel(appStateRepository: mockAppStateRepository) 28 | let recorder = viewModel.navigationStream.record() 29 | 30 | let output = try wait(for: recorder.availableElements, timeout: 0.1) 31 | 32 | // Assert 33 | XCTAssertEqual([], output) 34 | } 35 | 36 | func test_navigationStream_IsWalkthroughFinishedFalse_OutputWalkthrough() throws { 37 | // Arrange 38 | let mockAppStateRepository = mock(AppStateRepositoryService.self) 39 | given(mockAppStateRepository.isWalkthroughFinishedStream()) 40 | .willReturn(Just(false).append(Empty(completeImmediately: false)).eraseToAnyPublisher()) 41 | 42 | // Act 43 | let viewModel = RootViewModel(appStateRepository: mockAppStateRepository) 44 | let recorder = viewModel.navigationStream.record() 45 | 46 | let output = try wait(for: recorder.availableElements, timeout: 0.1) 47 | 48 | // Assert 49 | XCTAssertEqual([.walkthrough], output) 50 | } 51 | 52 | func test_navigationStream_IsWalkthroughFinishedTrue_OutputMain() throws { 53 | // Arrange 54 | let mockAppStateRepository = mock(AppStateRepositoryService.self) 55 | given(mockAppStateRepository.isWalkthroughFinishedStream()) 56 | .willReturn(Just(true).append(Empty(completeImmediately: false)).eraseToAnyPublisher()) 57 | 58 | // Act 59 | let viewModel = RootViewModel(appStateRepository: mockAppStateRepository) 60 | let recorder = viewModel.navigationStream.record() 61 | 62 | let output = try wait(for: recorder.availableElements, timeout: 0.1) 63 | 64 | // Assert 65 | XCTAssertEqual([.main], output) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /UI/Views/SettingsMenuView/SettingsMenuView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | SettingsMenuView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.settingsmenuview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/SettingsMenuView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/SettingsMenuView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/SettingsMenuView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: RepositoryServices 21 | - target: UIServices 22 | - target: UIComponents 23 | -------------------------------------------------------------------------------- /UI/Views/SettingsMenuView/Sources/SettingsMenuFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class SettingsMenuFlowController: UIHostingController, 8 | SettingsMenuFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: SettingsMenuFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: SettingsMenuViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: SettingsMenuView) { 19 | super.init(rootView: rootView) 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder _: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | public func start() { 28 | _cancellable = Set() 29 | 30 | _viewModel.navigationSignal 31 | .receive(on: DispatchQueue.main) 32 | .sink { [weak self] navigation in 33 | guard let self = self else { return } 34 | 35 | switch navigation { 36 | case let .menu(row): 37 | self.delegate?.settingsMenuFlowController(self, didSelect: row) 38 | } 39 | } 40 | .store(in: &_cancellable) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /UI/Views/SettingsMenuView/Sources/View/SettingsMenuView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Entities 4 | import RepositoryServices 5 | import UIComponents 6 | 7 | // MARK: - SettingsMenuView 8 | 9 | public struct SettingsMenuView: View { 10 | @ObservedObject private(set) var viewModel: SettingsMenuViewModel 11 | 12 | public var body: some View { 13 | List { 14 | ForEach(SettingsMenu.Section.allCases, id: \.self) { section in 15 | Section { 16 | ForEach(section.rows, id: \.self) { row in 17 | switch row.type { 18 | case .user: 19 | HStack { 20 | Image(systemName: "person.circle.fill") 21 | .resizable() 22 | .foregroundColor(.secondary) 23 | .frame(width: 48, height: 48) 24 | .padding([.top, .trailing, .bottom], 8) 25 | Text(viewModel.userName) 26 | } 27 | 28 | case .transition: 29 | NavigationLinkButton(row.title) { 30 | viewModel.navigate(.menu(row: row)) 31 | } 32 | 33 | case .version: 34 | HStack { 35 | Text("Version") 36 | Spacer() 37 | Text("1.0.0").foregroundColor(.secondary) 38 | } 39 | } 40 | } 41 | } header: { 42 | if let title = section.title { 43 | Text(title) 44 | } 45 | } 46 | .textCase(nil) 47 | } 48 | } 49 | .listStyle(.insetGrouped) 50 | .navigationTitle("Settings") 51 | } 52 | 53 | public init(viewModel: SettingsMenuViewModel) { 54 | self.viewModel = viewModel 55 | } 56 | } 57 | 58 | // MARK: - SettingsMenuView_Previews 59 | 60 | struct SettingsMenuView_Previews: PreviewProvider { 61 | private class PreviewModel: SettingsMenuViewModel { 62 | private let _userName: String 63 | 64 | override var userName: String { _userName } 65 | 66 | init(userName: String = "") { 67 | self._userName = userName 68 | 69 | super.init(preferenceRepository: PreviewPreferencesRepository()) 70 | } 71 | } 72 | 73 | static var previews: some View { 74 | NavigationView { 75 | SettingsMenuView(viewModel: PreviewModel(userName: "John Doe")) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /UI/Views/SettingsMenuView/Sources/ViewModel/SettingsMenuViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | import Entities 2 | 3 | extension SettingsMenuViewModel { 4 | enum Navigation: Hashable { 5 | case menu(row: SettingsMenu.Row) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /UI/Views/SettingsMenuView/Sources/ViewModel/SettingsMenuViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import RepositoryServices 4 | 5 | public class SettingsMenuViewModel: ObservableObject { 6 | private let _preferencesRepository: PreferencesRepositoryService 7 | 8 | private let _navigationSubject = PassthroughSubject() 9 | 10 | @Published private(set) var userName = "" 11 | 12 | var navigationSignal: AnyPublisher { 13 | _navigationSubject.eraseToAnyPublisher() 14 | } 15 | 16 | public init(preferenceRepository: PreferencesRepositoryService) { 17 | self._preferencesRepository = preferenceRepository 18 | 19 | _preferencesRepository.userNameStream() 20 | .assign(to: &$userName) 21 | } 22 | 23 | func navigate(_ navigation: Navigation) { 24 | _navigationSubject.send(navigation) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /UI/Views/SettingsView/SettingsView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | SettingsView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.settingsview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/SettingsView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/SettingsView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/SettingsView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/SettingsView/Sources/ViewModel/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class SettingsViewModel: ObservableObject { 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/ToolbarTestView/Sources/ToolbarTestFlowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIServices 4 | 5 | public final class ToolbarTestFlowController: UIHostingController, ToolbarTestFlowControllerService { 6 | private var _viewModel: ToolbarTestViewModel { 7 | rootView.viewModel 8 | } 9 | 10 | override public init(rootView: ToolbarTestView) { 11 | super.init(rootView: rootView) 12 | } 13 | 14 | @available(*, unavailable) 15 | required init?(coder _: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | public func start() { } 20 | } 21 | -------------------------------------------------------------------------------- /UI/Views/ToolbarTestView/Sources/View/ToolbarTestView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - ToolbarTestView 4 | 5 | public struct ToolbarTestView: View { 6 | @ObservedObject private(set) var viewModel: ToolbarTestViewModel 7 | 8 | public var body: some View { 9 | List { 10 | Section("Action") { 11 | Text(viewModel.actionText) 12 | } 13 | } 14 | .toolbar { 15 | ToolbarItem(placement: .navigationBarTrailing) { 16 | Button { 17 | viewModel.printNavigation1Text() 18 | } label: { 19 | Image(systemName: "1.circle") 20 | } 21 | } 22 | 23 | ToolbarItem(placement: .navigationBarTrailing) { 24 | Button { 25 | viewModel.printNavigation2Text() 26 | } label: { 27 | Image(systemName: "2.circle") 28 | } 29 | } 30 | 31 | ToolbarItemGroup(placement: .bottomBar) { 32 | Button("Left") { 33 | viewModel.printBottomLeftText() 34 | } 35 | Spacer() 36 | Button("Center") { 37 | viewModel.printBottomCenterText() 38 | } 39 | Spacer() 40 | Button("Right") { 41 | viewModel.printBottomRightText() 42 | } 43 | } 44 | } 45 | .navigationBarTitleDisplayMode(.inline) 46 | .navigationTitle("Toolbar test") 47 | } 48 | 49 | public init(viewModel: ToolbarTestViewModel) { 50 | self.viewModel = viewModel 51 | } 52 | } 53 | 54 | // MARK: - ToolbarTestView_Previews 55 | 56 | struct ToolbarTestView_Previews: PreviewProvider { 57 | private class PreviewViewModel: ToolbarTestViewModel { } 58 | 59 | static var previews: some View { 60 | NavigationView { 61 | ToolbarTestView(viewModel: PreviewViewModel()) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /UI/Views/ToolbarTestView/Sources/ViewModel/ToolbarTestViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class ToolbarTestViewModel: ObservableObject { 4 | @Published var actionText = "" 5 | 6 | public init() { } 7 | 8 | func printNavigation1Text() { 9 | actionText = "Navigation 1 tapped." 10 | } 11 | 12 | func printNavigation2Text() { 13 | actionText = "Navigation 2 tapped." 14 | } 15 | 16 | func printBottomLeftText() { 17 | actionText = "Bottom left tapped." 18 | } 19 | 20 | func printBottomCenterText() { 21 | actionText = "Bottom center tapped." 22 | } 23 | 24 | func printBottomRightText() { 25 | actionText = "Bottom right tapped." 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /UI/Views/ToolbarTestView/ToolbarTestView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | ToolbarTestView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.toolbartestview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/ToolbarTestView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/ToolbarTestView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/ToolbarTestView/Sources 18 | dependencies: 19 | - target: UIServices 20 | -------------------------------------------------------------------------------- /UI/Views/UserNameSettingView/Sources/UserNameSettingFlowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIServices 4 | 5 | // swiftformat:disable indent 6 | public final class UserNameSettingFlowController: UIHostingController, 7 | UserNameSettingFlowControllerService { 8 | // swiftformat:enable indent 9 | private var _viewModel: UserNameSettingViewModel { 10 | rootView.viewModel 11 | } 12 | 13 | override public init(rootView: UserNameSettingView) { 14 | super.init(rootView: rootView) 15 | 16 | // FIXME: Using only `View.navigationBarTitleDisplayMode(.inline)` does not be a smooth transition 17 | navigationItem.largeTitleDisplayMode = .never 18 | } 19 | 20 | @available(*, unavailable) 21 | required init?(coder _: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | public func start() { } 26 | } 27 | -------------------------------------------------------------------------------- /UI/Views/UserNameSettingView/Sources/View/UserNameSettingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import RepositoryServices 4 | 5 | // MARK: - UserNameSettingView 6 | 7 | public struct UserNameSettingView: View { 8 | @ObservedObject private(set) var viewModel: UserNameSettingViewModel 9 | 10 | public var body: some View { 11 | List { 12 | Section { 13 | TextField( 14 | "Put your name", 15 | text: Binding(get: { 16 | viewModel.userName 17 | }, set: { text in 18 | viewModel.updateUserName(text) 19 | }) 20 | ) 21 | .padding(4) 22 | } header: { 23 | Text("User name") 24 | } footer: { 25 | Text("For testing keyboard behavior and local data stores.") 26 | } 27 | .textCase(nil) 28 | } 29 | .navigationBarTitleDisplayMode(.inline) 30 | .navigationTitle("User name") 31 | } 32 | 33 | public init(viewModel: UserNameSettingViewModel) { 34 | self.viewModel = viewModel 35 | } 36 | } 37 | 38 | // MARK: - UserNameSettingView_Previews 39 | 40 | struct UserNameSettingView_Previews: PreviewProvider { 41 | private class PreviewViewModel: UserNameSettingViewModel { 42 | private let _userName: String 43 | 44 | init(userName: String = "") { 45 | self._userName = userName 46 | 47 | super.init(preferencesRepository: PreviewPreferencesRepository()) 48 | } 49 | 50 | override var userName: String { _userName } 51 | } 52 | 53 | static var previews: some View { 54 | Group { 55 | NavigationView { 56 | UserNameSettingView( 57 | viewModel: PreviewViewModel() 58 | ) 59 | } 60 | 61 | NavigationView { 62 | UserNameSettingView( 63 | viewModel: PreviewViewModel(userName: "Typical Name") 64 | ) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /UI/Views/UserNameSettingView/Sources/ViewModel/UserNameSettingViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | import RepositoryServices 5 | 6 | public class UserNameSettingViewModel: ObservableObject { 7 | private let _preferencesRepository: PreferencesRepositoryService 8 | 9 | @Published private(set) var userName = "" 10 | 11 | public init(preferencesRepository: PreferencesRepositoryService) { 12 | self._preferencesRepository = preferencesRepository 13 | 14 | _preferencesRepository.userNameStream() 15 | .receive(on: DispatchQueue.main) 16 | .assign(to: &$userName) 17 | } 18 | 19 | func updateUserName(_ name: String) { 20 | Task { 21 | await _preferencesRepository.saveUserName(name) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /UI/Views/UserNameSettingView/UserNameSettingView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | UserNameSettingView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.usernamesettingview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/UserNameSettingView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/UserNameSettingView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/UserNameSettingView/Sources 18 | dependencies: 19 | - target: RepositoryServices 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughFinishView/Sources/View/WalkthroughFinishView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Resources 4 | 5 | // MARK: - WalkthroughFinishView 6 | 7 | public struct WalkthroughFinishView: View { 8 | @ObservedObject private(set) var viewModel: WalkthroughFinishViewModel 9 | 10 | public var body: some View { 11 | NavigationView { // For layout purpose only 12 | ZStack { 13 | Color(uiColor: Asset.background.color) 14 | .ignoresSafeArea() 15 | 16 | Button { 17 | viewModel.navigate(.finish) 18 | } label: { 19 | Text("Start") 20 | } 21 | } 22 | .navigationBarTitleDisplayMode(.large) 23 | .navigationTitle("Start App") 24 | } 25 | } 26 | 27 | public init(viewModel: WalkthroughFinishViewModel) { 28 | self.viewModel = viewModel 29 | } 30 | } 31 | 32 | // MARK: - WalkthroughFinishView_Previews 33 | 34 | struct WalkthroughFinishView_Previews: PreviewProvider { 35 | private class PreviewModel: WalkthroughFinishViewModel { } 36 | 37 | static var previews: some View { 38 | WalkthroughFinishView(viewModel: PreviewModel()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughFinishView/Sources/ViewModel/WalkthroughFinishViewModel+Navigation.swift: -------------------------------------------------------------------------------- 1 | extension WalkthroughFinishViewModel { 2 | enum Navigation { 3 | case finish 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughFinishView/Sources/ViewModel/WalkthroughFinishViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public class WalkthroughFinishViewModel: ObservableObject { 4 | private let _navigationSubject = PassthroughSubject() 5 | 6 | var navigationSignal: AnyPublisher { 7 | _navigationSubject.eraseToAnyPublisher() 8 | } 9 | 10 | public init() { } 11 | 12 | func navigate(_ navigation: Navigation) { 13 | _navigationSubject.send(navigation) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughFinishView/Sources/WalkthroughFinishFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class WalkthroughFinishFlowController: UIHostingController, 8 | WalkthroughFinishFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: WalkthroughFinishFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: WalkthroughFinishViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: WalkthroughFinishView) { 19 | super.init(rootView: rootView) 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder _: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | public func start() { 28 | _cancellable = Set() 29 | 30 | _viewModel.navigationSignal 31 | .receive(on: DispatchQueue.main) 32 | .sink { [weak self] navigation in 33 | guard let self = self else { return } 34 | 35 | switch navigation { 36 | case .finish: 37 | self.delegate?.walkthroughFinishFlowControllerFinishWalkthrough(self) 38 | } 39 | } 40 | .store(in: &_cancellable) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughFinishView/WalkthroughFinishView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | WalkthroughFinishView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.walkthroughfinishview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/WalkthroughFinishView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/WalkthroughFinishView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/WalkthroughFinishView/Sources 18 | dependencies: 19 | - target: Resources 20 | - target: UIServices 21 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughIntroView/Sources/View/WalkthroughIntroView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Resources 4 | import UIComponents 5 | 6 | // MARK: - WalkthroughIntroView 7 | 8 | public struct WalkthroughIntroView: View { 9 | @ObservedObject private(set) var viewModel: WalkthroughIntroViewModel 10 | 11 | public var body: some View { 12 | NavigationView { // For layout purpose only 13 | VStack { 14 | HStack { 15 | Text( 16 | """ 17 | This is a Proof-of-Concept app for iOS app architecture using SwiftUI + FlowController pattern. 18 | """ 19 | ) 20 | Spacer(minLength: 0) 21 | } 22 | .padding() 23 | 24 | HStack { 25 | Text("App has features below:") 26 | Spacer(minLength: 0) 27 | } 28 | .padding([.leading, .trailing]) 29 | .padding([.bottom], 8) 30 | 31 | HStack { 32 | VStack(alignment: .leading, spacing: 4) { 33 | BulletPointText("Walkthrough with UIPageViewController") 34 | BulletPointText("Main view with UITabBarController") 35 | BulletPointText("Feed view and Settings view with UINavigationController") 36 | BulletPointText("Feed view shows the feed for Apple Newsroom and App sales") 37 | BulletPointText("Settings view has some views for feature tests") 38 | } 39 | Spacer(minLength: 0) 40 | } 41 | .padding([.leading, .trailing]) 42 | 43 | Spacer() 44 | } 45 | .background(Color(uiColor: Asset.background.color)) 46 | .navigationBarTitleDisplayMode(.large) 47 | .navigationTitle("About") 48 | } 49 | } 50 | 51 | public init(viewModel: WalkthroughIntroViewModel) { 52 | self.viewModel = viewModel 53 | } 54 | } 55 | 56 | // MARK: - WalkthroughIntroView_Previews 57 | 58 | struct WalkthroughIntroView_Previews: PreviewProvider { 59 | private class PreviewModel: WalkthroughIntroViewModel { } 60 | 61 | static var previews: some View { 62 | WalkthroughIntroView(viewModel: PreviewModel()) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughIntroView/Sources/ViewModel/WalkthroughIntroViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class WalkthroughIntroViewModel: ObservableObject { 4 | public init() { } 5 | } 6 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughIntroView/Sources/WalkthroughIntroFlowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIServices 4 | 5 | // swiftformat:disable indent 6 | public final class WalkthroughIntroFlowController: UIHostingController, 7 | WalkthroughIntroFlowControllerService { 8 | // swiftformat:enable indent 9 | private var _viewModel: WalkthroughIntroViewModel { 10 | rootView.viewModel 11 | } 12 | 13 | override public init(rootView: WalkthroughIntroView) { 14 | super.init(rootView: rootView) 15 | } 16 | 17 | @available(*, unavailable) 18 | required init?(coder _: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | 22 | public func start() { } 23 | } 24 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughIntroView/WalkthroughIntroView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | WalkthroughIntroView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.walkthroughintroview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/WalkthroughIntroView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/WalkthroughIntroView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/WalkthroughIntroView/Sources 18 | dependencies: 19 | - target: Resources 20 | - target: UIComponents 21 | - target: UIServices 22 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughSettingsView/Sources/View/WalkthroughSettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Entities 4 | import Resources 5 | import UIComponents 6 | 7 | // MARK: - WalkthroughSettingsView 8 | 9 | public struct WalkthroughSettingsView: View { 10 | @ObservedObject private(set) var viewModel: WalkthroughSettingsViewModel 11 | 12 | private let _languages: [FeedLanguage] = [.english, .japanese] 13 | 14 | public var body: some View { 15 | NavigationView { // For layout purpose only 16 | List { 17 | Section { 18 | RadioButtonGroup( 19 | _languages.map(\.title), 20 | selectedIndex: Binding(get: { 21 | _languages.firstIndex(of: viewModel.feedLanguage) 22 | }, set: { selectedIndex in 23 | if let selectedIndex = selectedIndex { 24 | viewModel.feedLanguage = _languages[selectedIndex] 25 | } 26 | }) 27 | ) 28 | .padding([.top, .bottom], 4) 29 | } header: { 30 | Text("Feed language") 31 | } footer: { 32 | Text("Language setting in the feed source.") 33 | } 34 | .textCase(nil) 35 | 36 | Section { 37 | TextField( 38 | "Put your name", 39 | text: $viewModel.userName 40 | ) 41 | .padding(4) 42 | } header: { 43 | Text("User name") 44 | } footer: { 45 | Text("For testing keyboard behavior and local data stores.") 46 | } 47 | .textCase(nil) 48 | } 49 | .listStyle(.insetGrouped) 50 | .navigationBarTitleDisplayMode(.large) 51 | .navigationTitle("Settings") 52 | } 53 | } 54 | 55 | public init(viewModel: WalkthroughSettingsViewModel) { 56 | self.viewModel = viewModel 57 | } 58 | } 59 | 60 | // MARK: - WalkthroughSettingsView_Previews 61 | 62 | struct WalkthroughSettingsView_Previews: PreviewProvider { 63 | private class PreviewModel: WalkthroughSettingsViewModel { 64 | init(feedLanguage: FeedLanguage, userName: String) { 65 | super.init() 66 | 67 | self.feedLanguage = feedLanguage 68 | self.userName = userName 69 | } 70 | } 71 | 72 | static var previews: some View { 73 | WalkthroughSettingsView( 74 | viewModel: PreviewModel( 75 | feedLanguage: .english, 76 | userName: "" 77 | ) 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughSettingsView/Sources/ViewModel/WalkthroughSettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | 5 | public class WalkthroughSettingsViewModel: ObservableObject { 6 | @Published var feedLanguage: FeedLanguage = .english 7 | @Published var userName = "" 8 | 9 | var feedLanguageStream: AnyPublisher { 10 | $feedLanguage.eraseToAnyPublisher() 11 | } 12 | 13 | var userNameStream: AnyPublisher { 14 | $userName.eraseToAnyPublisher() 15 | } 16 | 17 | public init() { } 18 | } 19 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughSettingsView/Sources/WalkthroughSettingsFlowController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | import UIServices 5 | 6 | // swiftformat:disable indent 7 | public final class WalkthroughSettingsFlowController: UIHostingController, 8 | WalkthroughSettingsFlowControllerService { 9 | // swiftformat:enable indent 10 | public weak var delegate: WalkthroughSettingsFlowControllerDelegate? 11 | 12 | private var _cancellable = Set() 13 | 14 | private var _viewModel: WalkthroughSettingsViewModel { 15 | rootView.viewModel 16 | } 17 | 18 | override public init(rootView: WalkthroughSettingsView) { 19 | super.init(rootView: rootView) 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder _: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | public func start() { 28 | _cancellable = Set() 29 | 30 | _viewModel.feedLanguageStream 31 | .receive(on: DispatchQueue.main) 32 | .sink { [weak self] language in 33 | guard let self = self else { return } 34 | self.delegate?.walkthroughSettingsFlowController(self, update: language) 35 | } 36 | .store(in: &_cancellable) 37 | 38 | _viewModel.userNameStream 39 | .receive(on: DispatchQueue.main) 40 | .sink { [weak self] name in 41 | guard let self = self else { return } 42 | self.delegate?.walkthroughSettingsFlowController(self, update: name) 43 | } 44 | .store(in: &_cancellable) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughSettingsView/WalkthroughSettingsView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | WalkthroughSettingsView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.walkthroughsettingsview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/WalkthroughSettingsView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/WalkthroughSettingsView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/WalkthroughSettingsView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: Resources 21 | - target: UIServices 22 | - target: UIComponents 23 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughView/Sources/ViewModel/WalkthroughViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | import Entities 4 | import RepositoryServices 5 | 6 | public class WalkthroughViewModel: ObservableObject { 7 | private let _appStateRepository: AppStateRepositoryService 8 | private let _preferencesRepository: PreferencesRepositoryService 9 | 10 | @Published var feedLanguage: FeedLanguage? 11 | @Published var userName: String? 12 | 13 | public init(appStateRepository: AppStateRepositoryService, preferencesRepository: PreferencesRepositoryService) { 14 | self._appStateRepository = appStateRepository 15 | self._preferencesRepository = preferencesRepository 16 | } 17 | 18 | func finishWalkthrough() { 19 | Task { 20 | if let feedLanguage = feedLanguage { 21 | await _preferencesRepository.saveFeedLanguage(feedLanguage) 22 | } 23 | if let userName = userName { 24 | await _preferencesRepository.saveUserName(userName) 25 | } 26 | await _appStateRepository.finishWalkthrough() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /UI/Views/WalkthroughView/WalkthroughView.project.yml: -------------------------------------------------------------------------------- 1 | targets: 2 | WalkthroughView: 3 | type: framework 4 | platform: iOS 5 | settings: 6 | base: 7 | PRODUCT_BUNDLE_IDENTIFIER: io.github.hugehoge.swiftuiflowcontroller.walkthroughview 8 | TARGETED_DEVICE_FAMILY: 1 9 | GENERATE_INFOPLIST_FILE: YES 10 | sources: 11 | - path: Sources 12 | group: UI/Views/WalkthroughView 13 | preBuildScripts: 14 | - name: Format and linting 15 | script: | 16 | ${SRCROOT}/bin/swiftformat --swiftversion 5.5 UI/Views/WalkthroughView/Sources 17 | ${SRCROOT}/bin/swiftlint UI/Views/WalkthroughView/Sources 18 | dependencies: 19 | - target: Entities 20 | - target: RepositoryServices 21 | - target: Resources 22 | - target: UIServices 23 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/bin/.gitkeep -------------------------------------------------------------------------------- /images/architecture/concept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/architecture/concept.png -------------------------------------------------------------------------------- /images/architecture/data_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/architecture/data_flow.png -------------------------------------------------------------------------------- /images/architecture/data_flow2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/architecture/data_flow2.png -------------------------------------------------------------------------------- /images/architecture/event_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/architecture/event_flow.png -------------------------------------------------------------------------------- /images/architecture/transition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/architecture/transition.png -------------------------------------------------------------------------------- /images/screenshots/app_sales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/app_sales.png -------------------------------------------------------------------------------- /images/screenshots/feed_language_setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/feed_language_setting.png -------------------------------------------------------------------------------- /images/screenshots/feed_list_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/feed_list_1.png -------------------------------------------------------------------------------- /images/screenshots/feed_list_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/feed_list_2.png -------------------------------------------------------------------------------- /images/screenshots/feed_list_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/feed_list_3.png -------------------------------------------------------------------------------- /images/screenshots/keyboard_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/keyboard_test.png -------------------------------------------------------------------------------- /images/screenshots/licenses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/licenses.png -------------------------------------------------------------------------------- /images/screenshots/modal_transition_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/modal_transition_test.png -------------------------------------------------------------------------------- /images/screenshots/push_transition_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/push_transition_test.png -------------------------------------------------------------------------------- /images/screenshots/settings_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/settings_menu.png -------------------------------------------------------------------------------- /images/screenshots/toolbar_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/toolbar_test.png -------------------------------------------------------------------------------- /images/screenshots/user_name_setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/user_name_setting.png -------------------------------------------------------------------------------- /images/screenshots/walkthrough_finish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/walkthrough_finish.png -------------------------------------------------------------------------------- /images/screenshots/walkthrough_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/walkthrough_intro.png -------------------------------------------------------------------------------- /images/screenshots/walkthrough_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/images/screenshots/walkthrough_settings.png -------------------------------------------------------------------------------- /lib/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugehoge/swiftui-flowcontroller-pattern/5b6baccbcb0690418fe62cfb50761bc38a83255d/lib/.gitkeep -------------------------------------------------------------------------------- /scripts/local-install-licenseplist.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Function 4 | 5 | function print_verbose() { 6 | if [ ${S_FLAG} -ne 1 ]; then 7 | echo "$1" 8 | fi 9 | } 10 | 11 | # Usage Text 12 | 13 | USAGE=$(cat <] [install path] 16 | 17 | Options: 18 | -h help text 19 | -s silent mode 20 | EOF 21 | ) 22 | 23 | # Arguments parse 24 | 25 | S_FLAG=0 26 | while getopts :hs OPT 27 | do 28 | case $OPT in 29 | h) echo "${USAGE}" 30 | exit 0 31 | ;; 32 | s) S_FLAG=1 33 | ;; 34 | \?) echo "${USAGE}" 1>&2 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | shift $((OPTIND - 1)) 41 | 42 | if [ $# != 1 ]; then 43 | echo "${USAGE}" 1>&2 44 | exit 1 45 | fi 46 | INSTALL_PATH=$1 47 | 48 | # Install process 49 | 50 | mkdir -p "${INSTALL_PATH}" 51 | 52 | NAME="LicensePlist" 53 | VERSION="3.17.0" 54 | ZIP_URL="https://github.com/mono0926/LicensePlist/releases/download/${VERSION}/portable_licenseplist.zip" 55 | BIN_PATH="${INSTALL_PATH}/license-plist" 56 | 57 | VERSION_CMD="${BIN_PATH} --version" 58 | EXPECTED_VERSION_FMT="${VERSION}" 59 | 60 | if [ "$(${VERSION_CMD} 2>/dev/null)" != "${EXPECTED_VERSION_FMT}" ]; then 61 | print_verbose "${NAME} ${VERSION} not installed. Download and installing..." 62 | 63 | curl -fsSL "${ZIP_URL}" | bsdtar xf - -C "${INSTALL_PATH}" 64 | chmod 755 "${BIN_PATH}" 65 | 66 | print_verbose "${NAME} ${VERSION} installed." 67 | else 68 | print_verbose "${NAME} ${VERSION} already installed." 69 | fi 70 | 71 | echo "${BIN_PATH}" 72 | -------------------------------------------------------------------------------- /scripts/local-install-swiftformat.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Function 4 | 5 | function print_verbose() { 6 | if [ ${S_FLAG} -ne 1 ]; then 7 | echo "$1" 8 | fi 9 | } 10 | 11 | # Usage Text 12 | 13 | USAGE=$(cat <] [install path] 16 | 17 | Options: 18 | -h help text 19 | -s silent mode 20 | EOF 21 | ) 22 | 23 | # Arguments parse 24 | 25 | S_FLAG=0 26 | while getopts :hs OPT 27 | do 28 | case $OPT in 29 | h) echo "${USAGE}" 30 | exit 0 31 | ;; 32 | s) S_FLAG=1 33 | ;; 34 | \?) echo "${USAGE}" 1>&2 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | shift $((OPTIND - 1)) 41 | 42 | if [ $# != 1 ]; then 43 | echo "${USAGE}" 1>&2 44 | exit 1 45 | fi 46 | INSTALL_PATH=$1 47 | 48 | # Install process 49 | 50 | mkdir -p "${INSTALL_PATH}" 51 | 52 | NAME="SwiftFormat" 53 | VERSION="0.49.3" 54 | ZIP_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${VERSION}/swiftformat.zip" 55 | BIN_PATH="${INSTALL_PATH}/swiftformat" 56 | 57 | VERSION_CMD="${BIN_PATH} --version" 58 | EXPECTED_VERSION_FMT="${VERSION}" 59 | 60 | if [ "$(${VERSION_CMD} 2>/dev/null)" != "${EXPECTED_VERSION_FMT}" ]; then 61 | print_verbose "${NAME} ${VERSION} not installed. Download and installing..." 62 | 63 | curl -fsSL "${ZIP_URL}" | bsdtar xf - -C "${INSTALL_PATH}" 64 | chmod 755 "${BIN_PATH}" 65 | 66 | print_verbose "${NAME} ${VERSION} installed." 67 | else 68 | print_verbose "${NAME} ${VERSION} already installed." 69 | fi 70 | 71 | echo "${BIN_PATH}" 72 | -------------------------------------------------------------------------------- /scripts/local-install-swiftgen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Function 4 | 5 | function print_verbose() { 6 | if [ ${S_FLAG} -ne 1 ]; then 7 | echo "$1" 8 | fi 9 | } 10 | 11 | # Usage Text 12 | 13 | USAGE=$(cat <] [install path] 16 | 17 | Options: 18 | -h help text 19 | -s silent mode 20 | EOF 21 | ) 22 | 23 | # Arguments parse 24 | 25 | S_FLAG=0 26 | while getopts :hs OPT 27 | do 28 | case $OPT in 29 | h) echo "${USAGE}" 30 | exit 0 31 | ;; 32 | s) S_FLAG=1 33 | ;; 34 | \?) echo "${USAGE}" 1>&2 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | shift $((OPTIND - 1)) 41 | 42 | if [ $# != 1 ]; then 43 | echo "${USAGE}" 1>&2 44 | exit 1 45 | fi 46 | INSTALL_PATH=$1 47 | 48 | # Install process 49 | 50 | mkdir -p "${INSTALL_PATH}" 51 | 52 | NAME="SwiftGen" 53 | VERSION="6.5.1" 54 | ZIP_URL="https://github.com/SwiftGen/SwiftGen/releases/download/${VERSION}/swiftgen-${VERSION}.zip" 55 | BIN_PATH="${INSTALL_PATH}/bin/swiftgen" 56 | 57 | STENCIL_VERSION="0.14.1" 58 | STENCILSWIFTKIT_VERSION="2.8.0" 59 | SWIFTGENKIT_VERSION="6.5.1" 60 | 61 | VERSION_CMD="${BIN_PATH} --version" 62 | EXPECTED_VERSION_FMT="SwiftGen v${VERSION} (Stencil v${STENCIL_VERSION}, StencilSwiftKit v${STENCILSWIFTKIT_VERSION}, SwiftGenKit v${SWIFTGENKIT_VERSION})" 63 | 64 | if [ "$(${VERSION_CMD} 2>/dev/null)" != "${EXPECTED_VERSION_FMT}" ]; then 65 | print_verbose "${NAME} ${VERSION} not installed. Download and installing..." 66 | 67 | curl -fsSL "${ZIP_URL}" | bsdtar xf - -C "${INSTALL_PATH}" 68 | chmod 755 "${BIN_PATH}" 69 | 70 | print_verbose "${NAME} ${VERSION} installed." 71 | else 72 | print_verbose "${NAME} ${VERSION} already installed." 73 | fi 74 | 75 | echo "${BIN_PATH}" 76 | -------------------------------------------------------------------------------- /scripts/local-install-swiftlint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Function 4 | 5 | function print_verbose() { 6 | if [ ${S_FLAG} -ne 1 ]; then 7 | echo "$1" 8 | fi 9 | } 10 | 11 | # Usage Text 12 | 13 | USAGE=$(cat <] [install path] 16 | 17 | Options: 18 | -h help text 19 | -s silent mode 20 | EOF 21 | ) 22 | 23 | # Arguments parse 24 | 25 | S_FLAG=0 26 | while getopts :hs OPT 27 | do 28 | case $OPT in 29 | h) echo "${USAGE}" 30 | exit 0 31 | ;; 32 | s) S_FLAG=1 33 | ;; 34 | \?) echo "${USAGE}" 1>&2 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | shift $((OPTIND - 1)) 41 | 42 | if [ $# != 1 ]; then 43 | echo "${USAGE}" 1>&2 44 | exit 1 45 | fi 46 | INSTALL_PATH=$1 47 | 48 | # Install process 49 | 50 | mkdir -p "${INSTALL_PATH}" 51 | 52 | NAME="SwiftLint" 53 | VERSION="0.46.2" 54 | ZIP_URL="https://github.com/realm/SwiftLint/releases/download/${VERSION}/portable_swiftlint.zip" 55 | BIN_PATH="${INSTALL_PATH}/swiftlint" 56 | 57 | VERSION_CMD="${BIN_PATH} version" 58 | EXPECTED_VERSION_FMT="${VERSION}" 59 | 60 | if [ "$(${VERSION_CMD} 2>/dev/null)" != "${EXPECTED_VERSION_FMT}" ]; then 61 | print_verbose "${NAME} ${VERSION} not installed. Download and installing..." 62 | 63 | curl -fsSL "${ZIP_URL}" | bsdtar xf - -C "${INSTALL_PATH}" 64 | chmod 755 "${BIN_PATH}" 65 | 66 | print_verbose "${NAME} ${VERSION} installed." 67 | else 68 | print_verbose "${NAME} ${VERSION} already installed." 69 | fi 70 | 71 | echo "${BIN_PATH}" 72 | -------------------------------------------------------------------------------- /scripts/local-install-xcodegen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Function 4 | 5 | function print_verbose() { 6 | if [ ${S_FLAG} -ne 1 ]; then 7 | echo "$1" 8 | fi 9 | } 10 | 11 | # Usage Text 12 | 13 | USAGE=$(cat <] [install path] 16 | 17 | Options: 18 | -h help text 19 | -s silent mode 20 | EOF 21 | ) 22 | 23 | # Arguments parse 24 | 25 | S_FLAG=0 26 | while getopts :hs OPT 27 | do 28 | case $OPT in 29 | h) echo "${USAGE}" 30 | exit 0 31 | ;; 32 | s) S_FLAG=1 33 | ;; 34 | \?) echo "${USAGE}" 1>&2 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | shift $((OPTIND - 1)) 41 | 42 | if [ $# != 1 ]; then 43 | echo "${USAGE}" 1>&2 44 | exit 1 45 | fi 46 | INSTALL_PATH=$1 47 | 48 | # Install process 49 | 50 | mkdir -p "${INSTALL_PATH}" 51 | 52 | NAME="XcodeGen" 53 | VERSION="2.25.0" 54 | ZIP_URL="https://github.com/yonaskolb/XcodeGen/releases/download/${VERSION}/xcodegen.zip" 55 | BIN_PATH="${INSTALL_PATH}/xcodegen/bin/xcodegen" 56 | 57 | VERSION_CMD="${BIN_PATH} version" 58 | EXPECTED_VERSION_FMT="Version: ${VERSION}" 59 | 60 | if [ "$(${VERSION_CMD} 2>/dev/null)" != "${EXPECTED_VERSION_FMT}" ]; then 61 | print_verbose "${NAME} ${VERSION} not installed. Download and installing..." 62 | 63 | curl -fsSL "${ZIP_URL}" | bsdtar xf - -C "${INSTALL_PATH}" 64 | chmod 755 "${BIN_PATH}" 65 | 66 | print_verbose "${NAME} ${VERSION} installed." 67 | else 68 | print_verbose "${NAME} ${VERSION} already installed." 69 | fi 70 | 71 | echo "${BIN_PATH}" 72 | -------------------------------------------------------------------------------- /swiftui-flowcontroller-pattern.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /swiftui-flowcontroller-pattern.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /swiftui-flowcontroller-pattern.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CombineExpectations", 6 | "repositoryURL": "https://github.com/groue/CombineExpectations.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "04d4e4b21c9e8361925f03f64a7ecda89ef9974f", 10 | "version": "0.10.0" 11 | } 12 | }, 13 | { 14 | "package": "Kanna", 15 | "repositoryURL": "https://github.com/tid-kijyun/Kanna.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "f9e4922223dd0d3dfbf02ca70812cf5531fc0593", 19 | "version": "5.2.7" 20 | } 21 | }, 22 | { 23 | "package": "Kingfisher", 24 | "repositoryURL": "https://github.com/onevcat/Kingfisher.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "0c02c46cfdc0656ce74fd0963a75e5000a0b7f23", 28 | "version": "7.1.2" 29 | } 30 | }, 31 | { 32 | "package": "MarkdownView", 33 | "repositoryURL": "https://github.com/keitaoouchi/MarkdownView.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "35964c602ecfccc359180224a9d7d318d03b304b", 37 | "version": "1.8.3" 38 | } 39 | }, 40 | { 41 | "package": "Mockingbird", 42 | "repositoryURL": "https://github.com/birdrides/mockingbird.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "6e0d473df6b975719e145f6aebf90990c634700e", 46 | "version": "0.20.0" 47 | } 48 | }, 49 | { 50 | "package": "OHHTTPStubs", 51 | "repositoryURL": "https://github.com/AliSoftware/OHHTTPStubs.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "12f19662426d0434d6c330c6974d53e2eb10ecd9", 55 | "version": "9.1.0" 56 | } 57 | }, 58 | { 59 | "package": "Resolver", 60 | "repositoryURL": "https://github.com/hmlongco/Resolver.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "97de0b0320036607564af4a60025b48f8d041221", 64 | "version": "1.5.0" 65 | } 66 | }, 67 | { 68 | "package": "Snappable", 69 | "repositoryURL": "https://github.com/hugehoge/Snappable.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "5b7ac7b5bbb0d7e83bfa6b7273f29247724df55e", 73 | "version": "0.2.0" 74 | } 75 | }, 76 | { 77 | "package": "Introspect", 78 | "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2", 82 | "version": "0.1.3" 83 | } 84 | } 85 | ] 86 | }, 87 | "version": 1 88 | } 89 | -------------------------------------------------------------------------------- /swiftui-flowcontroller-pattern.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // This header comment will be stripped by SwiftFormat. 8 | // 9 | 10 | 11 | --------------------------------------------------------------------------------