├── .github └── workflows │ └── test.yml ├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── Assets ├── architecture.jpg ├── problem.jpg └── scenario.jpg ├── Package.swift ├── Pexels ├── Pexels.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── Pexels-Production.xcscheme │ │ └── Pexels-Scenarios.xcscheme └── Sources │ ├── Common │ ├── Assets.xcassets │ │ ├── 1.imageset │ │ │ ├── 1.jpeg │ │ │ └── Contents.json │ │ ├── 10.imageset │ │ │ ├── 10.jpeg │ │ │ └── Contents.json │ │ ├── 11.imageset │ │ │ ├── 11.jpeg │ │ │ └── Contents.json │ │ ├── 12.imageset │ │ │ ├── 12.jpeg │ │ │ └── Contents.json │ │ ├── 13.imageset │ │ │ ├── 13.jpeg │ │ │ └── Contents.json │ │ ├── 14.imageset │ │ │ ├── 14.jpeg │ │ │ └── Contents.json │ │ ├── 15.imageset │ │ │ ├── 15.jpeg │ │ │ └── Contents.json │ │ ├── 2.imageset │ │ │ ├── 2.jpeg │ │ │ └── Contents.json │ │ ├── 3.imageset │ │ │ ├── 3.jpeg │ │ │ └── Contents.json │ │ ├── 4.imageset │ │ │ ├── 4.jpeg │ │ │ └── Contents.json │ │ ├── 5.imageset │ │ │ ├── 5.jpeg │ │ │ └── Contents.json │ │ ├── 6.imageset │ │ │ ├── 6.jpeg │ │ │ └── Contents.json │ │ ├── 7.imageset │ │ │ ├── 7.jpeg │ │ │ └── Contents.json │ │ ├── 8.imageset │ │ │ ├── 8.jpeg │ │ │ └── Contents.json │ │ ├── 9.imageset │ │ │ ├── 9.jpeg │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Models │ │ ├── ImageModel.swift │ │ └── ImagesModel.swift │ ├── Networking │ │ ├── ErrorDomain.swift │ │ ├── HTTPMethod.swift │ │ ├── NetworkClient.swift │ │ └── NetworkUtils.swift │ ├── Services │ │ ├── AuthenticationService.swift │ │ ├── Configuration.swift │ │ ├── FavouritesImageManager.swift │ │ └── PexelsImageService.swift │ ├── Utils │ │ ├── UserDefault.swift │ │ └── UserDefaults+Keys.swift │ └── Views │ │ ├── Authentication │ │ └── AuthenticationView.swift │ │ ├── Dashboard │ │ ├── DashboardView.swift │ │ └── DashboardViewModel.swift │ │ ├── Favourites │ │ ├── FavouritesView.swift │ │ └── FavouritesViewModel.swift │ │ ├── ImageView │ │ └── ImageView.swift │ │ ├── ImagesView │ │ ├── ImagesView.swift │ │ └── ImagesViewModel.swift │ │ └── StaggeredGrid │ │ └── StaggeredGrid.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Production │ ├── Media.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon-100.png │ │ │ ├── icon-1024.png │ │ │ ├── icon-120.png │ │ │ ├── icon-152.png │ │ │ ├── icon-167.png │ │ │ ├── icon-172.png │ │ │ ├── icon-180.png │ │ │ ├── icon-196.png │ │ │ ├── icon-20.png │ │ │ ├── icon-216.png │ │ │ ├── icon-29.png │ │ │ ├── icon-40.png │ │ │ ├── icon-48.png │ │ │ ├── icon-55.png │ │ │ ├── icon-58.png │ │ │ ├── icon-60.png │ │ │ ├── icon-76.png │ │ │ ├── icon-80.png │ │ │ ├── icon-87.png │ │ │ └── icon-88.png │ │ └── Contents.json │ └── ProductionApp.swift │ └── Scenarios │ ├── Media.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon-100.png │ │ ├── icon-1024.png │ │ ├── icon-120.png │ │ ├── icon-128.png │ │ ├── icon-152.png │ │ ├── icon-16.png │ │ ├── icon-167.png │ │ ├── icon-172.png │ │ ├── icon-180.png │ │ ├── icon-196.png │ │ ├── icon-20.png │ │ ├── icon-216.png │ │ ├── icon-256.png │ │ ├── icon-29.png │ │ ├── icon-32.png │ │ ├── icon-40.png │ │ ├── icon-48.png │ │ ├── icon-512.png │ │ ├── icon-55.png │ │ ├── icon-58.png │ │ ├── icon-60.png │ │ ├── icon-64.png │ │ ├── icon-76.png │ │ ├── icon-80.png │ │ ├── icon-87.png │ │ └── icon-88.png │ └── Contents.json │ ├── Mocks │ ├── Configuration+Mock.swift │ ├── ImageModel+Mock.swift │ ├── ImagesModel+Mock.swift │ ├── MockAuthenticationService.swift │ ├── MockFavouritesManager.swift │ ├── MockImageService.swift │ └── Resources │ │ └── search_response.json │ ├── Scenarios │ ├── Components │ │ ├── GridItemViewScenario.swift │ │ └── StaggeredGridScenario.swift │ ├── DesignSystem │ │ └── TypographyScenario.swift │ ├── EnvironmentScenario+App.swift │ ├── EnvironmentScenario.swift │ ├── Networking │ │ └── NetworkingScenario.swift │ ├── ScenarioKinds.swift │ └── Screens │ │ ├── FavouritesView │ │ └── FavouritesViewScenario.swift │ │ ├── ImageViewScenario.swift │ │ └── ImagesView │ │ └── ImagesViewScenario.swift │ ├── ScenariosApp.swift │ └── Utils │ └── GenericError.swift ├── README.md ├── Sample ├── Sample-Production copy-Info.plist ├── Sample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── Sample-Internal-SwiftUI.xcscheme │ │ ├── Sample-Internal.xcscheme │ │ ├── Sample-Production-SwiftUI.xcscheme │ │ └── Sample-Production.xcscheme ├── Sample │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── Colors │ │ │ │ ├── Contents.json │ │ │ │ └── TextFieldBackground.colorset │ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── gihub_logo.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── gihub_logo.png │ │ │ │ ├── gihub_logo@2x.png │ │ │ │ └── gihub_logo@3x.png │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ ├── Info-Internal-iOS11.plist │ │ ├── Info-Internal.plist │ │ ├── Info-Production.plist │ │ ├── Internal │ │ │ └── Media.xcassets │ │ │ │ └── AppIcon.appiconset │ │ │ │ ├── Contents.json │ │ │ │ ├── icon-100.png │ │ │ │ ├── icon-1024.png │ │ │ │ ├── icon-120.png │ │ │ │ ├── icon-128.png │ │ │ │ ├── icon-152.png │ │ │ │ ├── icon-16.png │ │ │ │ ├── icon-167.png │ │ │ │ ├── icon-172.png │ │ │ │ ├── icon-180.png │ │ │ │ ├── icon-196.png │ │ │ │ ├── icon-20.png │ │ │ │ ├── icon-216.png │ │ │ │ ├── icon-256.png │ │ │ │ ├── icon-29.png │ │ │ │ ├── icon-32.png │ │ │ │ ├── icon-40.png │ │ │ │ ├── icon-48.png │ │ │ │ ├── icon-512.png │ │ │ │ ├── icon-55.png │ │ │ │ ├── icon-58.png │ │ │ │ ├── icon-60.png │ │ │ │ ├── icon-64.png │ │ │ │ ├── icon-76.png │ │ │ │ ├── icon-80.png │ │ │ │ ├── icon-87.png │ │ │ │ └── icon-88.png │ │ ├── Production │ │ │ └── Media.xcassets │ │ │ │ ├── AppIcon.appiconset │ │ │ │ ├── Contents.json │ │ │ │ ├── icon-100.png │ │ │ │ ├── icon-1024.png │ │ │ │ ├── icon-120.png │ │ │ │ ├── icon-152.png │ │ │ │ ├── icon-167.png │ │ │ │ ├── icon-172.png │ │ │ │ ├── icon-180.png │ │ │ │ ├── icon-196.png │ │ │ │ ├── icon-20.png │ │ │ │ ├── icon-216.png │ │ │ │ ├── icon-29.png │ │ │ │ ├── icon-40.png │ │ │ │ ├── icon-48.png │ │ │ │ ├── icon-55.png │ │ │ │ ├── icon-58.png │ │ │ │ ├── icon-60.png │ │ │ │ ├── icon-76.png │ │ │ │ ├── icon-80.png │ │ │ │ ├── icon-87.png │ │ │ │ └── icon-88.png │ │ │ │ └── Contents.json │ │ ├── de.lproj │ │ │ ├── LaunchScreen.strings │ │ │ └── Localizable.strings │ │ └── en.lproj │ │ │ └── Localizable.strings │ └── Sources │ │ ├── Common │ │ ├── AppServices.swift │ │ ├── BaseAppDelegate.swift │ │ ├── Configuration.swift │ │ ├── Extensions │ │ │ ├── String+Localizable.swift │ │ │ └── UIApplication+EndEditing.swift │ │ ├── Features │ │ │ ├── DashboardView.swift │ │ │ ├── DocView.swift │ │ │ └── Github │ │ │ │ ├── Models │ │ │ │ ├── License.swift │ │ │ │ ├── Owner.swift │ │ │ │ ├── Repository.swift │ │ │ │ ├── RepositoryList.swift │ │ │ │ └── User.swift │ │ │ │ └── Views │ │ │ │ ├── Detail │ │ │ │ └── RepositoryDetailView.swift │ │ │ │ ├── List │ │ │ │ ├── RepositoryListView.swift │ │ │ │ ├── RepositoryListViewModel.swift │ │ │ │ └── RepositoryRowView.swift │ │ │ │ └── Login │ │ │ │ └── LoginView.swift │ │ ├── Networking │ │ │ ├── Constants.swift │ │ │ ├── ErrorDomain.swift │ │ │ ├── GithubService.swift │ │ │ ├── GithubURLMaker.swift │ │ │ ├── HTTPMethod.swift │ │ │ ├── NetworkClient.swift │ │ │ └── NetworkUtils.swift │ │ ├── Utils │ │ │ ├── Debouncer.swift │ │ │ ├── Paging.swift │ │ │ └── UserDefault.swift │ │ └── Views │ │ │ ├── ErrorView │ │ │ └── ErrorView.swift │ │ │ ├── LoadingView │ │ │ ├── LoadingRow.swift │ │ │ └── LoadingView.swift │ │ │ ├── NavigableWebView.swift │ │ │ ├── PrimaryButton.swift │ │ │ ├── RemoteImageLoading │ │ │ ├── RemoteImageContainer.swift │ │ │ └── RemoteImageContainerViewModel.swift │ │ │ ├── View+If.swift │ │ │ └── WebView.swift │ │ ├── Internal │ │ ├── Extensions │ │ │ └── UIView+Autolayout.swift │ │ ├── Mocks │ │ │ ├── MockNetworkClient.swift │ │ │ └── Resources │ │ │ │ └── response.json │ │ ├── Scenarios │ │ │ ├── DashboardScenarios │ │ │ │ └── DashboardViewScenario.swift │ │ │ ├── EnvironmentScenario+App.swift │ │ │ ├── EnvironmentScenario.swift │ │ │ ├── GithubScenarios │ │ │ │ ├── GithubRepositoryDetailScenario.swift │ │ │ │ ├── GithubRepositoryListScenario.swift │ │ │ │ └── Views │ │ │ │ │ ├── ErrorViewScenario.swift │ │ │ │ │ ├── LoadingViewScenario.swift │ │ │ │ │ ├── RemoteImageViewScenario.swift │ │ │ │ │ └── RepositoryRowViewScenario.swift │ │ │ ├── HomeScenarios │ │ │ │ └── HomeViewScenario.swift │ │ │ ├── HotReloading │ │ │ │ ├── HotReloadingSwiftUIScenario.swift │ │ │ │ └── HotReloadingUIKitScenario.swift │ │ │ ├── NetworkingScenario.swift │ │ │ ├── PrototypeScenario.swift │ │ │ ├── ScenarioKinds.swift │ │ │ ├── TypographyScenario.swift │ │ │ └── WebViewScenario.swift │ │ ├── SwiftUI │ │ │ └── InternalApp.swift │ │ └── UIKit │ │ │ └── InternalAppDelegate.swift │ │ └── Production │ │ ├── SwiftUI │ │ └── ProductionApp.swift │ │ └── UIKit │ │ └── ProductionAppDelegate.swift ├── UITests │ ├── Info.plist │ └── UITests.swift └── UnitTests │ ├── Info.plist │ └── UnitTests.swift ├── Scenarios.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── Scripts ├── docs.sh ├── git-format-staged └── swiftformat ├── Sources └── Scenarios │ ├── AppController │ ├── BaseScenarioSelectorAppController.swift │ ├── BaseSectionManager.swift │ ├── BasicAppController.swift │ ├── NavigationAppController.swift │ ├── ScenarioSelectorAppController.swift │ ├── ScenarioSelectorSplitAppController.swift │ ├── ScenariosAppController.swift │ └── UserInterfaceToogleableAppController.swift │ ├── ApplicationShortcutItem.swift │ ├── BaseScenariosManager.swift │ ├── Extensions │ └── Notification+Extensions.swift │ ├── RootViewProviding+SwiftUI.swift │ ├── RootViewProviding.swift │ ├── Scenario │ ├── Audience │ │ ├── Audience.swift │ │ └── AudienceTargetable.swift │ ├── Feature │ │ ├── FeatureConfigurationSelectorController.swift │ │ ├── FeatureContext.swift │ │ ├── FeatureScenario.swift │ │ └── ScenarioKind+Feature.swift │ ├── Scenario.swift │ ├── ScenarioId.swift │ └── ScenarioKind.swift │ ├── ScenariosManager.swift │ ├── UI │ ├── CollectionViewController.swift │ ├── ListViewController.swift │ └── UIViewController.swift │ └── Utils │ └── UserDefault.swift ├── Tests └── ScenariosTests │ └── ScenariosTests.swift ├── docs ├── ApplicationShortcutItem │ └── index.html ├── Audience │ └── index.html ├── AudienceTargetable │ └── index.html ├── AudienceTargetableScenario │ └── index.html ├── BasicAppController │ └── index.html ├── FeatureConfigurationSelectorController │ └── index.html ├── FeatureContext │ └── index.html ├── FeatureScenario │ └── index.html ├── IdentifiableType │ └── index.html ├── NavigationAppController │ └── index.html ├── Reloadable │ └── index.html ├── ReloadableHostingViewController │ └── index.html ├── ReloadableViewController │ └── index.html ├── RootViewProviding │ └── index.html ├── Scenario │ └── index.html ├── ScenarioAppController │ └── index.html ├── ScenarioCategory │ └── index.html ├── ScenarioId │ └── index.html ├── ScenarioInfo │ └── index.html ├── ScenarioKind │ └── index.html ├── ScenarioPlugin │ └── index.html ├── ScenariosManager │ └── index.html ├── Taggable │ └── index.html ├── TestScenario │ └── index.html ├── UserInterfaceToogleableNavigationAppController │ └── index.html ├── all.css └── index.html └── renovate.json /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test PR 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | name: Build and Test 11 | 12 | # See available software: https://github.com/actions/virtual-environments/blob/main/images/macos/macos-12-Readme.md 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - name: Cancel Previous Runs 17 | uses: styfle/cancel-workflow-action@0.12.1 18 | with: 19 | access_token: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 22 | # - name: Prepare Xcode 23 | # run: | 24 | # sudo xcode-select --switch /Applications/Xcode_13.4.1.app 25 | # xcodebuild -version 26 | # swift --version 27 | # xcrun simctl list devices 15.4 28 | 29 | - name: Boot simulator 30 | run: xcrun simctl boot "iPhone 15 Pro" 31 | 32 | - name: Run Internal tests 33 | run: xcodebuild clean build test -workspace Scenarios.xcworkspace -scheme "Sample-Internal" -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 34 | 35 | - name: Run Production tests 36 | run: xcodebuild clean build test -workspace Scenarios.xcworkspace -scheme "Sample-Production" -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 37 | 38 | - name: Run Internal (SwiftUI) tests 39 | run: xcodebuild clean build test -workspace Scenarios.xcworkspace -scheme "Sample-Internal" -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 40 | 41 | - name: Run Production (SwiftUI) tests 42 | run: xcodebuild clean build test -workspace Scenarios.xcworkspace -scheme "Sample-Production" -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm/ -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.2 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # Format options 2 | 3 | --trimwhitespace nonblank-lines 4 | --importgrouping testable-bottom 5 | --wraparguments before-first 6 | --header "\nCopyright © 2021 An Tran. All rights reserved.\n" 7 | --ifdef no-indent 8 | --disable redundantSelf 9 | 10 | # Rules 11 | 12 | --disable blankLinesAtStartOfScope 13 | --disable blankLinesAtEndOfScope 14 | --disable hoistPatternLet 15 | --disable unusedArguments 16 | --disable numberFormatting 17 | --disable redundantReturn -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Derived/* 3 | - AnSwift/Packages/Internal/Sources/Projects/SWAPI-GraphQL/Generated/* 4 | disabled_rules: 5 | - trailing_whitespace 6 | - trailing_comma 7 | - nesting 8 | - cyclomatic_complexity 9 | - file_length 10 | - todo 11 | - function_parameter_count 12 | - opening_brace 13 | - force_cast 14 | - force_try 15 | - identifier_name 16 | line_length: 17 | ignores_interpolated_strings: true 18 | ignores_comments: true 19 | warning: 180 20 | error: 200 21 | inclusive_language: 22 | override_allowed_terms: 23 | - masterKey 24 | type_name: 25 | min_length: 26 | error: 1 27 | warning: 1 28 | allowed_symbols: "_" 29 | -------------------------------------------------------------------------------- /Assets/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Assets/architecture.jpg -------------------------------------------------------------------------------- /Assets/problem.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Assets/problem.jpg -------------------------------------------------------------------------------- /Assets/scenario.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Assets/scenario.jpg -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Scenarios", 6 | platforms: [ 7 | .iOS(.v13), 8 | ], 9 | products: [ 10 | .library( 11 | name: "Scenarios", 12 | targets: ["Scenarios"] 13 | ), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "Scenarios", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "ScenariosTests", 23 | dependencies: ["Scenarios"] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Pexels/Pexels.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Pexels/Pexels.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/1.imageset/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/1.imageset/1.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "1.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/10.imageset/10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/10.imageset/10.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/10.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "10.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/11.imageset/11.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/11.imageset/11.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/11.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "11.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/12.imageset/12.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/12.imageset/12.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/12.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "12.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/13.imageset/13.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/13.imageset/13.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/13.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "13.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/14.imageset/14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/14.imageset/14.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/14.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "14.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/15.imageset/15.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/15.imageset/15.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/15.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "15.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/2.imageset/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/2.imageset/2.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "2.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/3.imageset/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/3.imageset/3.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "3.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/4.imageset/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/4.imageset/4.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "4.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/5.imageset/5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/5.imageset/5.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "5.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/6.imageset/6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/6.imageset/6.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "6.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/7.imageset/7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/7.imageset/7.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "7.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/8.imageset/8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/8.imageset/8.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/8.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "8.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/9.imageset/9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Common/Assets.xcassets/9.imageset/9.jpeg -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/9.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "9.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Models/ImageModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SDWebImageSwiftUI 6 | import SwiftUI 7 | 8 | struct ImageModel: Identifiable, Hashable, Codable { 9 | 10 | static func == (lhs: ImageModel, rhs: ImageModel) -> Bool { 11 | lhs.id == rhs.id 12 | } 13 | 14 | func hash(into hasher: inout Hasher) { 15 | hasher.combine(id) 16 | } 17 | 18 | var id = UUID().uuidString 19 | let thumbnail: ImageSource 20 | let image: ImageSource 21 | 22 | @ViewBuilder 23 | func imageview() -> some View { 24 | switch image { 25 | case .local(let name): 26 | Image(name) 27 | .resizable() 28 | .aspectRatio(contentMode: .fit) 29 | case .remote(let url): 30 | WebImage(url: url, options: [.progressiveLoad, .delayPlaceholder, .scaleDownLargeImages]) 31 | .resizable() 32 | .placeholder { 33 | Image(systemName: "wifi.slash") 34 | .font(.largeTitle) 35 | .foregroundColor(Color.secondary.opacity(0.5)) 36 | } 37 | .indicator { _, _ in 38 | RoundedRectangle(cornerRadius: 10) 39 | .fill(Color.secondary.opacity(0.2)) 40 | } 41 | .scaledToFit() 42 | } 43 | } 44 | } 45 | 46 | enum ImageSource: Codable, Equatable { 47 | case local(_ name: String) 48 | case remote(_ url: URL) 49 | 50 | enum CodingKeys: String, CodingKey { 51 | case local, remote 52 | } 53 | 54 | init(from decoder: Decoder) throws { 55 | let container = try decoder.container(keyedBy: CodingKeys.self) 56 | if let localName = try container.decodeIfPresent(String.self, forKey: .local) { 57 | self = .local(localName) 58 | } else { 59 | guard let remoteURL = try container.decodeIfPresent(URL.self, forKey: .remote) else { 60 | throw ImageSourceError.decodingError 61 | } 62 | self = .remote(remoteURL) 63 | } 64 | 65 | } 66 | 67 | func encode(to encoder: Encoder) throws { 68 | var container = encoder.container(keyedBy: CodingKeys.self) 69 | switch self { 70 | case .local(let value): 71 | try container.encode(value, forKey: .local) 72 | case .remote(let value): 73 | try container.encode(value, forKey: .remote) 74 | } 75 | } 76 | } 77 | 78 | enum ImageSourceError: Error { 79 | case decodingError 80 | } 81 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Models/ImagesModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct ImagesModel: Hashable, Codable { 8 | var images: [ImageModel] 9 | } 10 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Networking/ErrorDomain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum ErrorDomainDescription: String { 8 | // if request data has errors (i.e. json not valid) 9 | case networkRequestDomain = "NetworkRequest" 10 | // check if network response has an error (i.e. offline or the http status code not 200) 11 | case networkResponseDomain = "NetworkResponse" 12 | } 13 | 14 | public enum ErrorDomainCode: Int { 15 | case unexpectedResponseFromAPI = -1 16 | case missingDataResult = -2 17 | case parseError = -3 18 | case errorWhenCreateRequestObject = -4 19 | case encodeError = -5 20 | } 21 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Networking/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum HTTPMethod: String { 8 | case get = "GET" 9 | case post = "POST" 10 | case put = "PUT" 11 | case delete = "DELETE" 12 | } 13 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Networking/NetworkClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol NetworkClientProtocol { 8 | func perform(request: URLRequest, completion: @escaping (Result) -> Void) 9 | } 10 | 11 | // general class with protocol and error handling used for network communication 12 | final class NetworkClient: NetworkClientProtocol { 13 | 14 | private let session: URLSession 15 | 16 | init(urlSession: URLSession = URLSession(configuration: .default)) { 17 | session = urlSession 18 | } 19 | 20 | func perform(request: URLRequest, completion: @escaping (Result) -> Void) { 21 | let task = session.dataTask(with: request, completionHandler: { data, response, error in 22 | if let errorValue = error { 23 | completion(.failure(errorValue)) 24 | return 25 | } 26 | 27 | let httpStatus = response as? HTTPURLResponse 28 | 29 | // statusCode 200 -> OK, request was successful 30 | // statusCode 201 -> Created, request was successful, the requested resource was created by server 31 | // statusCode 204 -> No Content, request was successful, response does not contain data 32 | guard httpStatus?.statusCode == 200 || httpStatus?.statusCode == 201 || httpStatus?.statusCode == 204 else { 33 | let errorString = "unsuccessful request, http code: \(String(describing: httpStatus?.statusCode))" 34 | let error = NSError(domain: ErrorDomainDescription.networkResponseDomain.rawValue, code: httpStatus?.statusCode ?? ErrorDomainCode.unexpectedResponseFromAPI.rawValue, userInfo: [NSLocalizedDescriptionKey: errorString]) 35 | completion(.failure(error)) 36 | return 37 | } 38 | 39 | guard let data = data else { 40 | let errorString = "missing data result from request" 41 | let error = NSError(domain: ErrorDomainDescription.networkResponseDomain.rawValue, code: ErrorDomainCode.missingDataResult.rawValue, userInfo: [NSLocalizedDescriptionKey: errorString]) 42 | completion(.failure(error)) 43 | return 44 | } 45 | 46 | do { 47 | let decoder = JSONDecoder() 48 | let dataFromBackend = try decoder.decode(T.self, from: data) 49 | completion(.success(dataFromBackend)) 50 | } catch let parsingError { 51 | let error = NSError(domain: ErrorDomainDescription.networkResponseDomain.rawValue, code: ErrorDomainCode.parseError.rawValue, userInfo: [NSLocalizedDescriptionKey: parsingError.localizedDescription]) 52 | completion(.failure(error)) 53 | } 54 | }) 55 | 56 | task.resume() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Networking/NetworkUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | enum NetworkUtils { 8 | static func makeRequest(url: URL, httpMethod: HTTPMethod) -> URLRequest? { 9 | var request = URLRequest(url: url) 10 | request.httpMethod = httpMethod.rawValue 11 | return request 12 | } 13 | 14 | static func code(from error: Error) -> Int { 15 | let errorAsNsError = error as NSError 16 | return errorAsNsError.code 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Services/AuthenticationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | protocol AuthenticationServiceProtocol { 9 | var apiKey: CurrentValueSubject { get } 10 | func login(_ apiKey: String) 11 | func logout() 12 | } 13 | 14 | final class AuthenticationService: AuthenticationServiceProtocol { 15 | 16 | @UserDefault(UserDefaults.apiKeyKey, defaultValue: "") 17 | private var _privateAPIKey: String { 18 | didSet { 19 | apiKey.value = _privateAPIKey.isEmpty ? nil : _privateAPIKey 20 | } 21 | } 22 | 23 | var apiKey = CurrentValueSubject(nil) 24 | 25 | init() { 26 | apiKey.value = _privateAPIKey.isEmpty ? nil : _privateAPIKey 27 | } 28 | 29 | func login(_ apiKey: String) { 30 | _privateAPIKey = apiKey 31 | } 32 | 33 | func logout() { 34 | _privateAPIKey = "" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Services/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct Configuration { 8 | let name: String 9 | let authenticationService: AuthenticationServiceProtocol 10 | let pexelsImageService: ImageServiceProtocol 11 | let favouritesImageManager: FavouritesImageManaging 12 | } 13 | 14 | extension Configuration { 15 | static let production: Configuration = { 16 | let authenticationService = AuthenticationService() 17 | return Configuration( 18 | name: "Production", 19 | authenticationService: authenticationService, 20 | pexelsImageService: PexelsImageService(authenticationService: authenticationService), 21 | favouritesImageManager: FavouritesImageManager() 22 | ) 23 | }() 24 | } 25 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Services/FavouritesImageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | protocol FavouritesImageManaging: ImageServiceProtocol { 9 | func toggle(_ imageModel: ImageModel) 10 | func hasFavourited(_ imageModel: ImageModel) -> Bool 11 | } 12 | 13 | final class FavouritesImageManager: FavouritesImageManaging { 14 | 15 | @UserDefault(codableKey: UserDefaults.favouritesPhotosKey, defaultValue: ImagesModel(images: [])) 16 | private var _privateImages: ImagesModel 17 | 18 | func fetch() -> Future { 19 | Future { promise in 20 | return promise(.success(self._privateImages)) 21 | } 22 | } 23 | 24 | func toggle(_ imageModel: ImageModel) { 25 | if hasFavourited(imageModel) { 26 | _privateImages.images.removeAll(where: { $0.thumbnail == imageModel.thumbnail }) 27 | } else { 28 | _privateImages.images.append(imageModel) 29 | } 30 | } 31 | 32 | func hasFavourited(_ imageModel: ImageModel) -> Bool { 33 | _privateImages.images.first(where: { $0.thumbnail == imageModel.thumbnail }) != nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Utils/UserDefaults+Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension UserDefaults { 8 | static let apiKeyKey = "api_key" 9 | static let favouritesPhotosKey = "favourites_photos" 10 | } 11 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/Authentication/AuthenticationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct AuthenticationView: View { 8 | let authenticated: (String) -> Void 9 | 10 | @State var authKey: String = "" 11 | 12 | var body: some View { 13 | NavigationView { 14 | VStack { 15 | TextField("Pexels API Key", text: $authKey) 16 | .textFieldStyle(RoundedBorderTextFieldStyle()) 17 | Button { 18 | guard !authKey.isEmpty else { return } 19 | authenticated(authKey) 20 | } label: { 21 | Text("Login") 22 | } 23 | 24 | } 25 | .padding() 26 | .navigationTitle("Login") 27 | } 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/Dashboard/DashboardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct DashboardView: View { 8 | private let configuration: Configuration 9 | 10 | @StateObject var viewModel: DashboardViewModel 11 | 12 | init(configuration: Configuration = .production) { 13 | self.configuration = configuration 14 | _viewModel = StateObject(wrappedValue: DashboardViewModel(authenticationService: configuration.authenticationService)) 15 | } 16 | 17 | var body: some View { 18 | if viewModel.isLoggedIn { 19 | TabView { 20 | ImagesView( 21 | authenticationService: configuration.authenticationService, 22 | pexelsImageService: configuration.pexelsImageService, 23 | favouritesImageService: configuration.favouritesImageManager 24 | ) 25 | .tabItem { 26 | Image(systemName: "photo") 27 | Text("Photos") 28 | } 29 | FavouritesView(service: configuration.favouritesImageManager) 30 | .tabItem { 31 | Image(systemName: "heart.fill") 32 | Text("Favourites") 33 | } 34 | } 35 | } else { 36 | AuthenticationView { 37 | configuration.authenticationService.login($0) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/Dashboard/DashboardViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | final class DashboardViewModel: ObservableObject { 9 | 10 | @Published var isLoggedIn: Bool = false 11 | 12 | var connection: AnyCancellable? 13 | 14 | private let authenticationService: AuthenticationServiceProtocol 15 | 16 | init(authenticationService: AuthenticationServiceProtocol) { 17 | self.authenticationService = authenticationService 18 | connection = authenticationService.apiKey.sink(receiveValue: { [weak self] apiKey in 19 | self?.isLoggedIn = apiKey != nil 20 | }) 21 | } 22 | 23 | func logout() { 24 | authenticationService.logout() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/Favourites/FavouritesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | struct FavouritesView: View { 9 | @StateObject private var viewModel: FavouritesViewModel 10 | 11 | init(service: FavouritesImageManaging = FavouritesImageManager()) { 12 | _viewModel = StateObject(wrappedValue: FavouritesViewModel(service: service)) 13 | } 14 | 15 | var body: some View { 16 | NavigationView { 17 | StaggeredGrid(columns: 2, list: viewModel.images.images) { image in 18 | NavigationLink(destination: ImageView(image: image, favouritesImageManager: viewModel.service)) { 19 | GridItemView(image: image) 20 | } 21 | } 22 | .padding(.horizontal) 23 | .navigationTitle("Favourites") 24 | } 25 | .onAppear { 26 | viewModel.fetchImages() 27 | } 28 | } 29 | } 30 | 31 | struct FavouritesView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | FavouritesView() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/Favourites/FavouritesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | final class FavouritesViewModel: ObservableObject { 9 | @Published var images: ImagesModel = .init(images: []) 10 | 11 | let service: FavouritesImageManaging 12 | 13 | private var subscription: AnyCancellable? 14 | 15 | init(service: FavouritesImageManaging) { 16 | self.service = service 17 | } 18 | 19 | func fetchImages() { 20 | subscription?.cancel() 21 | subscription = service.fetch().sink( 22 | receiveCompletion: { completion in 23 | if case let .failure(error) = completion { 24 | print(error) 25 | } 26 | }, 27 | receiveValue: { [weak self] imageListModel in 28 | DispatchQueue.main.async { 29 | self?.images = imageListModel 30 | } 31 | } 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/ImageView/ImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ImageView: View { 8 | let image: ImageModel 9 | let favouritesImageManager: FavouritesImageManaging 10 | 11 | var body: some View { 12 | VStack { 13 | image.imageview() 14 | 15 | } 16 | .toolbar { 17 | ToolbarItem(placement: .navigationBarTrailing) { 18 | Button { 19 | favouritesImageManager.toggle(image) 20 | } label: { 21 | Image(systemName: favouritesImageManager.hasFavourited(image) ? "heart.fill" : "heart") 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/ImagesView/ImagesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | enum ViewState { 9 | case loading 10 | case loaded(value: T) 11 | case failed(error: Swift.Error) 12 | } 13 | 14 | extension ViewState: Equatable { 15 | static func == (lhs: Self, rhs: Self) -> Bool { 16 | switch (lhs, rhs) { 17 | case (.loading, .loading): 18 | return true 19 | case (.loaded(let leftValue), .loaded(let rightValue)): 20 | return leftValue == rightValue 21 | case (.failed(let leftError as NSError), .failed(let rightError as NSError)): 22 | return leftError == rightError 23 | default: 24 | return false 25 | } 26 | } 27 | } 28 | 29 | final class ImagesViewModel: ObservableObject { 30 | 31 | @Published var viewState: ViewState = .loading 32 | 33 | private let service: ImageServiceProtocol 34 | 35 | private var subscription: AnyCancellable? 36 | 37 | private var hasFetchedOnAppear: Bool = false 38 | 39 | init(service: ImageServiceProtocol) { 40 | self.service = service 41 | } 42 | 43 | func fetchImagesOnAppear() { 44 | guard !hasFetchedOnAppear else { return } 45 | hasFetchedOnAppear = true 46 | fetchImages() 47 | } 48 | 49 | func fetchImages() { 50 | subscription?.cancel() 51 | subscription = service.fetch().sink( 52 | receiveCompletion: { [weak self] completion in 53 | if case let .failure(error) = completion { 54 | DispatchQueue.main.async { 55 | self?.viewState = .failed(error: error) 56 | } 57 | } 58 | }, 59 | receiveValue: { [weak self] imagesModel in 60 | DispatchQueue.main.async { 61 | self?.viewState = .loaded(value: imagesModel) 62 | } 63 | } 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Pexels/Sources/Common/Views/StaggeredGrid/StaggeredGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // Largely inspired by https://kavsoft.dev/ 8 | struct StaggeredGrid: View where T: Hashable { 9 | 10 | private let content: (T) -> Content 11 | private let list: [T] 12 | private let columns: Int 13 | private let showsIndicators: Bool 14 | private let spacing: CGFloat 15 | 16 | init(columns: Int = 1, 17 | showsIndeicators: Bool = false, 18 | spacing: CGFloat = 10, 19 | list: [T], 20 | @ViewBuilder content: @escaping (T) -> Content) 21 | { 22 | self.content = content 23 | self.list = list 24 | self.spacing = spacing 25 | self.showsIndicators = showsIndeicators 26 | self.columns = columns 27 | } 28 | 29 | func setup() -> [[T]] { 30 | 31 | var gridArray: [[T]] = Array(repeating: [], count: columns) 32 | 33 | var currentIndex = 0 34 | 35 | for object in list { 36 | gridArray[currentIndex].append(object) 37 | if currentIndex == (columns - 1) { 38 | currentIndex = 0 39 | } else { 40 | currentIndex += 1 41 | } 42 | } 43 | 44 | return gridArray 45 | } 46 | 47 | var body: some View { 48 | VStack { 49 | ScrollView(.vertical, showsIndicators: showsIndicators) { 50 | HStack(alignment: .top) { 51 | ForEach(setup(), id: \.self) { columnsData in 52 | LazyVStack(spacing: spacing) { 53 | ForEach(columnsData) { object in 54 | content(object) 55 | } 56 | } 57 | } 58 | } 59 | .padding(.vertical) 60 | } 61 | } 62 | } 63 | } 64 | 65 | struct StaggeredGrid_Previews: PreviewProvider { 66 | static var previews: some View { 67 | let images = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map { 68 | ImageModel(thumbnail: .local(String($0)), image: .local(String($0))) 69 | } 70 | StaggeredGrid(columns: 3, list: images) { image in 71 | GridItemView(image: image) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Pexels/Sources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-100.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-1024.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-120.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-152.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-167.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-172.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-180.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-196.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-20.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-216.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-29.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-48.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-55.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-58.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-60.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-80.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-87.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Production/Media.xcassets/AppIcon.appiconset/icon-88.png -------------------------------------------------------------------------------- /Pexels/Sources/Production/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pexels/Sources/Production/ProductionApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | @main 8 | struct ProductionApp: App { 9 | var body: some Scene { 10 | WindowGroup { 11 | DashboardView() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-100.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-1024.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-120.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-128.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-152.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-16.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-167.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-172.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-180.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-196.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-20.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-216.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-256.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-29.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-32.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-48.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-512.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-55.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-58.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-60.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-64.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-80.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-87.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Pexels/Sources/Scenarios/Media.xcassets/AppIcon.appiconset/icon-88.png -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/Configuration+Mock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension Configuration { 8 | static let mocking = Configuration( 9 | name: "Mock", 10 | authenticationService: MockAuthenticationService(), 11 | pexelsImageService: MockImageService(), 12 | favouritesImageManager: MockFavouritesManager() 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/ImageModel+Mock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension ImageModel { 8 | static let mock = ImageModel( 9 | thumbnail: .local("1"), 10 | image: .local("1") 11 | ) 12 | 13 | static let failure = ImageModel( 14 | thumbnail: .remote(URL(string: "https://google.com/fakeimage.png")!), 15 | image: .remote(URL(string: "https://google.com/fakeimage.png")!) 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/ImagesModel+Mock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension ImagesModel { 8 | static let mock: ImagesModel = .init( 9 | images: Array(1 ... 10).map { 10 | ImageModel(thumbnail: .local(String($0)), image: .local(String($0))) 11 | } 12 | ) 13 | 14 | static let mixed: ImagesModel = .init( 15 | images: Array(1 ... 10).map { 16 | return ($0 % 2) == 0 ? ImageModel(thumbnail: .local(String($0)), image: .local(String($0))) : .failure 17 | } 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/MockAuthenticationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | final class MockAuthenticationService: AuthenticationServiceProtocol { 9 | let apiKey = CurrentValueSubject(nil) 10 | 11 | init(apiKey: String? = nil) { 12 | self.apiKey.value = apiKey 13 | } 14 | 15 | func login(_ apiKey: String) { 16 | self.apiKey.value = apiKey 17 | } 18 | 19 | func logout() { 20 | self.apiKey.value = nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/MockFavouritesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | 7 | final class MockFavouritesManager: FavouritesImageManaging { 8 | func hasFavourited(_ imageModel: ImageModel) -> Bool { 9 | false 10 | } 11 | 12 | func fetch() -> Future { 13 | Future { promise in 14 | return promise(.success(ImagesModel.mock)) 15 | } 16 | } 17 | 18 | func toggle(_ imageModel: ImageModel) {} 19 | } 20 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/MockImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | final class MockImageService: ImageServiceProtocol { 9 | 10 | var imagesModel: ImagesModel 11 | var error: Error? 12 | var delay: TimeInterval? 13 | 14 | init(_ imagesModel: ImagesModel = .mock, error: Error? = nil, delay: TimeInterval? = nil) { 15 | self.imagesModel = imagesModel 16 | self.error = error 17 | self.delay = delay 18 | } 19 | 20 | func fetch() -> Future { 21 | Future { promise in 22 | if let error = self.error { 23 | return promise(.failure(error)) 24 | } 25 | 26 | if let delay = self.delay { 27 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 28 | promise(.success(self.imagesModel)) 29 | } 30 | return 31 | } 32 | 33 | return promise(.success(self.imagesModel)) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Mocks/Resources/search_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_results": 10000, 3 | "page": 1, 4 | "per_page": 1, 5 | "photos": [ 6 | { 7 | "id": 3573351, 8 | "width": 3066, 9 | "height": 3968, 10 | "url": "https://www.pexels.com/photo/trees-during-day-3573351/", 11 | "photographer": "Lukas Rodriguez", 12 | "photographer_url": "https://www.pexels.com/@lukas-rodriguez-1845331", 13 | "photographer_id": 1845331, 14 | "avg_color": "#374824", 15 | "src": { 16 | "original": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png", 17 | "large2x": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940", 18 | "large": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&h=650&w=940", 19 | "medium": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&h=350", 20 | "small": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&h=130", 21 | "portrait": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800", 22 | "landscape": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200", 23 | "tiny": "https://images.pexels.com/photos/3573351/pexels-photo-3573351.png?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280" 24 | }, 25 | "liked": false, 26 | "alt": "Brown Rocks During Golden Hour" 27 | } 28 | ], 29 | "next_page": "https://api.pexels.com/v1/search/?page=2&per_page=1&query=nature" 30 | } 31 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/Components/GridItemViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | final class GridItemViewHappyCaseScenario: Scenario { 9 | static var name: String = "Happy Case" 10 | static var kind: ScenarioKind = .component 11 | static var category: ScenarioCategory? = "GridItemView" 12 | 13 | static var rootViewProvider: RootViewProviding { 14 | NavigationAppController(withResetButton: true) { _ in 15 | UIHostingController(rootView: GridItemView(image: ImageModel.mock).frame(width: 200, height: 300)) 16 | } 17 | } 18 | } 19 | 20 | final class GridItemViewErrorCaseScenario: Scenario { 21 | static var name: String = "Error Case" 22 | static var kind: ScenarioKind = .component 23 | static var category: ScenarioCategory? = "GridItemView" 24 | 25 | static var rootViewProvider: RootViewProviding { 26 | NavigationAppController(withResetButton: true) { _ in 27 | UIHostingController( 28 | rootView: GridItemView( 29 | image: .failure 30 | ) 31 | .frame(width: 200, height: 300) 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/Components/StaggeredGridScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | final class StaggeredGridScenario: Scenario { 9 | static var name: String = "StaggeredGrid" 10 | static var kind: ScenarioKind = .component 11 | 12 | static var rootViewProvider: RootViewProviding { 13 | NavigationAppController(withResetButton: true) { _ in 14 | UIHostingController(rootView: ContentView()) 15 | } 16 | } 17 | } 18 | 19 | private struct ContentView: View { 20 | var body: some View { 21 | StaggeredGrid(columns: 3, list: ImagesModel.mock.images) { image in 22 | GridItemView(image: image) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/DesignSystem/TypographyScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | public class TypographyScenario: Scenario { 10 | public static let name = "Typography" 11 | public static let kind = ScenarioKind.designSystem 12 | 13 | public static var rootViewProvider: RootViewProviding { 14 | UserInterfaceToogleableNavigationAppController(withResetButton: true) { _ in 15 | ReloadableHostingViewController(rootView: ContentView()) 16 | } 17 | } 18 | } 19 | 20 | private struct ContentView: View, Reloadable { 21 | 22 | @State var count: Int = 0 23 | 24 | var body: some View { 25 | VStack(alignment: .leading, spacing: 16) { 26 | Group { 27 | Text("This is large title") 28 | .font(.largeTitle) 29 | Text("This is title") 30 | .font(.title) 31 | Text("This is title2") 32 | .font(.title2) 33 | Text("This is title3") 34 | .font(.title3) 35 | Text("This is headline") 36 | .font(.headline) 37 | } 38 | 39 | Group { 40 | Text("This is body") 41 | .font(.body) 42 | Text("This is callout") 43 | .font(.callout) 44 | Text("This is footnote") 45 | .font(.footnote) 46 | Text("This is caption") 47 | .font(.caption) 48 | Text("This is caption2") 49 | .font(.caption2) 50 | } 51 | 52 | Spacer() 53 | } 54 | .padding() 55 | .navigationTitle("Dark/Light Mode") 56 | } 57 | 58 | func reload() { 59 | count += 1 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/EnvironmentScenario+App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | final class MockingEnvrionmentScenario: EnvironmentScenario { 8 | static var name: String = "Mocking" 9 | static var configuration: Configuration = .mocking 10 | } 11 | 12 | final class ProductionEnvrionmentScenario: EnvironmentScenario { 13 | static var name: String = "Production" 14 | static var configuration: Configuration = .production 15 | } 16 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/EnvironmentScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | protocol EnvironmentScenario: Scenario { 9 | static var configuration: Configuration { get } 10 | } 11 | 12 | extension EnvironmentScenario { 13 | static var kind: ScenarioKind { .environment } 14 | 15 | static var shortDescription: String? { 16 | configuration.name 17 | } 18 | 19 | static var rootViewProvider: RootViewProviding { 20 | configuration.authenticationService.logout() 21 | let view = UIHostingController(rootView: DashboardView(configuration: configuration)) 22 | return BasicAppController(rootViewController: view) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/ScenarioKinds.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | 8 | extension ScenarioKind { 9 | @objc static let environment = ScenarioKind(rawValue: "Environment", nameForSorting: "1") 10 | @objc static let screen = ScenarioKind(rawValue: "Screen", nameForSorting: "2") 11 | @objc static let component = ScenarioKind(rawValue: "Component", nameForSorting: "3") 12 | @objc static let designSystem = ScenarioKind(rawValue: "Design System", nameForSorting: "3") 13 | @objc static let networking = ScenarioKind(rawValue: "Networking", nameForSorting: "4") 14 | @objc static let prototype = ScenarioKind(rawValue: "Prototype", nameForSorting: "5") 15 | } 16 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/Screens/FavouritesView/FavouritesViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | final class FavouritesViewScenario: Scenario { 9 | static var name: String = "FavourtiesView" 10 | static var kind: ScenarioKind = .screen 11 | 12 | static var rootViewProvider: RootViewProviding { 13 | return BasicAppController( 14 | rootViewController: UIHostingController( 15 | rootView: FavouritesView(service: MockFavouritesManager()) 16 | ) 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Scenarios/Screens/ImageViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | final class ImageViewScenario: Scenario { 9 | static var name: String = "ImageView" 10 | static var kind: ScenarioKind = .screen 11 | 12 | static var rootViewProvider: RootViewProviding { 13 | NavigationAppController(withResetButton: true) { _ in 14 | UIHostingController( 15 | rootView: ImageView(image: .mock, favouritesImageManager: MockFavouritesManager()) 16 | ) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/ScenariosApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | import UIKit 8 | 9 | @main 10 | struct ScenariosApp: App { 11 | 12 | @UIApplicationDelegateAdaptor(InternalAppDelegate.self) var appDelegate 13 | @Environment(\.scenePhase) var scenePhase 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | SwiftUIViewProvider(appDelegate.manager.appController) 18 | } 19 | .onChange(of: scenePhase) { scenePhase in 20 | switch scenePhase { 21 | case .active: 22 | guard let shortcutItem = appDelegate.shortcutItem else { return } 23 | _ = appDelegate.manager.performAction(for: shortcutItem) 24 | default: return 25 | } 26 | } 27 | } 28 | } 29 | 30 | final class InternalAppDelegate: NSObject, UIApplicationDelegate { 31 | 32 | fileprivate let manager = ScenariosManager() 33 | 34 | var shortcutItem: UIApplicationShortcutItem? { InternalAppDelegate.shortcutItem } 35 | 36 | fileprivate static var shortcutItem: UIApplicationShortcutItem? 37 | 38 | func application( 39 | _ application: UIApplication, 40 | configurationForConnecting connectingSceneSession: UISceneSession, 41 | options: UIScene.ConnectionOptions 42 | ) -> UISceneConfiguration { 43 | if let shortcutItem = options.shortcutItem { 44 | InternalAppDelegate.shortcutItem = shortcutItem 45 | } 46 | 47 | let sceneConfiguration = UISceneConfiguration( 48 | name: "Scene Configuration", 49 | sessionRole: connectingSceneSession.role 50 | ) 51 | sceneConfiguration.delegateClass = SceneDelegate.self 52 | 53 | return sceneConfiguration 54 | } 55 | } 56 | 57 | private final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 58 | func windowScene( 59 | _ windowScene: UIWindowScene, 60 | performActionFor shortcutItem: UIApplicationShortcutItem, 61 | completionHandler: @escaping (Bool) -> Void 62 | ) { 63 | InternalAppDelegate.shortcutItem = shortcutItem 64 | completionHandler(true) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Pexels/Sources/Scenarios/Utils/GenericError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct GenericError: Error, LocalizedError { 8 | let description: String 9 | 10 | var errorDescription: String? { 11 | description 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scenarios 2 | 3 | Scenarios provides an infrastructure for fast prototyping and feature development for iOS Projects without breaking production apps 4 | 5 | # Video presentation 6 | 7 | [Watch the presentation about the framework at iOSConf 2022](https://www.youtube.com/watch?v=gbqC_67W_tg) 8 | 9 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/gbqC_67W_tg/0.jpg)](https://www.youtube.com/watch?v=gbqC_67W_tg) 10 | 11 | # Introduction 12 | 13 | ## Challenges of mobile frontend development 14 | 15 | - Stories with multiple requirements. 16 | - Multiple stakeholders (backend devs, designers, QAs, PMs, SMs, Testers, CTO, CEO ….). 17 | - Multiple environments, configurations. 18 | - Working on multiple features in parallel. 19 | - Demonstrating multiple states for UI components. 20 | - Mobile app deployment is complicated. 21 | - Continuous delivery. 22 | 23 | ![problem](Assets/problem.jpg) 24 | 25 | ## Scenario-driven development 26 | 27 | - Scenarios is a system supporting continuously delivering of incremental updates for mobile app frontends. 28 | - Targeting early feedback loop from all stakeholders. 29 | - Avoiding the need to deliver multiple apps for different purposes. 30 | - Easing parallelism between feature teams. 31 | - Supporting automated tests. 32 | - Extensible, new types of scenarios can be created to accommodate different stakeholders: prototype scenario, design system scenario, accessibility scenario, etc ... 33 | 34 | ![scenario](Assets/scenario.jpg) 35 | 36 | ## Recommended modular architecture 37 | 38 | ![architecture](Assets/architecture.jpg) 39 | 40 | ## Sample app 41 | 42 | There is a sample app inside this repository. The app fetches the list of popular Swift repositories from Github and display them in a UITableView. 43 | 44 | The app will contain all scenarios for each of the components, as well as a mocking and a production environment scenarios. 45 | 46 | https://user-images.githubusercontent.com/478757/136145086-85e43b43-9479-432a-b308-67533b51adad.mp4 47 | 48 | 49 | # Getting Started 50 | 51 | Please check out the Sample project. 52 | 53 | # Acknowledgement 54 | 55 | The original idea comes from the team working on [the NHS COVID-19 App](https://apps.apple.com/gb/app/nhs-covid-19/id1520427663) 56 | 57 | The original source code is taken from the iOS source code of the [NHS Covid-19 App](https://github.com/nihp-public/covid-19-app-ios-ag-public) 58 | 59 | # License 60 | 61 | MIT 62 | -------------------------------------------------------------------------------- /Sample/Sample-Production copy-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/Colors/TextFieldBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.965", 9 | "green" : "0.949", 10 | "red" : "0.945" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "gihub_logo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "gihub_logo@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "gihub_logo@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo@2x.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Assets.xcassets/gihub_logo.imageset/gihub_logo@3x.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Info-Internal-iOS11.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Info-Internal.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Info-Production.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-100.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-1024.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-120.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-128.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-152.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-16.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-167.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-172.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-180.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-196.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-20.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-216.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-256.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-29.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-32.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-48.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-512.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-55.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-58.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-60.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-64.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-80.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-87.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Internal/Media.xcassets/AppIcon.appiconset/icon-88.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-100.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-1024.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-120.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-152.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-167.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-172.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-180.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-196.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-20.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-216.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-29.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-48.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-55.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-58.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-60.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-80.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-87.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Sample/Sample/Resources/Production/Media.xcassets/AppIcon.appiconset/icon-88.png -------------------------------------------------------------------------------- /Sample/Sample/Resources/Production/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/de.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/de.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "txt_no_repo_desc" = "Keine Beschreibung"; 2 | "txt_number_forks" = "Anzahl Forks:"; 3 | "txt_number_watchers" = "Anzahl Watchers:"; 4 | "search_bar_hint" = "GitHub durchsuchen"; 5 | "title_git_repos" = "GitHub Swift Repos"; 6 | "btn_txt_login_to_git" = "Login GitHub"; 7 | "title_github_login" = "GitHub Login"; 8 | "btn_txt_forks" = "Repo Forks"; 9 | "txt_license" = "Lizenz"; 10 | "btn_txt_open_github" = "Auf GitHub ansehen"; 11 | "txt_fetching_more" = "Lade mehr Einträge..."; 12 | "txt_loading_repos" = "Laden"; 13 | "txt_loading_forks" = "Lade forks"; 14 | "txt_error_load_repos" = "Error beim Laden der Einträge"; 15 | "txt_error_load_forks" = "Error beim Laden der forks für diese Repo"; 16 | "txt_no_results_for_search" = "Keine Ergebnisse für die Suche"; 17 | "title_github_login" = "GitHub Login"; 18 | "btn_txt_review_app_access" = "Auf GitHub ansehen"; 19 | "title_github_access" = "GitHub Verbindung"; 20 | "title_repo_detail" = "GitHub Repo Detail"; 21 | "txt_location" = "Location"; 22 | "txt_company" = "Unternehmen"; 23 | "txt_login_to_github" = "Logge dich ein um nicht in Rate Limits von GitHub reinzulaufen."; 24 | -------------------------------------------------------------------------------- /Sample/Sample/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "txt_no_repo_desc" = "Missing Repository description"; 2 | "txt_number_forks" = "Number Forks:"; 3 | "txt_number_watchers" = "Number Watchers:"; 4 | "search_bar_hint" = "Search GitHub"; 5 | "title_git_repos" = "GitHub Swift Repos"; 6 | "btn_txt_login_to_git" = "Login GitHub"; 7 | "title_github_login" = "GitHub Login"; 8 | "btn_txt_forks" = "Repo Forks"; 9 | "txt_license" = "License"; 10 | "btn_txt_open_github" = "Open on Github"; 11 | "txt_fetching_more" = "Fetching more..."; 12 | "txt_loading_repos" = "Loading repos"; 13 | "txt_loading_forks" = "Loading repo forks"; 14 | "txt_error_load_repos" = "Error loading repos"; 15 | "txt_error_load_forks" = "Error loading forks for repo"; 16 | "txt_no_results_for_search" = "No results for your current search"; 17 | "title_github_login" = "GitHub Login"; 18 | "btn_txt_review_app_access" = "Review app access"; 19 | "title_github_access" = "GitHub Connection"; 20 | "title_repo_detail" = "GitHub Repo Detail"; 21 | "txt_location" = "Location"; 22 | "txt_company" = "Company"; 23 | "txt_login_to_github" = "Authenticate yourself by login to GitHub to avoid running in rate limits when browsing GitHub with this app."; 24 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/AppServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | @available(iOS 13.0, *) 8 | final class AppServices: ObservableObject { 9 | let docURL: URL 10 | let githubService: GithubService 11 | 12 | init(docURL: URL, githubService: GithubService) { 13 | self.docURL = docURL 14 | self.githubService = githubService 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/BaseAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | class BaseAppDelegate: UIResponder, UIApplicationDelegate { 8 | 9 | var window: UIWindow? 10 | 11 | func makeRootViewController() -> UIViewController { 12 | preconditionFailure("Must be overriden by subclasses") 13 | } 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | 17 | let window = UIWindow(frame: UIScreen.main.bounds) 18 | window.rootViewController = makeRootViewController() 19 | window.makeKeyAndVisible() 20 | self.window = window 21 | 22 | return true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct Configuration { 8 | let docsURL: URL 9 | let networkClient: NetworkClientProtocol 10 | } 11 | 12 | extension Configuration { 13 | static let production = Configuration( 14 | docsURL: URL(string: "https://antranapp.github.io/Scenarios/")!, 15 | networkClient: NetworkClient() 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Extensions/String+Localizable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension String { 8 | // write an extension to read string from Localizable file 9 | // better readability (or maybe a little bit more beautiful) instead of the direct usage of 'NSLocalizedString' 10 | static func localizedString(forKey key: String) -> String { 11 | return NSLocalizedString(key, comment: "") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Extensions/UIApplication+EndEditing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | extension UIApplication { 9 | func endEditing() { 10 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/DashboardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | struct DashboardView: View { 9 | 10 | @EnvironmentObject var appServices: AppServices 11 | 12 | var body: some View { 13 | TabView { 14 | RepositoryListView(viewModel: RepositoryListViewModel(githubService: appServices.githubService)) 15 | .tabItem { 16 | Label("Github", systemImage: "folder.circle") 17 | } 18 | 19 | DocView(url: appServices.docURL) 20 | .tabItem { 21 | Label("Docs", systemImage: "book.circle") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/DocView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct DocView: View { 8 | var url: URL 9 | 10 | var body: some View { 11 | NavigationView { 12 | NavigableWebView(url: url) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Models/License.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct License: Codable { 8 | let name: String 9 | let licenseUrl: URL? 10 | 11 | private enum CodingKeys: String, CodingKey { 12 | case name 13 | case licenseUrl = "url" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Models/Owner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct Owner: Codable { 8 | let avatarImageUrl: URL? 9 | let loginName: String 10 | 11 | private enum CodingKeys: String, CodingKey { 12 | case avatarImageUrl = "avatar_url" 13 | case loginName = "login" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Models/Repository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct Repository: Codable { 8 | var id: Double 9 | let repoName: String 10 | 11 | let owner: Owner? 12 | let numberOfForks: Int? 13 | let numberOfWatchers: Int? 14 | let repoDescription: String? 15 | let forksUrl: URL? 16 | let htmlUrl: URL 17 | let license: License? 18 | 19 | // use CodingKeys to not depend on the naming of the api 20 | private enum CodingKeys: String, CodingKey { 21 | case id 22 | case owner 23 | case repoName = "name" 24 | case repoDescription = "description" 25 | case forksUrl = "forks_url" 26 | case numberOfForks = "forks_count" 27 | case numberOfWatchers = "watchers_count" 28 | case htmlUrl = "html_url" 29 | case license 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Models/RepositoryList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct RepositoryList: Codable { 8 | let listItems: [Repository] 9 | 10 | private enum CodingKeys: String, CodingKey { 11 | case listItems = "items" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct User: Codable { 8 | let loginUsername: String 9 | let avatarUrl: URL 10 | let githubPageHtml: URL 11 | let repos_url: URL? 12 | let name: String 13 | let company: String? 14 | let location: String? 15 | 16 | private enum CodingKeys: String, CodingKey { 17 | case loginUsername = "login" 18 | case avatarUrl = "avatar_url" 19 | case githubPageHtml = "html_url" 20 | case repos_url 21 | case name 22 | case company 23 | case location 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Views/Detail/RepositoryDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct RepositoryDetailView: View { 8 | var repository: Repository 9 | 10 | private let heightButtons: CGFloat = 45 11 | 12 | var body: some View { 13 | VStack(spacing: 32) { 14 | RemoteImageContainer(url: repository.owner?.avatarImageUrl, width: 100, height: 100) 15 | 16 | Text(repository.repoName) 17 | .bold() 18 | .font(.title) 19 | 20 | VStack(spacing: 16) { 21 | if repository.repoDescription != nil { 22 | Text(repository.repoDescription!) 23 | .font(.subheadline) 24 | .foregroundColor(.secondary) 25 | } 26 | 27 | if repository.license?.name != nil { 28 | HStack { 29 | Text(String.localizedString(forKey: "txt_license")) 30 | Text(repository.license!.name) 31 | } 32 | } 33 | } 34 | 35 | NavigationLink(destination: NavigableWebView(title: repository.repoName, url: repository.htmlUrl)) { 36 | PrimaryButton( 37 | imageName: nil, 38 | buttonText: Text("btn_txt_open_github"), 39 | height: 45 40 | ) 41 | } 42 | 43 | Spacer() 44 | } 45 | .padding() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Views/List/RepositoryListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | import SwiftUI 8 | 9 | @available(iOS 13.0, *) 10 | final class RepositoryListViewModel: ObservableObject { 11 | 12 | private let githubService: GithubService 13 | private var paging = Paging() 14 | 15 | var searchDebounce = Debouncer(0.6) 16 | 17 | @Published var repos = [Repository]() 18 | @Published var errorWhenLoadingRepos: Error? 19 | @Published var isLoading: Bool = false 20 | 21 | init(githubService: GithubService) { 22 | self.githubService = githubService 23 | 24 | searchDebounce.on { [weak self] query in 25 | self?.paging.resetPage() 26 | self?.fetchResults(for: query, isSearching: true) 27 | } 28 | } 29 | 30 | func fetchRepos(for query: String?) { 31 | guard isLoading == false else { return } 32 | 33 | isLoading = true 34 | 35 | guard let url = GithubURLMaker.fetchRepositoryURL(for: query, page: paging.pageToFetch) else { 36 | // TODO: create error object and show up info 37 | return 38 | } 39 | 40 | githubService.fetch(url: url) { [weak self] (result: Result) in 41 | DispatchQueue.main.async { 42 | self?.isLoading = false 43 | switch result { 44 | case .success(let listGithubRepos): 45 | let listItems = listGithubRepos.listItems 46 | 47 | if self?.paging.pageToFetch == 1 { // first page 48 | self?.repos = listItems 49 | } else { // subsequent pages 50 | self?.repos.append(contentsOf: listItems) 51 | } 52 | self?.errorWhenLoadingRepos = nil 53 | self?.paging.updatePageToLoad(numberItemsLoaded: listItems.count) 54 | case .failure(let error): 55 | self?.errorWhenLoadingRepos = error 56 | print("error: \(error)") 57 | } 58 | } 59 | } 60 | } 61 | 62 | func fetchResults(for query: String?, isSearching: Bool) { 63 | query?.isEmpty == true ? fetchRepos(for: nil) : fetchRepos(for: query) 64 | } 65 | 66 | func moreItemsToLoad() -> Bool { 67 | return paging.moreItemsToLoad(numberItemsLoaded: repos.count) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Views/List/RepositoryRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct RepositoryRowView: View { 8 | 9 | let repository: Repository 10 | 11 | var body: some View { 12 | HStack { 13 | RemoteImageContainer(url: repository.owner!.avatarImageUrl) 14 | 15 | VStack(alignment: .leading, spacing: 5) { 16 | Text(repository.repoName) 17 | .font(.headline) 18 | 19 | Text("\(String.localizedString(forKey: "txt_number_forks")) \(repository.numberOfForks ?? 0)") 20 | .font(.subheadline) 21 | .foregroundColor(Color.gray) 22 | 23 | Text("\(String.localizedString(forKey: "txt_number_watchers")) \(repository.numberOfWatchers ?? 0)") 24 | .font(.subheadline) 25 | .foregroundColor(Color.gray) 26 | } 27 | 28 | Spacer() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Features/Github/Views/Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LoginView: View { 8 | 9 | @State private var accessToken: String 10 | 11 | init() { 12 | if let accessToken = UserDefaults.standard.object(forKey: Constants.accessTokenKey) as? String { 13 | _accessToken = State(initialValue: accessToken) 14 | } else { 15 | _accessToken = State(initialValue: "") 16 | } 17 | } 18 | 19 | var body: some View { 20 | VStack { 21 | Text("title_github_login") 22 | .font(.title) 23 | .padding() 24 | 25 | Text("txt_login_to_github") 26 | .font(.subheadline) 27 | .padding() 28 | 29 | Button(action: { 30 | print("Should open Github") 31 | }) { 32 | Text("Open Github") 33 | } 34 | 35 | TextField("Access Token", text: $accessToken) 36 | .textFieldStyle(RoundedBorderTextFieldStyle()) 37 | .padding() 38 | 39 | Button(action: { 40 | print("Should save: \(accessToken)") 41 | UserDefaults.standard.setValue(accessToken, forKey: Constants.accessTokenKey) 42 | print("AccessToken saved successfully.") 43 | }) { 44 | Text("Save") 45 | } 46 | 47 | Spacer() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | enum Constants { 8 | static let accessTokenKey = "AccessTokenKey" 9 | } 10 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/ErrorDomain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum ErrorDomainDescription: String { 8 | // if request data has errors (i.e. json not valid) 9 | case networkRequestDomain = "NetworkRequest" 10 | // check if network response has an error (i.e. offline or the http status code not 200) 11 | case networkResponseDomain = "NetworkResponse" 12 | } 13 | 14 | public enum ErrorDomainCode: Int { 15 | case unexpectedResponseFromAPI = -1 16 | case missingDataResult = -2 17 | case parseError = -3 18 | case errorWhenCreateRequestObject = -4 19 | case encodeError = -5 20 | } 21 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/GithubService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | final class GithubService { 8 | 9 | private var client: NetworkClientProtocol 10 | 11 | var numberRetriesLoadRepos = 0 12 | let maxNumberRetriesLoadRepos = 3 13 | 14 | init(client: NetworkClientProtocol = NetworkClient()) { 15 | self.client = client 16 | } 17 | 18 | func fetch(url: URL, completion: @escaping (Result) -> Void) { 19 | guard var request = NetworkUtils.makeRequest(url: url, httpMethod: .get) else { 20 | completion(.failure(GithubServiceError.invalidRequest)) 21 | return 22 | } 23 | 24 | if let accessToken = UserDefaults.standard.object(forKey: Constants.accessTokenKey) as? String { 25 | request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 26 | } 27 | 28 | client.perform(request: request) { (result: Result) in 29 | switch result { 30 | case .success(let successResult): 31 | self.numberRetriesLoadRepos = 0 32 | completion(.success(successResult)) 33 | case .failure(let errorValue): 34 | if self.retry(error: errorValue, url: url, completion: completion) { 35 | completion(.failure(errorValue)) 36 | } 37 | } 38 | } 39 | } 40 | 41 | func retry(error: Error, url: URL, completion: @escaping (Result) -> Void) -> Bool { 42 | let notAuthorizeError = NetworkUtils.code(from: error) 43 | 44 | guard numberRetriesLoadRepos < maxNumberRetriesLoadRepos, notAuthorizeError == 401 || notAuthorizeError == 403 else { 45 | // github throws another error (not that user is unauthorized) 46 | // or max retries reached 47 | // this error we handle normally 48 | return true 49 | } 50 | 51 | // github throws error that user logged out 52 | // delete persisted credentials and try again to fetch repos without credentials header 53 | UserDefaults.standard.removeObject(forKey: Constants.accessTokenKey) 54 | numberRetriesLoadRepos += 1 55 | fetch(url: url, completion: completion) 56 | return false 57 | } 58 | } 59 | 60 | enum GithubServiceError: Error, LocalizedError { 61 | case invalidRequest 62 | case notAuthorizeError 63 | } 64 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/GithubURLMaker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | enum GithubURLMaker { 8 | 9 | static func fetchRepositoryURL(for query: String?, page: Int) -> URL? { 10 | guard let queryString = query, !queryString.isEmpty else { 11 | return URL(string: "https://api.github.com/search/repositories?q=language:swift+sort:stars&page=\(page)") 12 | } 13 | 14 | guard let urlQuery = queryString.trimmingCharacters(in: .whitespacesAndNewlines).addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) else { 15 | print("error when creating query string") 16 | return nil 17 | } 18 | 19 | return URL(string: "https://api.github.com/search/repositories?q=\(urlQuery)+sort:stars&page=\(page)") 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum HTTPMethod: String { 8 | case get = "GET" 9 | case post = "POST" 10 | case put = "PUT" 11 | case delete = "DELETE" 12 | } 13 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Networking/NetworkUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | enum NetworkUtils { 8 | static func makeRequest(url: URL, httpMethod: HTTPMethod) -> URLRequest? { 9 | var request = URLRequest(url: url) 10 | request.httpMethod = httpMethod.rawValue 11 | return request 12 | } 13 | 14 | static func code(from error: Error) -> Int { 15 | let errorAsNsError = error as NSError 16 | return errorAsNsError.code 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Utils/Debouncer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | // Debouncer written instead of throtteling like here described: https://medium.com/@soxjke/property-wrappers-in-swift-5-1-297ae08fc7a0 8 | // when using throtteling, if typing can’t fit in x seconds interval, two requests are fired to Github API and intermediate search results blink on screen 9 | // to fix this behaviour, this can be done with debounce 10 | class Debouncer { 11 | private(set) var value: T? 12 | private var valueTimestamp = Date() 13 | private var interval: TimeInterval 14 | private var queue: DispatchQueue 15 | private var callbacks: [(T) -> Void] = [] 16 | private var debounceWorkItem = DispatchWorkItem {} 17 | 18 | public init(_ interval: TimeInterval, on queue: DispatchQueue = .main) { 19 | self.interval = interval 20 | self.queue = queue 21 | } 22 | 23 | func receive(_ value: T) { 24 | self.value = value 25 | dispatchDebounce() 26 | } 27 | 28 | func on(throttled: @escaping (T) -> Void) { 29 | self.callbacks.append(throttled) 30 | } 31 | 32 | private func dispatchDebounce() { 33 | self.valueTimestamp = Date() 34 | self.debounceWorkItem.cancel() 35 | 36 | self.debounceWorkItem = DispatchWorkItem { [weak self] in 37 | self?.onDebounce() 38 | } 39 | 40 | queue.asyncAfter(deadline: .now() + interval, execute: debounceWorkItem) 41 | } 42 | 43 | private func onDebounce() { 44 | guard Date().timeIntervalSince(self.valueTimestamp) > interval else { return } 45 | sendValue() 46 | } 47 | 48 | private func sendValue() { 49 | guard let value = self.value else { return } 50 | callbacks.forEach { $0(value) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Utils/Paging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | struct Paging { 8 | var pageToFetch: Int = 1 9 | 10 | private let maxPagesToLoad = 5 11 | 12 | // items per page returned by GitHub 13 | private let numberOfItemsPerPage = 30 14 | 15 | mutating func updatePageToLoad(numberItemsLoaded: Int) { 16 | guard numberItemsLoaded > 0 else { return } 17 | pageToFetch += 1 18 | } 19 | 20 | mutating func resetPage() { 21 | pageToFetch = 1 22 | } 23 | 24 | func moreItemsToLoad(numberItemsLoaded: Int) -> Bool { 25 | return numberItemsLoaded >= numberOfItemsPerPage && pageToFetch <= maxPagesToLoad 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/ErrorView/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ErrorView: View { 8 | 9 | var errorText: String 10 | var multipleLines: Bool = false 11 | 12 | var body: some View { 13 | VStack { 14 | Text(self.errorText) 15 | .if(multipleLines, transform: { view in 16 | view.lineLimit(nil) 17 | }) 18 | .if(!multipleLines, transform: { view in 19 | view.lineLimit(1) 20 | }) 21 | .font(.headline) 22 | .padding() 23 | .frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity, alignment: .leading) 24 | .foregroundColor(Color.white) 25 | .background(Color.red) 26 | .animation(.easeIn) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/LoadingView/LoadingRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LoadingRow: View { 8 | 9 | var loadingText: String 10 | 11 | var body: some View { 12 | HStack { 13 | LoadingView(isLoading: true, activityIndicatorStyle: .medium).padding(.init(top: 0, leading: 10, bottom: 0, trailing: 0)) 14 | Text(loadingText) 15 | .font(.headline) 16 | .padding(.init(top: 0, leading: 10, bottom: 0, trailing: 10)) 17 | .foregroundColor(Color.blue) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/LoadingView/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LoadingView: UIViewRepresentable { 8 | 9 | var isLoading: Bool 10 | var activityIndicatorStyle: UIActivityIndicatorView.Style 11 | 12 | func makeUIView(context: Context) -> UIActivityIndicatorView { 13 | let indicator = UIActivityIndicatorView(frame: .zero) 14 | indicator.style = activityIndicatorStyle 15 | indicator.hidesWhenStopped = true 16 | return indicator 17 | } 18 | 19 | func updateUIView(_ view: UIActivityIndicatorView, context: Context) { 20 | if self.isLoading { 21 | view.startAnimating() 22 | } else { 23 | view.stopAnimating() 24 | } 25 | } 26 | } 27 | 28 | #if DEBUG 29 | struct LoadingView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | LoadingView(isLoading: true, activityIndicatorStyle: .large) 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/NavigableWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct NavigableWebView: View { 8 | 9 | @StateObject var webViewStore = WebViewStore() 10 | 11 | var title: String? 12 | var url: URL 13 | 14 | var body: some View { 15 | WebView(webView: webViewStore.webView) 16 | .navigationBarTitle(Text(verbatim: title ?? webViewStore.title ?? ""), displayMode: .inline) 17 | .navigationBarItems( 18 | trailing: HStack { 19 | Button(action: goBack) { 20 | Image(systemName: "chevron.left") 21 | .imageScale(.large) 22 | .aspectRatio(contentMode: .fit) 23 | .frame(width: 32, height: 32) 24 | }.disabled(!webViewStore.canGoBack) 25 | Button(action: goForward) { 26 | Image(systemName: "chevron.right") 27 | .imageScale(.large) 28 | .aspectRatio(contentMode: .fit) 29 | .frame(width: 32, height: 32) 30 | }.disabled(!webViewStore.canGoForward) 31 | } 32 | ) 33 | .onAppear { 34 | webViewStore.webView.load(URLRequest(url: url)) 35 | } 36 | } 37 | 38 | func goBack() { 39 | webViewStore.webView.goBack() 40 | } 41 | 42 | func goForward() { 43 | webViewStore.webView.goForward() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/PrimaryButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // TODO: Change to ButtonStyle 8 | struct PrimaryButton: View { 9 | let imageName: String? 10 | let buttonText: Text 11 | let height: CGFloat 12 | 13 | var body: some View { 14 | HStack { 15 | if imageName != nil { 16 | Image(systemName: imageName!).foregroundColor(.white) 17 | } 18 | buttonText.foregroundColor(.white) 19 | }.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity, minHeight: height, idealHeight: height, maxHeight: height, alignment: .center) 20 | .background(Color.accentColor) 21 | .cornerRadius(5) 22 | .padding() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/RemoteImageLoading/RemoteImageContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | import SwiftUI 8 | import UIKit 9 | 10 | struct RemoteImageContainer: View { 11 | @ObservedObject var viewModel: RemoteImageContainerViewModel 12 | 13 | var imageWidth: CGFloat 14 | var imageHeight: CGFloat 15 | 16 | init(url: URL?, width: CGFloat = 50, height: CGFloat = 50) { 17 | imageWidth = width 18 | imageHeight = height 19 | viewModel = RemoteImageContainerViewModel(url: url) 20 | } 21 | 22 | var body: some View { 23 | if viewModel.error { 24 | Image(systemName: "xmark") 25 | .resizable() 26 | .aspectRatio(contentMode: .fit) 27 | .frame(width: imageWidth, height: imageHeight) 28 | } else { 29 | if viewModel.imageData.isEmpty { 30 | Image(systemName: "arrow.clockwise") 31 | .resizable() 32 | .aspectRatio(contentMode: .fit) 33 | .frame(width: imageWidth, height: imageHeight) 34 | } else { 35 | if let image = UIImage(data: viewModel.imageData) { 36 | Image(uiImage: image) 37 | .resizable() 38 | .aspectRatio(contentMode: .fit) 39 | .frame(width: imageWidth, height: imageHeight) 40 | } else { 41 | Image(systemName: "xmark") 42 | .resizable() 43 | .aspectRatio(contentMode: .fit) 44 | .frame(width: imageWidth, height: imageHeight) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/RemoteImageLoading/RemoteImageContainerViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | class RemoteImageContainerViewModel: ObservableObject { 9 | 10 | @Published var imageData = Data() 11 | @Published var error: Bool = false 12 | 13 | init(url: URL?) { 14 | 15 | guard let url = url else { return } 16 | 17 | URLSession.shared.dataTask(with: url) { data, response, error in 18 | if error != nil { 19 | DispatchQueue.main.async { 20 | self.error = true 21 | } 22 | } 23 | guard let dataValue = data else { 24 | DispatchQueue.main.async { 25 | self.error = true 26 | } 27 | return 28 | } 29 | 30 | DispatchQueue.main.async { 31 | self.imageData = dataValue 32 | } 33 | }.resume() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/View+If.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Applies the given transform if the given condition evaluates to `true`. 10 | /// - Parameters: 11 | /// - condition: The condition to evaluate. 12 | /// - transform: The transform to apply to the source `View`. 13 | /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. 14 | @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { 15 | if condition { 16 | transform(self) 17 | } else { 18 | self 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Common/Views/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import SwiftUI 7 | import WebKit 8 | 9 | @dynamicMemberLookup 10 | public class WebViewStore: ObservableObject { 11 | @Published public var webView: WKWebView { 12 | didSet { 13 | setupObservers() 14 | } 15 | } 16 | 17 | public init(webView: WKWebView = WKWebView()) { 18 | self.webView = webView 19 | setupObservers() 20 | } 21 | 22 | private func setupObservers() { 23 | func subscriber(for keyPath: KeyPath) -> NSKeyValueObservation { 24 | return webView.observe(keyPath, options: [.prior]) { _, change in 25 | if change.isPrior { 26 | self.objectWillChange.send() 27 | } 28 | } 29 | } 30 | // Setup observers for all KVO compliant properties 31 | observers = [ 32 | subscriber(for: \.title), 33 | subscriber(for: \.url), 34 | subscriber(for: \.isLoading), 35 | subscriber(for: \.estimatedProgress), 36 | subscriber(for: \.hasOnlySecureContent), 37 | subscriber(for: \.serverTrust), 38 | subscriber(for: \.canGoBack), 39 | subscriber(for: \.canGoForward), 40 | ] 41 | } 42 | 43 | private var observers: [NSKeyValueObservation] = [] 44 | 45 | public subscript(dynamicMember keyPath: KeyPath) -> T { 46 | webView[keyPath: keyPath] 47 | } 48 | } 49 | 50 | /// A container for using a WKWebView in SwiftUI 51 | public struct WebView: View, UIViewRepresentable { 52 | /// The WKWebView to display 53 | public let webView: WKWebView 54 | 55 | public init(webView: WKWebView) { 56 | self.webView = webView 57 | } 58 | 59 | public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { 60 | webView 61 | } 62 | 63 | public func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext) {} 64 | } 65 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Extensions/UIView+Autolayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import UIKit 7 | 8 | extension UIView { 9 | func addAutolayoutSubview(_ subview: UIView) { 10 | subview.translatesAutoresizingMaskIntoConstraints = false 11 | addSubview(subview) 12 | } 13 | 14 | func addFillingSubview(_ subview: UIView, inset: CGFloat = 0) { 15 | addAutolayoutSubview(subview) 16 | NSLayoutConstraint.activate([ 17 | subview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 18 | subview.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 19 | subview.topAnchor.constraint(equalTo: topAnchor, constant: inset), 20 | subview.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), 21 | ]) 22 | } 23 | 24 | func addCenterSubview(_ subview: UIView) { 25 | addAutolayoutSubview(subview) 26 | 27 | NSLayoutConstraint.activate([ 28 | subview.centerXAnchor.constraint(equalTo: centerXAnchor), 29 | subview.centerYAnchor.constraint(equalTo: centerYAnchor), 30 | ]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Mocks/MockNetworkClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | final class MockNetworkClient: NetworkClientProtocol { 8 | 9 | func perform(request: URLRequest, completion: @escaping (Result) -> Void) where T: Decodable { 10 | guard let url = Bundle.main.url(forResource: "response", withExtension: "json") else { 11 | completion(.failure(MockNetworkError.fileNotFound)) 12 | return 13 | } 14 | 15 | do { 16 | let data = try Data(contentsOf: url) 17 | let decoder = JSONDecoder() 18 | let list = try decoder.decode(T.self, from: data) 19 | completion(.success(list)) 20 | } catch { 21 | completion(.failure(error)) 22 | } 23 | } 24 | } 25 | 26 | enum MockNetworkError: Error { 27 | case fileNotFound 28 | } 29 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/DashboardScenarios/DashboardViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class DashboardViewScenario: Scenario { 10 | 11 | static var name: String = "Dashboard" 12 | 13 | static var kind: ScenarioKind = .screen 14 | 15 | static var rootViewProvider: RootViewProviding { 16 | let appServices = AppServices( 17 | docURL: Configuration.production.docsURL, 18 | githubService: GithubService(client: Configuration.production.networkClient) 19 | ) 20 | let dashboardView = DashboardView().environmentObject(appServices) 21 | return BasicAppController(rootViewController: UIHostingController(rootView: dashboardView)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/EnvironmentScenario+App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | final class MockingEnvrionmentScenario: EnvironmentScenario { 8 | static var name: String = "Mocking" 9 | static var configuration: Configuration = .mocking 10 | } 11 | 12 | final class ProductionEnvrionmentScenario: EnvironmentScenario { 13 | static var name: String = "Production" 14 | static var configuration: Configuration = .production 15 | } 16 | 17 | private extension Configuration { 18 | static let mocking = Configuration( 19 | docsURL: URL(string: "https://en.wikipedia.org/wiki/Scenario_(computing)")!, 20 | networkClient: MockNetworkClient() 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/EnvironmentScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | 8 | protocol EnvironmentScenario: Scenario { 9 | static var configuration: Configuration { get } 10 | } 11 | 12 | extension EnvironmentScenario { 13 | static var kind: ScenarioKind { .environment } 14 | 15 | static var shortDescription: String? { 16 | longDescription 17 | } 18 | 19 | static var longDescription: String? { 20 | configuration.docsURL.absoluteString 21 | } 22 | 23 | static var rootViewProvider: RootViewProviding { 24 | let appServices = AppServices( 25 | docURL: configuration.docsURL, 26 | githubService: GithubService(client: configuration.networkClient) 27 | ) 28 | let view = UIHostingController(rootView: DashboardView().environmentObject(appServices)) 29 | return BasicAppController(rootViewController: view) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/GithubScenarios/GithubRepositoryDetailScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class GithubRepositoryDetailScenario: Scenario { 10 | static var name: String = "Detail" 11 | static var kind: ScenarioKind = .screen 12 | static var category: ScenarioCategory? = "Github" 13 | 14 | static var rootViewProvider: RootViewProviding { 15 | let detailView = RepositoryDetailView(repository: .alamofire) 16 | return NavigationAppController(withResetButton: true) { _ in 17 | UIHostingController(rootView: detailView) 18 | } 19 | } 20 | } 21 | 22 | private extension Repository { 23 | static let alamofire = Repository( 24 | id: 7774181, 25 | repoName: "Alamofire", 26 | owner: Owner( 27 | avatarImageUrl: URL(string: "https://avatars.githubusercontent.com/u/7774181?v=4"), 28 | loginName: "Alamofire" 29 | ), 30 | numberOfForks: 6762, 31 | numberOfWatchers: 35965, 32 | repoDescription: "Elegant HTTP Networking in Swift", 33 | forksUrl: URL(string: "https://api.github.com/repos/Alamofire/Alamofire/forks"), 34 | htmlUrl: URL(string: "https://github.com/Alamofire/Alamofire")!, 35 | license: License( 36 | name: "MIT License", 37 | licenseUrl: URL(string: "https://api.github.com/licenses/mit") 38 | ) 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/GithubScenarios/GithubRepositoryListScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class GithubRepositoryListScenario: Scenario { 10 | static var name: String = "List" 11 | static var kind: ScenarioKind = .screen 12 | static var category: ScenarioCategory? = "Github" 13 | 14 | static var rootViewProvider: RootViewProviding { 15 | let service = GithubService() 16 | let githubView = RepositoryListView(viewModel: RepositoryListViewModel(githubService: service)) 17 | return NavigationAppController(withResetButton: true) { _ in 18 | UIHostingController(rootView: githubView) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/GithubScenarios/Views/ErrorViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class ErrorViewScenario: Scenario { 10 | static var name: String = "Error" 11 | static var kind: ScenarioKind = .component 12 | 13 | static var rootViewProvider: RootViewProviding { 14 | NavigationAppController(withResetButton: true) { _ in 15 | UIHostingController(rootView: ContentView()) 16 | } 17 | } 18 | } 19 | 20 | private struct ContentView: View { 21 | var body: some View { 22 | VStack(spacing: 32) { 23 | VStack { 24 | ErrorView(errorText: "This is a short error message") 25 | Text("Short") 26 | } 27 | 28 | Divider() 29 | 30 | ErrorView(errorText: "This is a very long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long error message") 31 | Text("Long, single line") 32 | 33 | Divider() 34 | 35 | ErrorView(errorText: "This is a very long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long error message", multipleLines: true) 36 | Text("Long, multiple lines") 37 | } 38 | .navigationTitle("Error View") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/GithubScenarios/Views/LoadingViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class LoadingViewScenario: Scenario { 10 | static var name: String = "Loading" 11 | static var kind: ScenarioKind = .component 12 | 13 | static var rootViewProvider: RootViewProviding { 14 | NavigationAppController(withResetButton: true) { _ in 15 | UIHostingController(rootView: ContentView()) 16 | } 17 | } 18 | } 19 | 20 | private struct ContentView: View { 21 | var body: some View { 22 | VStack(spacing: 32) { 23 | VStack { 24 | LoadingView(isLoading: true, activityIndicatorStyle: .large) 25 | Text("loading: true, style: large") 26 | } 27 | 28 | Divider() 29 | 30 | VStack { 31 | LoadingView(isLoading: true, activityIndicatorStyle: .medium) 32 | Text("loading: true, style: medium") 33 | } 34 | 35 | Divider() 36 | 37 | VStack { 38 | LoadingView(isLoading: false, activityIndicatorStyle: .medium) 39 | Text("loading: false, style: medium") 40 | } 41 | } 42 | .navigationTitle("Loading View") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/GithubScenarios/Views/RemoteImageViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class RemoteImageViewHappyCaseDefaultSzieScenario: Scenario { 10 | static var name: String = "Happy Case - Default Size" 11 | static var kind: ScenarioKind = .component 12 | static var category: ScenarioCategory? = "RemoteImage" 13 | 14 | static var rootViewProvider: RootViewProviding { 15 | NavigationAppController(withResetButton: true) { _ in 16 | UIHostingController(rootView: HappyCaseDefaultSizeView()) 17 | } 18 | } 19 | } 20 | 21 | private struct HappyCaseDefaultSizeView: View { 22 | let correctURL = URL(string: "https://avatars.githubusercontent.com/u/484656?v=4")! 23 | var body: some View { 24 | VStack { 25 | RemoteImageContainer(url: correctURL) 26 | Text("Happy Case - Default size") 27 | } 28 | .navigationTitle("Happy Case - Default Size") 29 | } 30 | } 31 | 32 | final class RemoteImageViewHappyCaseCustomSizeScenario: Scenario { 33 | static var name: String = "Happy Case - Custom Size" 34 | static var kind: ScenarioKind = .component 35 | static var category: ScenarioCategory? = "RemoteImage" 36 | 37 | static var rootViewProvider: RootViewProviding { 38 | NavigationAppController(withResetButton: true) { _ in 39 | UIHostingController(rootView: HappyCaseCustomSizeView()) 40 | } 41 | } 42 | } 43 | 44 | private struct HappyCaseCustomSizeView: View { 45 | let correctURL = URL(string: "https://avatars.githubusercontent.com/u/484656?v=4")! 46 | var body: some View { 47 | VStack { 48 | RemoteImageContainer(url: correctURL, width: 200, height: 200) 49 | Text("Happy Case - Size 200 x 200") 50 | } 51 | .navigationTitle("Happy Case - Custom size") 52 | } 53 | } 54 | 55 | final class RemoteImageViewErrorCaseCustomSizeScenario: Scenario { 56 | static var name: String = "Error Case" 57 | static var kind: ScenarioKind = .component 58 | static var category: ScenarioCategory? = "RemoteImage" 59 | 60 | static var rootViewProvider: RootViewProviding { 61 | NavigationAppController(withResetButton: true) { _ in 62 | UIHostingController(rootView: UnhappyCaseView()) 63 | } 64 | } 65 | } 66 | 67 | private struct UnhappyCaseView: View { 68 | let wrongURL = URL(string: "https://invalid_url.png")! 69 | var body: some View { 70 | VStack { 71 | RemoteImageContainer(url: wrongURL) 72 | Text("Error Case") 73 | } 74 | .navigationTitle("Error Case") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/HomeScenarios/HomeViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class HomeViewScenario: Scenario { 10 | static var name: String = "Docs" 11 | static var kind: ScenarioKind = .screen 12 | static var rootViewProvider: RootViewProviding { 13 | NavigationAppController(withResetButton: true) { _ in 14 | UIHostingController(rootView: DocView(url: Configuration.production.docsURL)) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/HotReloading/HotReloadingSwiftUIScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2022 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Inject 7 | import Scenarios 8 | import SwiftUI 9 | 10 | public final class HotReloadingSwiftUIScenario: Scenario { 11 | public static var name: String = "SwiftUI" 12 | public static var kind: ScenarioKind = .prototype 13 | public static var category: ScenarioCategory? = "Hot Reloading" 14 | 15 | public static var rootViewProvider: RootViewProviding { 16 | BasicAppController( 17 | rootViewController: UIHostingController(rootView: ContentView()) 18 | ) 19 | } 20 | } 21 | 22 | private struct ContentView: View { 23 | 24 | @ObserveInjection var inject 25 | 26 | var body: some View { 27 | VStack { 28 | Text("SwiftUI: Hot Reloading is cool") 29 | .foregroundColor(Color.blue) 30 | Text("Find more at https://antran.app") 31 | } 32 | .background(Color.red) 33 | .enableInjection() 34 | .onInjection { _ in 35 | NotificationCenter.default.post(name: .refreshScenario, object: nil) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/HotReloading/HotReloadingUIKitScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2022 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Inject 7 | import Scenarios 8 | import SwiftUI 9 | 10 | public final class HotReloadingUIKitScenario: Scenario { 11 | public static var name: String = "UIKit" 12 | public static var kind: ScenarioKind = .prototype 13 | public static var category: ScenarioCategory? = "Hot Reloading" 14 | 15 | public static var rootViewProvider: RootViewProviding { 16 | BasicAppController( 17 | rootViewController: Inject.ViewControllerHost(ViewController()) 18 | ) 19 | } 20 | } 21 | 22 | private class ViewController: UIViewController { 23 | init() { 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 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | 35 | view.backgroundColor = .systemBackground 36 | 37 | let label = UILabel() 38 | label.text = "UIKit: Hot Reloading" 39 | label.textAlignment = .center 40 | 41 | let stackView = UIStackView(arrangedSubviews: [label]) 42 | stackView.axis = .vertical 43 | 44 | view.addFillingSubview(stackView) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/PrototypeScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import UIKit 8 | 9 | final class PrototypeScenario: Scenario { 10 | static var name: String = "Prototype" 11 | static var kind: ScenarioKind = .prototype 12 | 13 | static var rootViewProvider: RootViewProviding { 14 | NavigationAppController(withResetButton: true) { _ in 15 | ViewController() 16 | } 17 | } 18 | } 19 | 20 | private final class ViewController: UIViewController { 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | view.backgroundColor = .white 24 | 25 | let label = UILabel() 26 | label.text = "Prototype" 27 | 28 | view.addCenterSubview(label) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/ScenarioKinds.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | 8 | extension ScenarioKind { 9 | @objc static let environment = ScenarioKind(rawValue: "Environment", nameForSorting: "1") 10 | @objc static let screen = ScenarioKind(rawValue: "Screen", nameForSorting: "2") 11 | @objc static let component = ScenarioKind(rawValue: "Component", nameForSorting: "3") 12 | @objc static let designSystem = ScenarioKind(rawValue: "Design System", nameForSorting: "3") 13 | @objc static let networking = ScenarioKind(rawValue: "Networking", nameForSorting: "4") 14 | @objc static let prototype = ScenarioKind(rawValue: "Prototype", nameForSorting: "5") 15 | } 16 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/TypographyScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | public class TypographyScenario: AudienceTargetableScenario { 10 | public static let name = "Typography" 11 | public static let kind = ScenarioKind.designSystem 12 | 13 | public static var rootViewProvider: RootViewProviding { 14 | UserInterfaceToogleableNavigationAppController(withResetButton: true) { _ in 15 | ReloadableHostingViewController(rootView: ContentView()) 16 | } 17 | } 18 | } 19 | 20 | private struct ContentView: View, Reloadable { 21 | 22 | @State var count: Int = 0 23 | 24 | var body: some View { 25 | VStack(alignment: .leading, spacing: 16) { 26 | Group { 27 | Text("This is large title") 28 | .font(.largeTitle) 29 | Text("This is title") 30 | .font(.title) 31 | Text("This is title2") 32 | .font(.title2) 33 | Text("This is title3") 34 | .font(.title3) 35 | Text("This is headline") 36 | .font(.headline) 37 | } 38 | 39 | Group { 40 | Text("This is body") 41 | .font(.body) 42 | Text("This is callout") 43 | .font(.callout) 44 | Text("This is footnote") 45 | .font(.footnote) 46 | Text("This is caption") 47 | .font(.caption) 48 | Text("This is caption2") 49 | .font(.caption2) 50 | } 51 | 52 | Spacer() 53 | } 54 | .padding() 55 | .navigationTitle("Dark/Light Mode") 56 | } 57 | 58 | func reload() { 59 | count += 1 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/Scenarios/WebViewScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Scenarios 7 | import SwiftUI 8 | 9 | final class WebViewHardCodeTitleScenario: Scenario { 10 | static var name: String = "Hard-coded title" 11 | static var kind: ScenarioKind = .component 12 | static var category: ScenarioCategory? = "WebView" 13 | 14 | static var rootViewProvider: RootViewProviding { 15 | NavigationAppController(withResetButton: true) { _ in 16 | UIHostingController(rootView: NavigableWebView(title: "WebView", url: URL(string: "https://www.google.com")!)) 17 | } 18 | } 19 | } 20 | 21 | final class WebViewHardDynamicTitleScenario: Scenario { 22 | static var name: String = "Dynamic title" 23 | static var kind: ScenarioKind = .component 24 | static var category: ScenarioCategory? = "WebView" 25 | 26 | static var rootViewProvider: RootViewProviding { 27 | NavigationAppController(withResetButton: true) { _ in 28 | UIHostingController(rootView: NavigableWebView(url: URL(string: "https://www.google.com")!)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/SwiftUI/InternalApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import SwiftUI 7 | import UIKit 8 | 9 | @main 10 | struct InternalApp: App { 11 | 12 | @UIApplicationDelegateAdaptor(InternalAppDelegate.self) var appDelegate 13 | @Environment(\.scenePhase) var scenePhase 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | SwiftUIViewProvider(appDelegate.manager.appController) 18 | } 19 | .onChange(of: scenePhase) { scenePhase in 20 | switch scenePhase { 21 | case .active: 22 | guard let shortcutItem = appDelegate.shortcutItem else { return } 23 | _ = appDelegate.manager.performAction(for: shortcutItem) 24 | default: return 25 | } 26 | } 27 | } 28 | } 29 | 30 | final class InternalAppDelegate: NSObject, UIApplicationDelegate { 31 | 32 | fileprivate let manager = ScenariosManager() 33 | 34 | var shortcutItem: UIApplicationShortcutItem? { InternalAppDelegate.shortcutItem } 35 | 36 | fileprivate static var shortcutItem: UIApplicationShortcutItem? 37 | 38 | func application( 39 | _ application: UIApplication, 40 | configurationForConnecting connectingSceneSession: UISceneSession, 41 | options: UIScene.ConnectionOptions 42 | ) -> UISceneConfiguration { 43 | if let shortcutItem = options.shortcutItem { 44 | InternalAppDelegate.shortcutItem = shortcutItem 45 | } 46 | 47 | let sceneConfiguration = UISceneConfiguration( 48 | name: "Scene Configuration", 49 | sessionRole: connectingSceneSession.role 50 | ) 51 | sceneConfiguration.delegateClass = SceneDelegate.self 52 | 53 | return sceneConfiguration 54 | } 55 | } 56 | 57 | private final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 58 | func windowScene( 59 | _ windowScene: UIWindowScene, 60 | performActionFor shortcutItem: UIApplicationShortcutItem, 61 | completionHandler: @escaping (Bool) -> Void 62 | ) { 63 | InternalAppDelegate.shortcutItem = shortcutItem 64 | completionHandler(true) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Internal/UIKit/InternalAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Scenarios 6 | import UIKit 7 | 8 | @UIApplicationMain 9 | final class InternalAppDelegate: BaseAppDelegate { 10 | 11 | // MARK: Properties 12 | 13 | private lazy var manager: BaseScenariosManager = { 14 | if #available(iOS 13, *) { 15 | return ScenariosManager() 16 | } else { 17 | return BaseScenariosManager() 18 | } 19 | }() 20 | 21 | // MARK: APIs 22 | 23 | override func makeRootViewController() -> UIViewController { 24 | manager.makeAppController().rootViewController 25 | } 26 | 27 | override func application( 28 | _ application: UIApplication, 29 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 30 | ) -> Bool { 31 | defer { 32 | manager.prepare(window!) 33 | } 34 | 35 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 36 | } 37 | 38 | @objc func application( 39 | _ application: UIApplication, 40 | performActionFor shortcutItem: UIApplicationShortcutItem, 41 | completionHandler: @escaping (Bool) -> Void 42 | ) { 43 | let result = manager.performAction(for: shortcutItem) 44 | completionHandler(result) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Production/SwiftUI/ProductionApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | @main 8 | struct ProductionApp: App { 9 | var body: some Scene { 10 | WindowGroup { 11 | let appServices = AppServices( 12 | docURL: Configuration.production.docsURL, 13 | githubService: GithubService(client: Configuration.production.networkClient) 14 | 15 | ) 16 | DashboardView().environmentObject(appServices) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sample/Sample/Sources/Production/UIKit/ProductionAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | @UIApplicationMain 8 | final class ProductionAppDelegate: BaseAppDelegate { 9 | 10 | override func makeRootViewController() -> UIViewController { 11 | let appServices = AppServices( 12 | docURL: Configuration.production.docsURL, 13 | githubService: GithubService(client: Configuration.production.networkClient) 14 | 15 | ) 16 | return UIHostingController(rootView: DashboardView().environmentObject(appServices)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sample/UITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sample/UITests/UITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | final class UITests: XCTestCase {} 8 | -------------------------------------------------------------------------------- /Sample/UnitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sample/UnitTests/UnitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | final class UnitTests: XCTestCase {} 8 | -------------------------------------------------------------------------------- /Scenarios.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Scenarios.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Scenarios.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CodeViewer", 6 | "repositoryURL": "https://github.com/dwarvesf/CodeViewer", 7 | "state": { 8 | "branch": "main", 9 | "revision": "44bd04af81046ce65a4e38dc97a9dd3d387f069e", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Inject", 15 | "repositoryURL": "https://github.com/krzysztofzablocki/Inject", 16 | "state": { 17 | "branch": null, 18 | "revision": "04925668f57e74bc40f21b185a41c5585a84a9fe", 19 | "version": "1.2.2" 20 | } 21 | }, 22 | { 23 | "package": "SDWebImage", 24 | "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "2c53f531f1bedd253f55d85105409c28ed4a922c", 28 | "version": "5.12.3" 29 | } 30 | }, 31 | { 32 | "package": "SDWebImageSwiftUI", 33 | "repositoryURL": "https://github.com/SDWebImage/SDWebImageSwiftUI", 34 | "state": { 35 | "branch": null, 36 | "revision": "cd8625b7cf11a97698e180d28bb7d5d357196678", 37 | "version": "2.0.2" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Scripts/docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | swift doc generate Sources --module-name Scenarios --output docs --format html --base-url https://antranapp.github.io/Scenarios/ 4 | -------------------------------------------------------------------------------- /Scripts/swiftformat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antranapp/Scenarios/c9c7160549fa154067657a2221335d49d3d21922/Scripts/swiftformat -------------------------------------------------------------------------------- /Sources/Scenarios/AppController/BaseScenarioSelectorAppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | class BaseScenarioSelectorAppController: BaseSectionManager, RootViewProviding { 8 | 9 | var rootViewController: UIViewController 10 | 11 | private var layout: ScenarioListLayout = .nestedList 12 | private var targetAudience: Audience? 13 | 14 | private var content: UIViewController? { 15 | didSet { 16 | oldValue?.remove() 17 | if let content = content { 18 | (rootViewController as! UINavigationController).viewControllers = [content] 19 | UIAccessibility.post(notification: .screenChanged, argument: nil) 20 | } 21 | } 22 | } 23 | 24 | // MARK: Initilizer 25 | 26 | override init( 27 | targetAudience: Audience?, 28 | select: @escaping (ScenarioId) -> Void 29 | ) { 30 | self.targetAudience = targetAudience 31 | 32 | let navigationController = UINavigationController() 33 | navigationController.navigationBar.prefersLargeTitles = true 34 | rootViewController = navigationController 35 | super.init(targetAudience: targetAudience, select: select) 36 | 37 | // For some reasons, the content is not get updated on the first launch 38 | // on iOS 11. Need to call these explicitly to build up the Scenario Catalog 39 | content = makeScenarioViewController(with: sections) 40 | (rootViewController as! UINavigationController).viewControllers = [content!] 41 | } 42 | 43 | // MARK: Private helpers 44 | 45 | override func onDidSetSections(_ sections: [ListSection]) { 46 | content = makeScenarioViewController(with: sections) 47 | } 48 | 49 | override func showInfo(_ info: ScenarioInfo) { 50 | var action: UIAlertAction? 51 | if let configure = info.configure { 52 | action = UIAlertAction( 53 | title: "Configure", 54 | style: .default, 55 | handler: { [weak rootViewController] _ in 56 | guard let rootViewController = rootViewController else { return } 57 | rootViewController.dismiss(animated: true) { 58 | configure(rootViewController) 59 | } 60 | 61 | } 62 | ) 63 | } 64 | 65 | rootViewController.showAlert( 66 | title: info.name, 67 | message: info.description, 68 | preferredStyle: .actionSheet, 69 | action: action 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Scenarios/AppController/BasicAppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public struct BasicAppController: RootViewProviding { 8 | 9 | public var rootViewController: UIViewController 10 | 11 | public init(rootViewController: UIViewController) { 12 | self.rootViewController = rootViewController 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Scenarios/AppController/ScenarioSelectorAppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | #if canImport(Combine) 6 | import Combine 7 | #endif 8 | import Foundation 9 | 10 | @available(iOS 13.0, *) 11 | final class ScenarioSelectorAppController: BaseScenarioSelectorAppController { 12 | 13 | private var cancellables = Set() 14 | private var favouriteScenarios: AnyPublisher<[ScenarioId], Never>? 15 | 16 | init( 17 | targetAudience: Audience?, 18 | favouriteScenarios: AnyPublisher<[ScenarioId], Never>?, 19 | layout: ScenarioListLayout, 20 | select: @escaping (ScenarioId) -> Void 21 | ) { 22 | self.favouriteScenarios = favouriteScenarios 23 | super.init(targetAudience: targetAudience, select: select) 24 | 25 | setupBindings(select: select) 26 | } 27 | 28 | private func setupBindings(select: @escaping (ScenarioId) -> Void) { 29 | favouriteScenarios? 30 | .sink { [weak self] scenarioIds in 31 | guard let self = self else { return } 32 | self.sections = self.makeSections(select: select, favouriteScenarios: scenarioIds) 33 | } 34 | .store(in: &cancellables) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Scenarios/AppController/ScenarioSelectorSplitAppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by An Tran on 6/2/22. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | @available(iOS 14.0, *) 13 | final class ScenarioSelectorSplitAppController: BaseSectionManager, RootViewProviding { 14 | 15 | let rootViewController: UIViewController 16 | 17 | private var splitViewController: UISplitViewController? { 18 | rootViewController as? UISplitViewController 19 | } 20 | 21 | override init( 22 | targetAudience: Audience?, 23 | select: @escaping (ScenarioId) -> Void 24 | ) { 25 | let splitViewController = UISplitViewController(style: .doubleColumn) 26 | rootViewController = splitViewController 27 | 28 | super.init(targetAudience: targetAudience, select: select) 29 | 30 | let scenariosViewController = makeScenarioViewController(with: sections) 31 | splitViewController.setViewController(scenariosViewController, for: .primary) 32 | 33 | setScenario(nil) 34 | } 35 | 36 | func setScenario(_ id: ScenarioId?) { 37 | if let id = id { 38 | splitViewController?.setViewController(id.scenarioType.rootViewProvider.rootViewController, for: .secondary) 39 | } else { 40 | splitViewController?.setViewController(UIHostingController(rootView: ContentView(title: "Choose an scenario form the menu")), for: .secondary) 41 | } 42 | } 43 | } 44 | 45 | @available(iOS 14.0, *) 46 | private struct ContentView: View { 47 | let title: String 48 | 49 | var body: some View { 50 | Text(title) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Scenarios/AppController/ScenariosAppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public final class ScenariosAppController: RootViewProviding { 8 | public let rootViewController = UIViewController() 9 | 10 | public var rootViewProvider: RootViewProviding? { 11 | didSet { 12 | dismissToRootView() 13 | oldValue?.rootViewController.remove() 14 | if let content = rootViewProvider { 15 | rootViewController.addFilling(content.rootViewController) 16 | } 17 | } 18 | } 19 | 20 | func setScenarioSelector(_ rootViewProvider: RootViewProviding) { 21 | self.rootViewProvider = rootViewProvider 22 | } 23 | 24 | func setScenario(_ id: ScenarioId) { 25 | if #available(iOS 14.0, *) { 26 | if let rootViewProvider = rootViewProvider as? ScenarioSelectorSplitAppController { 27 | rootViewProvider.setScenario(id) 28 | } else { 29 | self.rootViewProvider = id.scenarioType.rootViewProvider 30 | } 31 | } else { 32 | self.rootViewProvider = id.scenarioType.rootViewProvider 33 | } 34 | } 35 | 36 | private func dismissToRootView() { 37 | if (rootViewProvider?.rootViewController.presentedViewController) != nil { 38 | rootViewProvider?.rootViewController.dismiss(animated: false, completion: nil) 39 | } 40 | } 41 | } 42 | 43 | extension UIViewController { 44 | func addFilling(_ child: UIViewController) { 45 | addChild(child) 46 | view.addFillingSubview(child.view) 47 | child.didMove(toParent: self) 48 | } 49 | 50 | func remove() { 51 | guard parent != nil else { return } 52 | willMove(toParent: nil) 53 | view.removeFromSuperview() 54 | removeFromParent() 55 | } 56 | } 57 | 58 | extension UIView { 59 | func addAutolayoutSubview(_ subview: UIView) { 60 | subview.translatesAutoresizingMaskIntoConstraints = false 61 | addSubview(subview) 62 | } 63 | 64 | func addFillingSubview(_ subview: UIView, inset: CGFloat = 0) { 65 | addAutolayoutSubview(subview) 66 | NSLayoutConstraint.activate([ 67 | subview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 68 | subview.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 69 | subview.topAnchor.constraint(equalTo: topAnchor, constant: inset), 70 | subview.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), 71 | ]) 72 | } 73 | 74 | func addCenterSubview(_ subview: UIView) { 75 | addAutolayoutSubview(subview) 76 | 77 | NSLayoutConstraint.activate([ 78 | subview.centerXAnchor.constraint(equalTo: centerXAnchor), 79 | subview.centerYAnchor.constraint(equalTo: centerYAnchor), 80 | ]) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Scenarios/ApplicationShortcutItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public struct ApplicationShortcutItem { 8 | var item: UIApplicationShortcutItem 9 | var action: () -> Void 10 | } 11 | 12 | public extension ApplicationShortcutItem { 13 | init( 14 | type: String, 15 | title: String, 16 | systemImageName: String, 17 | action: @escaping () -> Void 18 | ) { 19 | 20 | let icon: UIApplicationShortcutIcon? 21 | if #available(iOS 13.0, *) { 22 | icon = UIApplicationShortcutIcon(systemImageName: systemImageName) 23 | } else { 24 | icon = nil 25 | } 26 | self.init( 27 | item: UIApplicationShortcutItem( 28 | type: type, 29 | localizedTitle: title, 30 | localizedSubtitle: nil, 31 | icon: icon, 32 | userInfo: nil 33 | ), 34 | action: action 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Scenarios/Extensions/Notification+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public extension Notification.Name { 8 | static let resetScenario = Notification.Name(rawValue: "ResetScenario") 9 | static let refreshScenario = Notification.Name(rawValue: "RefreshScenario") 10 | static let switchLayout = Notification.Name(rawValue: "SwitchLayout") 11 | static let toggleFavourite = Notification.Name(rawValue: "ToggleFavourite") 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Scenarios/RootViewProviding+SwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | import UIKit 8 | 9 | @available(iOS 13.0, *) 10 | public struct SwiftUIViewProvider: UIViewControllerRepresentable { 11 | let rootViewProvider: RootViewProviding 12 | 13 | public init(_ rootViewProvider: RootViewProviding) { 14 | self.rootViewProvider = rootViewProvider 15 | } 16 | 17 | public func makeUIViewController(context: Context) -> UIViewController { 18 | rootViewProvider.rootViewController 19 | } 20 | 21 | public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { 22 | // Doing nothing 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Scenarios/RootViewProviding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public protocol RootViewProviding { 8 | var rootViewController: UIViewController { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Audience/Audience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct Audience: RawRepresentable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, Equatable { 8 | public var rawValue: String 9 | 10 | public init(rawValue: String) { 11 | self.rawValue = rawValue 12 | } 13 | 14 | public init(stringLiteral value: StringLiteralType) { 15 | self.init(rawValue: value) 16 | } 17 | } 18 | 19 | public extension Audience { 20 | static let developer: Audience = "Developers" 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Audience/AudienceTargetable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol AudienceTargetable { 8 | static var audiences: [Audience] { get } 9 | } 10 | 11 | extension AudienceTargetable { 12 | static func canDisplay(for audience: Audience?) -> Bool { 13 | // Always display when there is no filter. 14 | guard let audience = audience else { 15 | return true 16 | } 17 | 18 | // Always display when the audiences list is empty. 19 | guard !audiences.isEmpty else { 20 | return true 21 | } 22 | 23 | // Only display the Scenario when the targeted audience 24 | // is conatined in the audiences list. 25 | return audiences.contains(audience) 26 | } 27 | } 28 | 29 | public typealias AudienceTargetableScenario = Scenario & AudienceTargetable 30 | 31 | public extension AudienceTargetable where Self: AudienceTargetableScenario { 32 | static var audiences: [Audience] { [.developer] } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Feature/FeatureContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | import UIKit 8 | 9 | @available(iOS 13.0, *) 10 | public class FeatureContext { 11 | let configurations: [Configuration] 12 | var selectedConfiguration: Configuration? 13 | var didSelect: (Configuration) -> AnyPublisher 14 | var didPrepare: (Output) -> AnyPublisher 15 | 16 | public init( 17 | configurations: [Configuration], 18 | didSelect: @escaping (Configuration) -> AnyPublisher, 19 | didPrepare: @escaping (Output) -> AnyPublisher 20 | ) { 21 | self.configurations = configurations 22 | self.didSelect = didSelect 23 | self.didPrepare = didPrepare 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Feature/FeatureScenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | @available(iOS 13.0, *) 8 | public protocol FeatureScenario: AudienceTargetableScenario { 9 | associatedtype Configuration 10 | associatedtype Output 11 | 12 | static var context: FeatureContext { get } 13 | } 14 | 15 | @available(iOS 13.0, *) 16 | public extension FeatureScenario { 17 | static var kind: ScenarioKind { 18 | .feature 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Feature/ScenarioKind+Feature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public extension ScenarioKind { 8 | @objc static let feature: ScenarioKind = "Feature" 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/Scenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import ObjectiveC 6 | import UIKit 7 | 8 | @objc public protocol ScenarioMarker {} 9 | 10 | public protocol IdentifiableType: AnyObject { 11 | static var id: String { get } 12 | } 13 | 14 | public extension IdentifiableType { 15 | static var id: String { 16 | NSStringFromClass(Self.self) 17 | } 18 | } 19 | 20 | public struct ScenarioInfo { 21 | var name: String 22 | var description: String 23 | var configure: ((UIViewController) -> Void)? 24 | 25 | public init(name: String, description: String, configure: ((UIViewController) -> Void)? = nil) { 26 | self.name = name 27 | self.description = description 28 | self.configure = configure 29 | } 30 | } 31 | 32 | public protocol BaseScenario: IdentifiableType, ScenarioMarker { 33 | static var name: String { get } 34 | static var nameForSorting: String { get } 35 | static var kind: ScenarioKind { get } 36 | } 37 | 38 | public extension BaseScenario { 39 | static var nameForSorting: String { 40 | name 41 | } 42 | } 43 | 44 | public protocol Scenario: BaseScenario { 45 | static var rootViewProvider: RootViewProviding { get } 46 | static var shortDescription: String? { get } 47 | static var longDescription: String? { get } 48 | static var category: ScenarioCategory? { get } 49 | static var subCategory: ScenarioCategory? { get } 50 | static var info: ScenarioInfo? { get } 51 | } 52 | 53 | public extension Scenario { 54 | 55 | static var shortDescription: String? { 56 | nil 57 | } 58 | 59 | static var longDescription: String? { 60 | nil 61 | } 62 | 63 | static var info: ScenarioInfo? { 64 | longDescription.map { 65 | ScenarioInfo(name: name, description: $0) 66 | } 67 | } 68 | 69 | static var category: ScenarioCategory? { 70 | nil 71 | } 72 | 73 | static var subCategory: ScenarioCategory? { 74 | nil 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Scenarios/Scenario/ScenarioKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Represents a unique identifier of the set of scenarios. 8 | public class ScenarioKind: NSObject, RawRepresentable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, CaseIterable { 9 | 10 | /// The raw string value. 11 | public var rawValue: String 12 | 13 | /// A string representing the name of this kind. 14 | public var name: String { rawValue } 15 | 16 | public var nameForSorting: String { 17 | _nameForSorting ?? name 18 | } 19 | 20 | /// A textual representation of this instance. 21 | override public var description: String { rawValue } 22 | 23 | private var _nameForSorting: String? 24 | 25 | /// Creates a new kind with given raw string value. 26 | /// 27 | /// - Parameters: 28 | /// - rawValue: The raw string value. 29 | public required init(rawValue: String) { 30 | self.rawValue = rawValue 31 | _nameForSorting = nil 32 | } 33 | 34 | /// Creates a new kind with given raw string value. 35 | /// 36 | /// - Parameters: 37 | /// - value: The raw string value. 38 | public required convenience init(stringLiteral value: String) { 39 | self.init(rawValue: value) 40 | } 41 | 42 | public convenience init(rawValue: String, nameForSorting: String? = nil) { 43 | self.init(rawValue: rawValue) 44 | _nameForSorting = nameForSorting 45 | } 46 | 47 | public static var allCases: [ScenarioKind] = { 48 | var count: CUnsignedInt = 0 49 | var cases = [ScenarioKind]() 50 | guard let methods = class_copyPropertyList(object_getClass(ScenarioKind.self), &count) else { 51 | return cases 52 | } 53 | for i in 0 ..< count { 54 | let selector = property_getName(methods.advanced(by: Int(i)).pointee) 55 | if let key = String(cString: selector, encoding: .utf8), 56 | let kind = ScenarioKind.value(forKey: key) as? ScenarioKind 57 | { 58 | cases.append(kind) 59 | } 60 | } 61 | 62 | return cases 63 | }() 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Scenarios/UI/UIViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public extension UIViewController { 8 | 9 | func showAlert( 10 | title: String, 11 | message: String? = nil, 12 | preferredStyle: UIAlertController.Style = .alert, 13 | action: UIAlertAction? = nil 14 | ) { 15 | let alert = UIAlertController(title: title, message: message, preferredStyle: preferredStyle) 16 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 17 | if let action = action { 18 | alert.addAction(action) 19 | } 20 | 21 | present(alert, animated: true, completion: nil) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Tests/ScenariosTests/ScenariosTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 An Tran. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | @testable import Scenarios 7 | 8 | final class ScenariosTests: XCTestCase {} 9 | -------------------------------------------------------------------------------- /docs/ApplicationShortcutItem/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - ApplicationShortcutItem 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Structure 37 | Application​Shortcut​Item 38 |

39 | 40 |
public struct ApplicationShortcutItem
41 | 42 |
43 |

Initializers

44 | 45 |
46 |

47 | init(type:​title:​system​Image​Name:​action:​) 48 |

49 |
init(type: String, title: String, systemImageName: String, action: @escaping () -> Void)
50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 | 58 |
59 |

60 | Generated on using swift-doc 1.0.0-beta.5. 61 |

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/AudienceTargetable/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - AudienceTargetable 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Protocol 37 | Audience​Targetable 38 |

39 | 40 |
public protocol AudienceTargetable
41 | 42 | 43 | 44 | 45 |
46 |

Requirements

47 | 48 |
49 |

50 | audiences 51 |

52 |
var audiences: [Audience]
53 |
54 |
55 |
56 |
57 | 58 |
59 |

60 | Generated on using swift-doc 1.0.0-beta.5. 61 |

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/AudienceTargetableScenario/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - AudienceTargetableScenario 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Typealias 37 | Audience​Targetable​Scenario 38 |

39 | 40 |
public typealias AudienceTargetableScenario = Scenario & AudienceTargetable
41 |
42 |
43 | 44 |
45 |

46 | Generated on using swift-doc 1.0.0-beta.5. 47 |

48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /docs/FeatureContext/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - FeatureContext 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Class 37 | Feature​Context 38 |

39 | 40 |
public class FeatureContext<Configuration, Output>
41 | 42 |
43 |

Initializers

44 | 45 |
46 |

47 | init(configurations:​did​Select:​did​Prepare:​) 48 |

49 |
public init(configurations: [Configuration], didSelect: @escaping (Configuration) -> AnyPublisher<Output, Error>, didPrepare: @escaping (Output) -> AnyPublisher<UIViewController, Error>)
50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 | 58 |
59 |

60 | Generated on using swift-doc 1.0.0-beta.5. 61 |

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/ReloadableViewController/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - ReloadableViewController 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Typealias 37 | Reloadable​View​Controller 38 |

39 | 40 |
public typealias ReloadableViewController = Reloadable & UIViewController
41 |
42 |
43 | 44 |
45 |

46 | Generated on using swift-doc 1.0.0-beta.5. 47 |

48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /docs/ScenarioCategory/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - ScenarioCategory 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Protocol 37 | Scenario​Category 38 |

39 | 40 |
public protocol ScenarioCategory
41 | 42 | 43 | 44 | 45 |
46 |

Requirements

47 | 48 |
49 |

50 | id 51 |

52 |
var id: String
53 |
54 |
55 |

56 | name 57 |

58 |
var name: String
59 |
60 |
61 |
62 |
63 | 64 |
65 |

66 | Generated on using swift-doc 1.0.0-beta.5. 67 |

68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/ScenarioInfo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - ScenarioInfo 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Structure 37 | Scenario​Info 38 |

39 | 40 |
public struct ScenarioInfo
41 | 42 |
43 |

Initializers

44 | 45 |
46 |

47 | init(name:​description:​configure:​) 48 |

49 |
public init(name: String, description: String, configure: ((UIViewController) -> Void)? = nil)
50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 | 58 |
59 |

60 | Generated on using swift-doc 1.0.0-beta.5. 61 |

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/ScenarioPlugin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - ScenarioPlugin 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Protocol 37 | Scenario​Plugin 38 |

39 | 40 |
public protocol ScenarioPlugin
41 | 42 | 43 | 44 | 45 |
46 |

Requirements

47 | 48 |
49 |

50 | register() 51 |

52 |
func register()
53 |
54 |
55 |
56 |
57 | 58 |
59 |

60 | Generated on using swift-doc 1.0.0-beta.5. 61 |

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/Taggable/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scenarios - Taggable 7 | 8 | 9 | 10 |
11 | 12 | 13 | Scenarios 14 | 15 | Documentation 16 | 17 | Beta 18 |
19 | 20 | 25 | 26 | 32 | 33 |
34 |
35 |

36 | Protocol 37 | Taggable 38 |

39 | 40 |
public protocol Taggable
41 | 42 | 43 | 44 | 45 |
46 |

Requirements

47 | 48 |
49 |

50 | tag 51 |

52 |
var tag: Int
53 |
54 |
55 |
56 |
57 | 58 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | --------------------------------------------------------------------------------