diff --git a/IssueTracker/IssueTracker.xcodeproj/project.pbxproj b/IssueTracker/IssueTracker.xcodeproj/project.pbxproj index 593df30e32..3842274a97 100644 --- a/IssueTracker/IssueTracker.xcodeproj/project.pbxproj +++ b/IssueTracker/IssueTracker.xcodeproj/project.pbxproj @@ -7,6 +7,21 @@ objects = { /* Begin PBXBuildFile section */ + 000B92C2286D67F8009EB2D8 /* NewIssueModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000B92C1286D67F8009EB2D8 /* NewIssueModel.swift */; }; + 000B92C4286D6816009EB2D8 /* Option.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000B92C3286D6816009EB2D8 /* Option.swift */; }; + 000B92C7286D6F9D009EB2D8 /* OptionSelectModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000B92C6286D6F9D009EB2D8 /* OptionSelectModel.swift */; }; + 0022D4D728D9A94700088543 /* ViewControllerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0022D4D628D9A94700088543 /* ViewControllerCoordinator.swift */; }; + 003081ED28A2486E001B7600 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081EC28A2486E001B7600 /* AppCoordinator.swift */; }; + 003081F028A24E2C001B7600 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081EF28A24E2C001B7600 /* Coordinator.swift */; }; + 003081F228A28F8C001B7600 /* LoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081F128A28F8C001B7600 /* LoginCoordinator.swift */; }; + 003081F428A29EC0001B7600 /* ReposCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081F328A29EC0001B7600 /* ReposCoordinator.swift */; }; + 003081F628A29EC8001B7600 /* IssueCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081F528A29EC8001B7600 /* IssueCoordinator.swift */; }; + 003081F828A29ED3001B7600 /* NewIssueCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081F728A29ED3001B7600 /* NewIssueCoordinator.swift */; }; + 003081FA28A29EE2001B7600 /* OptionSelectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003081F928A29EE2001B7600 /* OptionSelectCoordinator.swift */; }; + 00383AB0288A8306008B215E /* LoginModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00383AAF288A8306008B215E /* LoginModel.swift */; }; + 004800F6286E8C0700A58B2A /* Assignee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 004800F5286E8C0700A58B2A /* Assignee.swift */; }; + 004800F8286E907300A58B2A /* Optionable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 004800F7286E907300A58B2A /* Optionable.swift */; }; + 004ABDE628BF516400F5CEC8 /* NewIssueFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 004ABDE528BF516400F5CEC8 /* NewIssueFormat.swift */; }; 0053D7552859B162006614EC /* Config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0053D7542859B162006614EC /* Config.plist */; }; 0053D7572859B4CA006614EC /* PrivateStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0053D7562859B4CA006614EC /* PrivateStorage.swift */; }; 00567EE8286194DB00F3E8EC /* IssueService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00567EE7286194DB00F3E8EC /* IssueService.swift */; }; @@ -14,6 +29,7 @@ 005E48D62862AE91004BD5F6 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005E48D52862AE91004BD5F6 /* Container.swift */; }; 005E48D828630635004BD5F6 /* NewIssueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005E48D728630635004BD5F6 /* NewIssueViewController.swift */; }; 005E48DA28636064004BD5F6 /* Devider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005E48D928636064004BD5F6 /* Devider.swift */; }; + 00AF8F282869A7060009674C /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00AF8F272869A7060009674C /* Repository.swift */; }; 00BCB406285B01AA005E87BC /* Issue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BCB405285B01AA005E87BC /* Issue.swift */; }; 00BCB408285B0262005E87BC /* Label.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BCB407285B0262005E87BC /* Label.swift */; }; 00BCB40A285B027B005E87BC /* Milestone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BCB409285B027B005E87BC /* Milestone.swift */; }; @@ -24,6 +40,8 @@ 981BD43F285B12F300DDDE64 /* Margins.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981BD43E285B12F200DDDE64 /* Margins.swift */; }; 983E66C6285B208800C9B975 /* GithubUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983E66C5285B208800C9B975 /* GithubUserDefaults.swift */; }; 983E66C8285B212A00C9B975 /* GitHubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983E66C7285B212A00C9B975 /* GitHubService.swift */; }; + 9844C193286C803F00D0CFC9 /* ReposViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9844C192286C803F00D0CFC9 /* ReposViewController.swift */; }; + 9844C195286D434B00D0CFC9 /* ReposModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9844C194286D434B00D0CFC9 /* ReposModel.swift */; }; 985CAB47285C1A26005E709D /* UIStackView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985CAB46285C1A26005E709D /* UIStackView+Extension.swift */; }; 985CAB49285C1A61005E709D /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985CAB48285C1A61005E709D /* UIColor+Extension.swift */; }; 985CAB4C28603AE9005E709D /* NetworkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985CAB4B28603AE9005E709D /* NetworkHeader.swift */; }; @@ -61,6 +79,21 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 000B92C1286D67F8009EB2D8 /* NewIssueModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewIssueModel.swift; sourceTree = ""; }; + 000B92C3286D6816009EB2D8 /* Option.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = ""; }; + 000B92C6286D6F9D009EB2D8 /* OptionSelectModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionSelectModel.swift; sourceTree = ""; }; + 0022D4D628D9A94700088543 /* ViewControllerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerCoordinator.swift; sourceTree = ""; }; + 003081EC28A2486E001B7600 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 003081EF28A24E2C001B7600 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; + 003081F128A28F8C001B7600 /* LoginCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCoordinator.swift; sourceTree = ""; }; + 003081F328A29EC0001B7600 /* ReposCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReposCoordinator.swift; sourceTree = ""; }; + 003081F528A29EC8001B7600 /* IssueCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueCoordinator.swift; sourceTree = ""; }; + 003081F728A29ED3001B7600 /* NewIssueCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewIssueCoordinator.swift; sourceTree = ""; }; + 003081F928A29EE2001B7600 /* OptionSelectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionSelectCoordinator.swift; sourceTree = ""; }; + 00383AAF288A8306008B215E /* LoginModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginModel.swift; sourceTree = ""; }; + 004800F5286E8C0700A58B2A /* Assignee.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assignee.swift; sourceTree = ""; }; + 004800F7286E907300A58B2A /* Optionable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optionable.swift; sourceTree = ""; }; + 004ABDE528BF516400F5CEC8 /* NewIssueFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewIssueFormat.swift; sourceTree = ""; }; 0053D7542859B162006614EC /* Config.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Config.plist; sourceTree = ""; }; 0053D7562859B4CA006614EC /* PrivateStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateStorage.swift; sourceTree = ""; }; 00567EE7286194DB00F3E8EC /* IssueService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueService.swift; sourceTree = ""; }; @@ -68,6 +101,7 @@ 005E48D52862AE91004BD5F6 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; 005E48D728630635004BD5F6 /* NewIssueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewIssueViewController.swift; sourceTree = ""; }; 005E48D928636064004BD5F6 /* Devider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Devider.swift; sourceTree = ""; }; + 00AF8F272869A7060009674C /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; 00BCB405285B01AA005E87BC /* Issue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Issue.swift; sourceTree = ""; }; 00BCB407285B0262005E87BC /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; 00BCB409285B027B005E87BC /* Milestone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Milestone.swift; sourceTree = ""; }; @@ -78,6 +112,8 @@ 981BD43E285B12F200DDDE64 /* Margins.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Margins.swift; sourceTree = ""; }; 983E66C5285B208800C9B975 /* GithubUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubUserDefaults.swift; sourceTree = ""; }; 983E66C7285B212A00C9B975 /* GitHubService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubService.swift; sourceTree = ""; }; + 9844C192286C803F00D0CFC9 /* ReposViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReposViewController.swift; sourceTree = ""; }; + 9844C194286D434B00D0CFC9 /* ReposModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReposModel.swift; sourceTree = ""; }; 985CAB46285C1A26005E709D /* UIStackView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStackView+Extension.swift"; sourceTree = ""; }; 985CAB48285C1A61005E709D /* UIColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; 985CAB4B28603AE9005E709D /* NetworkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHeader.swift; sourceTree = ""; }; @@ -126,6 +162,57 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 000B92BF286D67C6009EB2D8 /* NewIssue */ = { + isa = PBXGroup; + children = ( + 000B92C0286D67E9009EB2D8 /* Model */, + 005E48D728630635004BD5F6 /* NewIssueViewController.swift */, + 000B92C3286D6816009EB2D8 /* Option.swift */, + ); + path = NewIssue; + sourceTree = ""; + }; + 000B92C0286D67E9009EB2D8 /* Model */ = { + isa = PBXGroup; + children = ( + 000B92C1286D67F8009EB2D8 /* NewIssueModel.swift */, + 004ABDE528BF516400F5CEC8 /* NewIssueFormat.swift */, + ); + path = Model; + sourceTree = ""; + }; + 000B92C5286D6F85009EB2D8 /* OptionSelect */ = { + isa = PBXGroup; + children = ( + 988FA2E92864034C0058C333 /* OptionSelectViewController.swift */, + 000B92C8286D6FA1009EB2D8 /* Model */, + ); + path = OptionSelect; + sourceTree = ""; + }; + 000B92C8286D6FA1009EB2D8 /* Model */ = { + isa = PBXGroup; + children = ( + 000B92C6286D6F9D009EB2D8 /* OptionSelectModel.swift */, + ); + path = Model; + sourceTree = ""; + }; + 003081EE28A2487B001B7600 /* Coordinator */ = { + isa = PBXGroup; + children = ( + 003081EF28A24E2C001B7600 /* Coordinator.swift */, + 003081EC28A2486E001B7600 /* AppCoordinator.swift */, + 003081F128A28F8C001B7600 /* LoginCoordinator.swift */, + 003081F328A29EC0001B7600 /* ReposCoordinator.swift */, + 003081F528A29EC8001B7600 /* IssueCoordinator.swift */, + 003081F728A29ED3001B7600 /* NewIssueCoordinator.swift */, + 003081F928A29EE2001B7600 /* OptionSelectCoordinator.swift */, + 0022D4D628D9A94700088543 /* ViewControllerCoordinator.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; 00567EE92861B42A00F3E8EC /* Service */ = { isa = PBXGroup; children = ( @@ -145,9 +232,7 @@ 981BD438285B123A00DDDE64 /* Issue */ = { isa = PBXGroup; children = ( - 988FA2E92864034C0058C333 /* OptionSelectViewController.swift */, 98130E1A285AC8A5001B6DA1 /* IssueViewController.swift */, - 005E48D728630635004BD5F6 /* NewIssueViewController.swift */, 00567EEA2861B43600F3E8EC /* Model */, 00567EE92861B42A00F3E8EC /* Service */, 981BD439285B124E00DDDE64 /* View */, @@ -178,14 +263,34 @@ 983E66CA285B2AD300C9B975 /* Entity */ = { isa = PBXGroup; children = ( + 00AF8F272869A7060009674C /* Repository.swift */, 00BCB405285B01AA005E87BC /* Issue.swift */, - 00BCB407285B0262005E87BC /* Label.swift */, - 00BCB409285B027B005E87BC /* Milestone.swift */, 00BCB40B285B076B005E87BC /* State.swift */, + 00BCB409285B027B005E87BC /* Milestone.swift */, + 004800F7286E907300A58B2A /* Optionable.swift */, + 00BCB407285B0262005E87BC /* Label.swift */, + 004800F5286E8C0700A58B2A /* Assignee.swift */, ); path = Entity; sourceTree = ""; }; + 9844C191286C802000D0CFC9 /* Repos */ = { + isa = PBXGroup; + children = ( + 9844C196286D436900D0CFC9 /* Model */, + 9844C192286C803F00D0CFC9 /* ReposViewController.swift */, + ); + path = Repos; + sourceTree = ""; + }; + 9844C196286D436900D0CFC9 /* Model */ = { + isa = PBXGroup; + children = ( + 9844C194286D434B00D0CFC9 /* ReposModel.swift */, + ); + path = Model; + sourceTree = ""; + }; 985CAB45285C19D0005E709D /* Extension */ = { isa = PBXGroup; children = ( @@ -211,6 +316,7 @@ isa = PBXGroup; children = ( 985CAB52286068A4005E709D /* OAuthService.swift */, + 00383AAF288A8306008B215E /* LoginModel.swift */, 988510D928572B8700CC2DA9 /* LoginViewController.swift */, ); path = Login; @@ -239,18 +345,22 @@ 988510D428572B8700CC2DA9 /* IssueTracker */ = { isa = PBXGroup; children = ( - 985CAB5128606898005E709D /* Login */, - 985CAB4A28603AD3005E709D /* GitHub */, + 983E66C4285B204600C9B975 /* Storage */, 985CAB45285C19D0005E709D /* Extension */, + 985CAB4A28603AD3005E709D /* GitHub */, 983E66CA285B2AD300C9B975 /* Entity */, - 983E66C4285B204600C9B975 /* Storage */, + 985CAB5128606898005E709D /* Login */, + 9844C191286C802000D0CFC9 /* Repos */, 981BD438285B123A00DDDE64 /* Issue */, + 000B92BF286D67C6009EB2D8 /* NewIssue */, + 000B92C5286D6F85009EB2D8 /* OptionSelect */, + 003081EE28A2487B001B7600 /* Coordinator */, 988510D528572B8700CC2DA9 /* AppDelegate.swift */, + 005E48D52862AE91004BD5F6 /* Container.swift */, 988510DE28572B8900CC2DA9 /* Assets.xcassets */, 988510E028572B8900CC2DA9 /* LaunchScreen.storyboard */, 988510E328572B8900CC2DA9 /* Info.plist */, 0053D7542859B162006614EC /* Config.plist */, - 005E48D52862AE91004BD5F6 /* Container.swift */, ); path = IssueTracker; sourceTree = ""; @@ -413,6 +523,7 @@ files = ( 00BCB40A285B027B005E87BC /* Milestone.swift in Sources */, 005E48D828630635004BD5F6 /* NewIssueViewController.swift in Sources */, + 004800F8286E907300A58B2A /* Optionable.swift in Sources */, 985CAB4C28603AE9005E709D /* NetworkHeader.swift in Sources */, 985CAB47285C1A26005E709D /* UIStackView+Extension.swift in Sources */, 981BD43F285B12F300DDDE64 /* Margins.swift in Sources */, @@ -423,20 +534,37 @@ 985CAB5528607052005E709D /* UIView+Extension.swift in Sources */, 98130E1B285AC8A5001B6DA1 /* IssueViewController.swift in Sources */, 988510DA28572B8700CC2DA9 /* LoginViewController.swift in Sources */, + 003081F828A29ED3001B7600 /* NewIssueCoordinator.swift in Sources */, 00567EE8286194DB00F3E8EC /* IssueService.swift in Sources */, 981BD43D285B12AC00DDDE64 /* IssueListCell.swift in Sources */, + 003081F028A24E2C001B7600 /* Coordinator.swift in Sources */, + 00383AB0288A8306008B215E /* LoginModel.swift in Sources */, + 000B92C2286D67F8009EB2D8 /* NewIssueModel.swift in Sources */, 988510D628572B8700CC2DA9 /* AppDelegate.swift in Sources */, 985CAB49285C1A61005E709D /* UIColor+Extension.swift in Sources */, 005E48DA28636064004BD5F6 /* Devider.swift in Sources */, 985CAB4E28603AFF005E709D /* QueryParameter.swift in Sources */, 0053D7572859B4CA006614EC /* PrivateStorage.swift in Sources */, 00BCB408285B0262005E87BC /* Label.swift in Sources */, + 9844C195286D434B00D0CFC9 /* ReposModel.swift in Sources */, + 003081ED28A2486E001B7600 /* AppCoordinator.swift in Sources */, 983E66C6285B208800C9B975 /* GithubUserDefaults.swift in Sources */, + 004800F6286E8C0700A58B2A /* Assignee.swift in Sources */, + 0022D4D728D9A94700088543 /* ViewControllerCoordinator.swift in Sources */, + 000B92C4286D6816009EB2D8 /* Option.swift in Sources */, 005E48D62862AE91004BD5F6 /* Container.swift in Sources */, + 003081FA28A29EE2001B7600 /* OptionSelectCoordinator.swift in Sources */, + 003081F628A29EC8001B7600 /* IssueCoordinator.swift in Sources */, 00567EEC2861B49F00F3E8EC /* IssueModel.swift in Sources */, 00BCB40C285B076B005E87BC /* State.swift in Sources */, + 003081F228A28F8C001B7600 /* LoginCoordinator.swift in Sources */, + 9844C193286C803F00D0CFC9 /* ReposViewController.swift in Sources */, + 000B92C7286D6F9D009EB2D8 /* OptionSelectModel.swift in Sources */, + 004ABDE628BF516400F5CEC8 /* NewIssueFormat.swift in Sources */, 988FA2EA2864034C0058C333 /* OptionSelectViewController.swift in Sources */, + 003081F428A29EC0001B7600 /* ReposCoordinator.swift in Sources */, 00BCB406285B01AA005E87BC /* Issue.swift in Sources */, + 00AF8F282869A7060009674C /* Repository.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/IssueTracker/IssueTracker/AppDelegate.swift b/IssueTracker/IssueTracker/AppDelegate.swift index 46be59f078..203a3d5fa9 100644 --- a/IssueTracker/IssueTracker/AppDelegate.swift +++ b/IssueTracker/IssueTracker/AppDelegate.swift @@ -11,32 +11,29 @@ import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - private let container = Container() - var window: UIWindow? + var coordinator: AppCoordinator? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // TODO: - 토큰 유효기간 판단 - // 토큰저장할때: 토큰 & 토큰이 저장된 시간 & 유효시간(1일) - // 토큰이 유효한지 판단 - // UserDefaults.토큰이저장된시간 > 1일 - // { 다시 로그인을 해야된다고 판단 => UerDefaults.token 삭제 } + let navController = UINavigationController() + + coordinator = AppCoordinator(navigationController: navController) + coordinator?.start() + self.window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = container.buildRootViewController() + window?.rootViewController = navController window?.makeKeyAndVisible() return true } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - OAuthService().fetchToken(from: url) { [weak self] accessToken in - guard let token = accessToken else { - // TODO: 로그인 실패 얼럿띄우기 - return + coordinator?.fetchToken(url: url, completion: { [weak self] bool in + if bool { + self?.coordinator?.showRootViewController() } - GithubUserDefaults.setToken(token) - self?.window?.rootViewController = self?.container.buildRootViewController() - } + }) + return true } } diff --git a/IssueTracker/IssueTracker/Container.swift b/IssueTracker/IssueTracker/Container.swift index a32fbd6c19..2ab6b98ca9 100644 --- a/IssueTracker/IssueTracker/Container.swift +++ b/IssueTracker/IssueTracker/Container.swift @@ -1,38 +1,81 @@ -// -// Container.swift -// IssueTracker -// -// Created by Bibi on 2022/06/22. -// - import UIKit -struct Container { - enum Screen { - case login - case issue(token: String) - case newIssue +class Container { + + let environment: ContainerEnvironment + private var registeredObjects: [String: Any] = [:] + private var registeredViewControllerCoordinator: [UIViewController : Coordinator] = [:] + + init(environment: ContainerEnvironment) { + self.environment = environment + } + + // model, viewcontroller, coordinator를 생성 시점에 등록함 + func register(_ object: T) { + let key = String(describing: T.self) + registeredObjects[key] = object + if let viewControllerObject = object as? UIViewController { + registerPairCoordinator(with: viewControllerObject) + } + } + + // let value: Type = container.resolve() 로 사용 + func resolve() -> T? { + let key = String(describing: T.self) + guard let object = registeredObjects[key], + let object = object as? T else { + print("⚠️\(key)는 register되지 않음") + return nil + } + return object } - func buildRootViewController() -> UIViewController { - if let accessToken = GithubUserDefaults.getToken() { - return buildViewController(.issue(token: accessToken)) - } else { - return buildViewController(.login) + func registerPairCoordinator(with viewController: UIViewController) { + let viewControllerName = String(describing: type(of: viewController)) + + let allViewController = ViewControllerCoordinator.allCases + for oneCase in allViewController { + let oneCaseName = String(describing: oneCase) // Enum case의 이름을 String으로 변환 + if oneCaseName == viewControllerName { + let coordinatorName = oneCase.rawValue + guard let coordinator = registeredObjects[coordinatorName], + let castedCoordinator = coordinator as? Coordinator else { + return + } + registeredViewControllerCoordinator[viewController] = castedCoordinator + } } } - func buildViewController(_ screen: Screen) -> UIViewController { - switch screen { - case .login: - return LoginViewController(service: OAuthService()) - case .issue(let token): - let service = IssueService() - let model = IssueModel(service: service, token: token) - let viewController = IssueViewController(model: model) - return UINavigationController(rootViewController: vc) - case .newIssue: - return NewIssueViewController() + func resolvePair(of viewController: UIViewController) -> Coordinator? { + return registeredViewControllerCoordinator[viewController] + } + + func fetchAccessToken(url: URL, completion: @escaping (Bool) -> Void) { + environment.oAuthService.fetchToken(from: url) { [weak self] accessToken in + guard let token = accessToken, + let self = self else { + completion(false) + return + } + self.environment.githubUserDefaults.setToken(token) + self.environment.issueService.setAccessToken(token) + completion(true) } } } + +// Container에 필요한 Environment : 필요한 의존성을 가진 객체 +struct ContainerEnvironment { + var githubUserDefaults: GithubUserDefaults + var oAuthService: OAuthService + var issueService: IssueService + + static let live: ContainerEnvironment = { + let githubUserDefaults = GithubUserDefaults() + let token = githubUserDefaults.getToken() + + // MARK: 문제 발생 지점 - 로그인 전에는 토큰이 없으므로 ""가 들어감 + return ContainerEnvironment(githubUserDefaults: githubUserDefaults, oAuthService: OAuthService(), issueService: IssueService()) + }() +} diff --git a/IssueTracker/IssueTracker/Coordinator/AppCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/AppCoordinator.swift new file mode 100644 index 0000000000..6355a596fb --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/AppCoordinator.swift @@ -0,0 +1,164 @@ +// +// AppCoordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +class AppCoordinator: NSObject, Coordinator { + + let container = Container(environment: .live) + + let navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + // navigationController의 동작 감지 + navigationController.delegate = self + + showRootViewController() + } + + func fetchToken(url: URL, completion: @escaping (Bool) -> Void) { + container.fetchAccessToken(url: url) { bool in + completion(bool) + } + } + + func showRootViewController() { + container.environment.githubUserDefaults.getToken() != nil + ? showReposViewController() + : showLoginViewController() + } + + private func showLoginViewController() { + let coordinator = LoginCoordinator(navigationController: navigationController, container: container) + coordinator.delegate = self + container.register(coordinator) + coordinator.start() + self.childCoordinators.append(coordinator) + } + + private func showReposViewController() { + let coordinator = ReposCoordinator(navigationController: navigationController, container: container) + coordinator.delegate = self + container.register(coordinator) + coordinator.start() + self.childCoordinators.append(coordinator) + } + + private func showIssueViewController(repo: Repository) { + let coordinator = IssueCoordinator(navigationController: navigationController, container: container, repository: repo) + coordinator.delegate = self + container.register(coordinator) + coordinator.start() + self.childCoordinators.append(coordinator) + } + + private func showNewIssueViewController(repo: Repository) { + let coordinator = NewIssueCoordinator(navigationController: navigationController, container: container, repo: repo) + coordinator.delegate = self + container.register(coordinator) + coordinator.start() + self.childCoordinators.append(coordinator) + } + + private func showOptionSelectViewController(option: Option, repo: Repository) { + let coordinator = OptionSelectCoordinator(navigationController: navigationController, container: container, option: option, repo: repo) + coordinator.delegate = self + container.register(coordinator) + coordinator.start() + self.childCoordinators.append(coordinator) + } + + private func removeChildCoordinator(child: Coordinator) { + for (index, coordinator) in childCoordinators.enumerated() { + if coordinator === child { // 참조 비교 + childCoordinators.remove(at: index) + } + } + } +} + +// 아래에 delegate받아 처리할 메서드(화면전환) 로직 작성 +extension AppCoordinator: LoginCoordinatorDelegate { + func didLoggedIn(coordinator: LoginCoordinator) { + + DispatchQueue.main.async { [weak self] in + self?.navigationController.popViewController(animated: true) + } + } +} + +extension AppCoordinator: ReposCoordinatorDelegate { + func didSelect(repository: Repository) { + showIssueViewController(repo: repository) + } +} + +extension AppCoordinator: IssueCoordinatorDelegate { + func makeIssue(with repo: Repository) { + showNewIssueViewController(repo: repo) + } +} + +extension AppCoordinator: NewIssueCoordinatorDelegate { + + func showOptions(option: Option, repo: Repository) { + showOptionSelectViewController(option: option, repo: repo) + } + + func goBackToIssueVC(repo: Repository) { + DispatchQueue.main.async { [weak self] in + self?.navigationController.popViewController(animated: true) + } + + guard let issueCoordinator: IssueCoordinator = container.resolve() else { + return + } + issueCoordinator.fetchIssues() + } +} + +extension AppCoordinator: OptionSelectCoordinatorDelegate { + func goBackToNewIssueVC(item: Optionable, option: Option) { + DispatchQueue.main.async { + self.navigationController.popViewController(animated: true) + } + + guard let newIssueCoordinator: NewIssueCoordinator = container.resolve() else { + return + } + newIssueCoordinator.setOptions(item: item, option: option) + newIssueCoordinator.reloadOptions() + } + + +} + +extension AppCoordinator: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { // navCon이 새 뷰컨을 보여준 직후 호출되어, 어떤 뷰컨으로부터 이동했는지 확인 가능 + // MARK: 뭐하는 코드인지 공부하기 + guard let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else { + return + } + + // navStack에 존재한다면 - pop이 아닌 push된 것이므로 별도의 처리가 필요하지 않음 + if navigationController.viewControllers.contains(fromViewController) { + return + } + + // navStack에 존재하지 않음 = pop됨 : 해당 뷰컨의 coordinator를 childCoordinators에서 지워야 함 + if let coordinator = container.resolvePair(of: fromViewController) { + removeChildCoordinator(child: coordinator) + } + } +} diff --git a/IssueTracker/IssueTracker/Coordinator/Coordinator.swift b/IssueTracker/IssueTracker/Coordinator/Coordinator.swift new file mode 100644 index 0000000000..2e4aba979f --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/Coordinator.swift @@ -0,0 +1,15 @@ +// +// Coordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +protocol Coordinator: AnyObject { // 클래스에서만 사용 가능하도록 보장 + var navigationController: UINavigationController { get } + var childCoordinators: [Coordinator] { get set } + func start() +} diff --git a/IssueTracker/IssueTracker/Coordinator/IssueCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/IssueCoordinator.swift new file mode 100644 index 0000000000..426bd3beae --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/IssueCoordinator.swift @@ -0,0 +1,74 @@ +// +// IssueCoordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +protocol IssueCoordinatorDelegate { + func makeIssue(with repo: Repository) +} + +class IssueCoordinator: Coordinator { + + var navigationController: UINavigationController + var viewController: IssueViewController? + + var container: Container + + var childCoordinators: [Coordinator] = [] + + var delegate: IssueCoordinatorDelegate? + + var selectedRepo: Repository? + + init(navigationController: UINavigationController, container: Container, repository: Repository) { + self.navigationController = navigationController + self.container = container + self.selectedRepo = repository + } + + func start() { + guard let repo = selectedRepo else { + return + } + let model = IssueModel(environment: .init(requestRepositoryIssues: { [weak self] completion in + self?.container.environment.issueService.requestRepositoryIssues(repo: repo, completion: { result in + completion(result) + }) + })) + let viewcontroller = IssueViewController(model: model, repo: repo) + viewcontroller.delegate = self + self.viewController = viewcontroller + container.register(model) + container.register(viewcontroller) + + loadIssues() + self.navigationController.pushViewController(viewcontroller, animated: true) + + } + + func loadIssues() { + guard let viewController = self.viewController else { + return + } + viewController.loadIssue() + } + + func fetchIssues() { + guard let viewController = self.viewController else { + return + } + viewController.fetchIssue() + } + +} + +extension IssueCoordinator: IssueViewControllerDelegate { + func touchedNewIssueButton(repo: Repository) { + self.delegate?.makeIssue(with: repo) + } +} diff --git a/IssueTracker/IssueTracker/Coordinator/LoginCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/LoginCoordinator.swift new file mode 100644 index 0000000000..eeb428421a --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/LoginCoordinator.swift @@ -0,0 +1,43 @@ +import Foundation +import UIKit + +protocol LoginCoordinatorDelegate { + func didLoggedIn(coordinator: LoginCoordinator) +} + +class LoginCoordinator: Coordinator { + + let navigationController: UINavigationController + + let container: Container + var childCoordinators: [Coordinator] = [] + + var delegate: LoginCoordinatorDelegate? + + init(navigationController: UINavigationController, container: Container) { + self.navigationController = navigationController + self.container = container + } + + func start() { + let environment = LoginModelEnvironment { [weak self] completion in + self?.container.environment.oAuthService.requestCode(completion: { result in + completion(result) + }) + } + let model = LoginModel(environment: environment) + let viewController = LoginViewController(model: model) + + container.register(model) + container.register(viewController) + viewController.delegate = self + + navigationController.pushViewController(viewController, animated: false) + } +} + +extension LoginCoordinator: LoginViewControllerDelegate { + func login() { + self.delegate?.didLoggedIn(coordinator: self) + } +} diff --git a/IssueTracker/IssueTracker/Coordinator/NewIssueCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/NewIssueCoordinator.swift new file mode 100644 index 0000000000..7b1932f99a --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/NewIssueCoordinator.swift @@ -0,0 +1,73 @@ +// +// NewIssueCoordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +protocol NewIssueCoordinatorDelegate { + func goBackToIssueVC(repo: Repository) + + func showOptions(option: Option, repo: Repository) +} + +class NewIssueCoordinator: Coordinator { + + var delegate: NewIssueCoordinatorDelegate? + + var navigationController: UINavigationController + var viewController: NewIssueViewController? + + var childCoordinators: [Coordinator] = [] + + private var repo: Repository + private var container: Container + + init(navigationController: UINavigationController, container: Container, repo: Repository) { + self.navigationController = navigationController + self.container = container + self.repo = repo + } + + func start() { + let modelEnvironment = NewIssueModelEnvironment { [weak self] newIssueFormat, completion in + self?.container.environment.issueService.createIssue(newIssue: newIssueFormat, completion: completion) + } requestRepositoryIssues: { [weak self] completion in + guard let self = self else { + return + } + self.container.environment.issueService.requestRepositoryIssues(repo: self.repo, completion: completion) + } + + let model = NewIssueModel(environment: modelEnvironment) + let newIssueVC = NewIssueViewController(repo: repo, model: model) + + container.register(model) + container.register(newIssueVC) + + newIssueVC.delegate = self + + navigationController.pushViewController(newIssueVC, animated: true) + } + + func reloadOptions() { + self.viewController?.reloadOptions() + } + + func setOptions(item: Optionable, option: Option) { + self.viewController?.setSelectedOption(item: item, option: option) + } +} + +extension NewIssueCoordinator: NewIssueViewControllerDelegate { + func touchedOption(option: Option, repo: Repository) { + self.delegate?.showOptions(option: option, repo: repo) + } + + func goBackToPreviousVC(repo: Repository) { + self.delegate?.goBackToIssueVC(repo: repo) + } +} diff --git a/IssueTracker/IssueTracker/Coordinator/OptionSelectCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/OptionSelectCoordinator.swift new file mode 100644 index 0000000000..c8c8c1dbab --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/OptionSelectCoordinator.swift @@ -0,0 +1,63 @@ +// +// OptionSelectCoordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +protocol OptionSelectCoordinatorDelegate { + func goBackToNewIssueVC(item: Optionable, option: Option) +} + +class OptionSelectCoordinator: Coordinator { + + var delegate: OptionSelectCoordinatorDelegate? + + var navigationController: UINavigationController + + var childCoordinators: [Coordinator] = [] + + private var container: Container + private var option: Option + private var repo: Repository + + + init(navigationController: UINavigationController, container: Container, option: Option, repo: Repository) { + self.navigationController = navigationController + self.container = container + self.option = option + self.repo = repo + } + + func start() { + let optionSelectModelEnvironment = OptionSelectModelEnvironment { + [weak self] repo, completion in + self?.container.environment.issueService.requestRepositoryLabels(repo: repo, completion: completion) + } requestRepositoryMilestones: { + [weak self] repo, completion in + self?.container.environment.issueService.requestRepositoryMilestones(repo: repo, completion: completion) + } requestRepositoryAssigness: { + [weak self] repo, completion in + self?.container.environment.issueService.requestRepositoryAssigness(repo: repo, completion: completion) + } + + let optionSelectModel = OptionSelectModel(environment: optionSelectModelEnvironment) + let optionSelectVC = OptionSelectViewController(model: optionSelectModel, option: option, repo: repo) + + container.register(optionSelectModel) + container.register(optionSelectVC) + + optionSelectVC.delegate = self + + navigationController.pushViewController(optionSelectVC, animated: true) + } +} + +extension OptionSelectCoordinator: OptionSelectViewControllerDelegate { + func selected(item: Optionable, option: Option) { + self.delegate?.goBackToNewIssueVC(item: item, option: option) + } +} diff --git a/IssueTracker/IssueTracker/Coordinator/ReposCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/ReposCoordinator.swift new file mode 100644 index 0000000000..9ee11fc205 --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/ReposCoordinator.swift @@ -0,0 +1,56 @@ +// +// ReposCoordinator.swift +// IssueTracker +// +// Created by Bibi on 2022/08/09. +// + +import Foundation +import UIKit + +protocol ReposCoordinatorDelegate { + func didSelect(repository: Repository) +} + +class ReposCoordinator: Coordinator { + var container: Container + + var childCoordinators: [Coordinator] = [] + + let navigationController: UINavigationController + + var delegate: ReposCoordinatorDelegate? + + init(navigationController: UINavigationController, container: Container) { + self.navigationController = navigationController + self.container = container + } + + func start() { + let environment = ReposModelEnvironment(requestRepos: { [weak self] completion in + self?.container.environment.issueService.requestRepos(completion: { result in + completion(result) + }) + }) + let model = ReposModel(environment: environment) + let viewController = ReposViewController(model: model) + + container.register(model) + container.register(viewController) + + viewController.delegate = self + + navigationController.pushViewController(viewController, animated: false) + } +} + +extension ReposCoordinator: ReposViewControllerDelegate { + func showIssue(didSelectRowAt indexPath: IndexPath) { + guard let model: ReposModel = container.resolve() else { + return + } + let selectedRepo = model.getViewData(index: indexPath.row) + self.delegate?.didSelect(repository: selectedRepo) + } +} + diff --git a/IssueTracker/IssueTracker/Coordinator/ViewControllerCoordinator.swift b/IssueTracker/IssueTracker/Coordinator/ViewControllerCoordinator.swift new file mode 100644 index 0000000000..8111315889 --- /dev/null +++ b/IssueTracker/IssueTracker/Coordinator/ViewControllerCoordinator.swift @@ -0,0 +1,16 @@ +// +// CoordinatorViewController.swift +// IssueTracker +// +// Created by Bibi on 2022/09/20. +// + +import Foundation + +enum ViewControllerCoordinator: String, CaseIterable { + case LoginViewController = "LoginCoordinator" + case ReposViewController = "ReposCoordinator" + case IssueViewController = "IssueCoordinator" + case NewIssueViewController = "NewIssueCoordinator" + case OptionSelectViewController = "OptionSelectCoordinator" +} diff --git a/IssueTracker/IssueTracker/Entity/Assignee.swift b/IssueTracker/IssueTracker/Entity/Assignee.swift new file mode 100644 index 0000000000..4c30cfb96f --- /dev/null +++ b/IssueTracker/IssueTracker/Entity/Assignee.swift @@ -0,0 +1,16 @@ +// +// Assignee.swift +// IssueTracker +// +// Created by Bibi on 2022/07/01. +// + +import Foundation + +struct Assignee: Codable, Optionable { + var subTitle: String { + self.login + } + + let login: String +} diff --git a/IssueTracker/IssueTracker/Entity/Issue.swift b/IssueTracker/IssueTracker/Entity/Issue.swift index 78c5c29d94..216cea13c9 100644 --- a/IssueTracker/IssueTracker/Entity/Issue.swift +++ b/IssueTracker/IssueTracker/Entity/Issue.swift @@ -16,11 +16,6 @@ struct Issue: Codable { let repository: Repository } -struct Repository: Codable { - let name: String - let owner: Owner -} - struct Owner: Codable { let login: String } diff --git a/IssueTracker/IssueTracker/Entity/Label.swift b/IssueTracker/IssueTracker/Entity/Label.swift index 8eb852e00a..256709968e 100644 --- a/IssueTracker/IssueTracker/Entity/Label.swift +++ b/IssueTracker/IssueTracker/Entity/Label.swift @@ -8,7 +8,11 @@ import Foundation // MARK: - Label -struct Label: Codable { +struct Label: Codable, Optionable { + var subTitle: String { + self.name + } + let id: Int let url: String let name, color: String diff --git a/IssueTracker/IssueTracker/Entity/Milestone.swift b/IssueTracker/IssueTracker/Entity/Milestone.swift index 0e3ad48cea..2ff611585a 100644 --- a/IssueTracker/IssueTracker/Entity/Milestone.swift +++ b/IssueTracker/IssueTracker/Entity/Milestone.swift @@ -8,7 +8,12 @@ import Foundation // MARK: - Milestone -struct Milestone: Codable { +struct Milestone: Codable, Optionable { + var subTitle: String { + self.title + } + + let number: Int let title: String let description: String let openIssues, closedIssues: Int diff --git a/IssueTracker/IssueTracker/Entity/Optionable.swift b/IssueTracker/IssueTracker/Entity/Optionable.swift new file mode 100644 index 0000000000..9fd72d4ac8 --- /dev/null +++ b/IssueTracker/IssueTracker/Entity/Optionable.swift @@ -0,0 +1,12 @@ +// +// Optionable.swift +// IssueTracker +// +// Created by Bibi on 2022/07/01. +// + +import Foundation + +protocol Optionable { + var subTitle: String { get } +} diff --git a/IssueTracker/IssueTracker/Entity/Repository.swift b/IssueTracker/IssueTracker/Entity/Repository.swift new file mode 100644 index 0000000000..bba12f1826 --- /dev/null +++ b/IssueTracker/IssueTracker/Entity/Repository.swift @@ -0,0 +1,14 @@ +// +// Repository.swift +// IssueTracker +// +// Created by Bibi on 2022/06/27. +// + +import Foundation + + +struct Repository: Codable { + let name: String + let owner: Owner +} diff --git a/IssueTracker/IssueTracker/Extension/UIStackView+Extension.swift b/IssueTracker/IssueTracker/Extension/UIStackView+Extension.swift index 8afb8bf4d5..fcb608f3c7 100644 --- a/IssueTracker/IssueTracker/Extension/UIStackView+Extension.swift +++ b/IssueTracker/IssueTracker/Extension/UIStackView+Extension.swift @@ -2,8 +2,8 @@ import UIKit extension UIStackView { func clearSubviews() { - self.arrangedSubviews.forEach { view in - self.removeArrangedSubview(view) + arrangedSubviews.forEach { [weak self] view in + self?.removeArrangedSubview(view) view.removeFromSuperview() } } diff --git a/IssueTracker/IssueTracker/GitHub/RequestURL.swift b/IssueTracker/IssueTracker/GitHub/RequestURL.swift index f12846bb47..81fd0e6ace 100644 --- a/IssueTracker/IssueTracker/GitHub/RequestURL.swift +++ b/IssueTracker/IssueTracker/GitHub/RequestURL.swift @@ -4,8 +4,13 @@ enum RequestURL: CustomStringConvertible { case authorize case accessToken case issues + case userIssues case createIssue(owner: String, repo: String) case repos + case repositoryIssue(owner: String, repo: String) + case repositoryLabels(owner: String, repo: String) + case repositoryMilestones(owner: String, repo: String) + case repositoryAssignees(owner: String, repo: String) var description: String { switch self { @@ -15,10 +20,20 @@ enum RequestURL: CustomStringConvertible { return "https://github.com/login/oauth/access_token" case .issues: return "https://api.github.com/issues" + case .userIssues: + return "https://api.github.com/user/issues" case let .createIssue(owner, repo): return "https://api.github.com/repos/\(owner)/\(repo)/issues" case .repos: - return "https://github.com/user/repos" + return "https://api.github.com/user/repos" + case let .repositoryIssue(owner, repo): + return "https://api.github.com/repos/\(owner)/\(repo)/issues" + case let .repositoryLabels(owner, repo): + return "https://api.github.com/repos/\(owner)/\(repo)/labels" + case let .repositoryMilestones(owner, repo): + return "https://api.github.com/repos/\(owner)/\(repo)/milestones" + case let .repositoryAssignees(owner, repo): + return "https://api.github.com/repos/\(owner)/\(repo)/assignees" } } } diff --git a/IssueTracker/IssueTracker/Issue/IssueViewController.swift b/IssueTracker/IssueTracker/Issue/IssueViewController.swift index 0cf1bcfd96..bee9a1c337 100644 --- a/IssueTracker/IssueTracker/Issue/IssueViewController.swift +++ b/IssueTracker/IssueTracker/Issue/IssueViewController.swift @@ -1,60 +1,72 @@ import UIKit -final class IssueViewController: UIViewController { +protocol IssueViewControllerDelegate: AnyObject { + func touchedNewIssueButton(repo: Repository) +} - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .vertical - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.register(IssueListCell.self, forCellWithReuseIdentifier: IssueListCell.identifier) - collectionView.dataSource = self - collectionView.delegate = self - return collectionView - }() +final class IssueViewController: UIViewController { - private lazy var addButton: UIButton = { - var configuration = UIButton.Configuration.filled() -// configuration.image = UIImage(systemName: "plus") - configuration.baseBackgroundColor = .systemBlue - configuration.baseForegroundColor = .white - configuration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5) - configuration.background.cornerRadius = 70 - var button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] _ in - self?.touchedAddButton() - })) - button.setImage(UIImage(systemName: "plus"), for: .normal) - -// button.layer.cornerRadius = 50 / 2 - - return button - }() + private let model: IssueModel + private let repo: Repository - private var model: IssueModel + weak var delegate: IssueViewControllerDelegate? - init(model: IssueModel) { + init(model: IssueModel, repo: Repository) { self.model = model + self.repo = repo super.init(nibName: nil, bundle: nil) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + deinit { + print("-- \(type(of: self)) is deinited") + } + + required convenience init?(coder: NSCoder) { + self.init(coder: coder) } // MARK: ViewDidLoad override func viewDidLoad() { super.viewDidLoad() + + self.title = "Issues" + self.view.backgroundColor = .white setupNavigationBar() setupViews() - model.requestIssue() - model.updatedIssues = { [weak self] issues in - DispatchQueue.main.async { [weak self] in + + model.issuesUpdated = { + DispatchQueue.main.async { [weak self] in + self?.collectionView.reloadData() + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.navigationBar.prefersLargeTitles = true + } + + func loadIssue() { + self.model.requestIssue { titleArr in + if titleArr != nil { + DispatchQueue.main.async { [weak self] in + self?.collectionView.reloadData() + } + } + } + } + + func fetchIssue() { + self.model.requestIssue { titleArr in + DispatchQueue.main.async { [weak self] in self?.collectionView.reloadData() } } } @objc func touchedSelectButton() { + } @objc func touchedFilterButton() { @@ -64,18 +76,10 @@ final class IssueViewController: UIViewController { } @objc func touchedAddButton() { - self.navigationController?.pushViewController(Container().buildViewController(.newIssue), animated: true) + self.delegate?.touchedNewIssueButton(repo: repo) } private func setupNavigationBar() { - self.title = "이슈" -// self.navigationController?.navigationBar.prefersLargeTitles = true - - let filterButton = createButton(title: "필터", image: UIImage(systemName: "scroll"), action: UIAction(handler: { [weak self] _ in - self?.touchedFilterButton() - })) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: filterButton) - let selectButton = createButton(title: "선택", image: UIImage(systemName: "checkmark.circle"), action: UIAction(handler: { [weak self] _ in self?.touchedSelectButton() })) @@ -103,6 +107,30 @@ final class IssueViewController: UIViewController { } } + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(IssueListCell.self, forCellWithReuseIdentifier: IssueListCell.identifier) + collectionView.dataSource = self + collectionView.delegate = self + return collectionView + }() + + private lazy var addButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.baseBackgroundColor = .systemBlue + configuration.baseForegroundColor = .white + configuration.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5) + configuration.background.cornerRadius = 70 + var button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] _ in + self?.touchedAddButton() + })) + button.setImage(UIImage(systemName: "plus"), for: .normal) + + return button + }() + private func createButton(title: String, image: UIImage?, action: UIAction) -> UIButton { var configuration = UIButton.Configuration.plain() var container = AttributeContainer() diff --git a/IssueTracker/IssueTracker/Issue/Model/IssueModel.swift b/IssueTracker/IssueTracker/Issue/Model/IssueModel.swift index f7abc983af..4dcbf59fa7 100644 --- a/IssueTracker/IssueTracker/Issue/Model/IssueModel.swift +++ b/IssueTracker/IssueTracker/Issue/Model/IssueModel.swift @@ -9,19 +9,17 @@ import Foundation class IssueModel { - private let service: IssueService - private let accessToken: String + private let environment: IssueModelEnvironment - init(service: IssueService, token: String) { - self.service = service - self.accessToken = token + init(environment: IssueModelEnvironment) { + self.environment = environment } - var updatedIssues: ( (_ issues: [Issue]) -> Void )? + var issuesUpdated: ( () -> Void )? private var issues: [Issue] = [] { didSet { - updatedIssues?(issues) + issuesUpdated?() } } @@ -36,14 +34,22 @@ class IssueModel { return nil } - func requestIssue() { - service.requestIssues(accessToken: accessToken) { result in + func requestIssue(completion: @escaping ([String]?) -> Void) { + environment.requestRepositoryIssues() { result in switch result { case .success(let issues): self.issues = issues + let issuesTitleArr = issues.map{ $0.title } + completion(issuesTitleArr) case .failure(let error): print(error.localizedDescription) + completion(nil) } } } } + +struct IssueModelEnvironment { + // completion을 파라미터로 받고, 리턴타입은 Void인 클로저 + let requestRepositoryIssues: (@escaping (Result<[Issue], IssueError>) -> Void) -> Void +} diff --git a/IssueTracker/IssueTracker/Issue/NewIssueViewController.swift b/IssueTracker/IssueTracker/Issue/NewIssueViewController.swift deleted file mode 100644 index 2179868e16..0000000000 --- a/IssueTracker/IssueTracker/Issue/NewIssueViewController.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// NewIssueViewController.swift -// IssueTracker -// -// Created by Bibi on 2022/06/22. -// - -import UIKit - -class NewIssueViewController: UIViewController { - - private lazy var navSegmentedControl: UISegmentedControl = { - let buttonList = ["마크다운", "미리보기"] - var control = UISegmentedControl(items: buttonList) - - return control - }() - - private lazy var titleLabel: UILabel = { - var label = UILabel() - label.text = "제목" - label.font = UIFont.systemFont(ofSize: 20, weight: .bold) - return label - }() - - private lazy var titleField: UITextField = { - var textField = UITextField() - return textField - }() - - private lazy var contentField: UITextView = { - var contentField = UITextView() - return contentField - }() - - private lazy var horizontalDevider: UIView = { - var devider = Devider(direction: .horizontal(width: self.view.bounds.width), color: .systemGray) - return devider - }() - - private let optionList = ["저장소", "레이블", "마일스톤", "담당자"] - private let optionTableCellIdentifier = "optionTableCellIdentifier" - - private lazy var optionTable: UITableView = { - var tableView = UITableView() - tableView.delegate = self - tableView.dataSource = self - tableView.register(UITableViewCell.self, - forCellReuseIdentifier: optionTableCellIdentifier) - return tableView - }() - - private lazy var optionTableHeader: UILabel = { - var label = UILabel() - label.text = "추가옵션" - label.font = UIFont.systemFont(ofSize: 20, weight: .bold) - label.textAlignment = .left - return label - }() - - override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setUpViews() - } - - private func setupNavigationBar() { - self.navigationItem.titleView = navSegmentedControl - self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: createButton) - } - - private func setUpViews() { - self.view.backgroundColor = .white - - self.view.addSubview(titleLabel) - titleLabel.snp.makeConstraints { make in - make.top.leading.equalTo(self.view.safeAreaLayoutGuide).offset(10) - make.width.equalTo(50) - } - - self.view.addSubview(titleField) - titleField.snp.makeConstraints { make in - make.top.trailing.equalTo(self.view.safeAreaLayoutGuide).offset(10) - make.leading.equalTo(titleLabel.snp.trailing) - } - - self.view.addSubview(horizontalDevider) - horizontalDevider.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(5) - make.leading.equalTo(self.view.safeAreaLayoutGuide).offset(5) - make.trailing.equalTo(self.view.safeAreaLayoutGuide).offset(-5) - make.height.equalTo(1) - } - - optionTable.tableHeaderView = optionTableHeader - optionTable.tableHeaderView?.frame.size.height = 30 - - self.view.addSubview(optionTable) - optionTable.isScrollEnabled = true - optionTable.snp.makeConstraints { make in - make.leading.trailing.bottom.equalTo(self.view.safeAreaLayoutGuide) - make.height.equalTo(optionTable - .contentSize - .height + 30) - } - - self.view.addSubview(contentField) - contentField.snp.makeConstraints { make in - make.top.equalTo(horizontalDevider.snp.bottom) - make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide) - make.bottom.equalTo(optionTable.snp.top) - - } - } - - private lazy var createButton: UIButton = { - var configuration = UIButton.Configuration.plain() - var container = AttributeContainer() - container.font = UIFont.systemFont(ofSize: 14) - configuration.attributedTitle = AttributedString("저장", attributes: container) - - configuration.buttonSize = .small - configuration.image = UIImage(systemName: "folder.badge.plus") - configuration.imagePadding = 4 - let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { action in - self.touchedCreateButton() - })) - return button - }() - - private func touchedCreateButton() { - // TODO: 이슈생성 - //1. api 호출 - //2. api 가 성공적으로 응답을 보내줬다면 => - //2-1. 이전 화면으로 돌아가고 - //2-2. 이슈 목록 조회 다시해서 보여주기 - //3. api 가 실패했다면 => issue 생성실패 얼럿띄우기 - - } -} - -extension NewIssueViewController: UITableViewDelegate { - -} - -extension NewIssueViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return optionList.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: optionTableCellIdentifier, - for: indexPath) - var sidebarCell = UIListContentConfiguration.sidebarCell() - sidebarCell.text = optionList[indexPath.item] - sidebarCell.secondaryText = "선택내용" - sidebarCell.prefersSideBySideTextAndSecondaryText = true - - cell.contentConfiguration = sidebarCell - cell.accessoryType = .disclosureIndicator - return cell - } - -} diff --git a/IssueTracker/IssueTracker/Issue/OptionSelectViewController.swift b/IssueTracker/IssueTracker/Issue/OptionSelectViewController.swift deleted file mode 100644 index f81c5d0518..0000000000 --- a/IssueTracker/IssueTracker/Issue/OptionSelectViewController.swift +++ /dev/null @@ -1,52 +0,0 @@ -import UIKit -import SnapKit - -class OptionSelectViewController: UIViewController { - - private let dummy = ["bug", "feature", "document"] - private let tableViewCellIdentifier = "tableViewCellIdentifier" - - override func viewDidLoad() { - super.viewDidLoad() - setupViews() - self.view.backgroundColor = .white - } - - private func setupViews() { - view.addSubview(tableView) - tableView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - } - - private lazy var tableView: UITableView = { - let tableView = UITableView() - tableView.register(UITableViewCell.self, - forCellReuseIdentifier: tableViewCellIdentifier) - tableView.delegate = self - tableView.dataSource = self - return tableView - }() - -} - -extension OptionSelectViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // TODO: - 선택한 옵션을 이전 ViewController 에 넘겨주기 - } -} - -extension OptionSelectViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return dummy.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: tableViewCellIdentifier, - for: indexPath) - var content = cell.defaultContentConfiguration() - content.attributedText = NSAttributedString(string: dummy[indexPath.row]) - cell.contentConfiguration = content - return cell - } -} diff --git a/IssueTracker/IssueTracker/Issue/Service/IssueService.swift b/IssueTracker/IssueTracker/Issue/Service/IssueService.swift index e09202923e..3b4c6ddf6c 100644 --- a/IssueTracker/IssueTracker/Issue/Service/IssueService.swift +++ b/IssueTracker/IssueTracker/Issue/Service/IssueService.swift @@ -8,27 +8,36 @@ import Foundation import Alamofire - -enum IssueError: Error { - case issueNotFound - case cannotCreateIssue -} - -struct IssueService { +class IssueService { + + private var accessToken: String? - func requestIssues(accessToken: String, completion: @escaping (Result<[Issue], IssueError>) -> Void) { + func setAccessToken(_ token: String) { + self.accessToken = token + } + + func requestIssues(completion: @escaping (Result<[Issue], IssueError>) -> Void) { + guard let token = accessToken else { + return + } + let urlString = RequestURL.issues.description let headers: HTTPHeaders = [ NetworkHeader.acceptV3.getHttpHeader(), - NetworkHeader.authorization(accessToken: accessToken).getHttpHeader() + NetworkHeader.authorization(accessToken: token).getHttpHeader() ] let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let globalThread = DispatchQueue.global(qos: .default) - AF.request(urlString, method: .get, headers: headers) - .responseDecodable(of: [Issue].self, queue: globalThread, decoder: decoder) { (response) in + + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [Issue].self, + queue: globalThread, + decoder: decoder) { (response) in switch response.result { case let .success(decodeData): completion(.success(decodeData)) @@ -38,43 +47,240 @@ struct IssueService { } } - func createIssue(title: String, accessToken: String, completion: @escaping (Result) -> Void) { - let urlString = RequestURL.createIssue(owner: "Jinsujin", repo: "issue-tracker").description + func createIssue(newIssue: NewIssueFormat, completion: @escaping (Bool) -> Void) { + guard let token = accessToken else { + return + } + + let urlString = RequestURL.createIssue(owner: newIssue.repo.owner.login, repo: newIssue.repo.name).description let headers: HTTPHeaders = [ NetworkHeader.acceptV3.getHttpHeader(), - NetworkHeader.authorization(accessToken: accessToken).getHttpHeader() + NetworkHeader.authorization(accessToken: token).getHttpHeader() ] + var labelList: [String] = [] + var assigneeList: [String] = [] + if let label = newIssue.label, + let assignee = newIssue.assignee { + labelList.append(label.name) + assigneeList.append(assignee.login) + } + let parameters: [String: Any] = [ - "title": title + "title": newIssue.title, + "body": newIssue.content, + "labels": labelList, + "milestone": newIssue.milestone?.number, + "assignees": assigneeList + ] + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let globalThread = DispatchQueue.global(qos: .default) + + AF.request(urlString, + method: .post, + parameters: parameters, + encoding: JSONEncoding.default, + headers: headers) + .response(queue: globalThread) { response in + switch response.result { + case .success: + completion(true) + case .failure(let error): + print(error) + completion(false) + } + } + } + + func requestRepositoryIssues(repo: Repository, completion: @escaping (Result<[Issue], IssueError>) -> Void) { + guard let token = accessToken else { + return + } + + let urlString = RequestURL.repositoryIssue(owner: repo.owner.login, repo: repo.name).description + let headers: HTTPHeaders = [ + NetworkHeader.acceptV3.getHttpHeader(), + NetworkHeader.authorization(accessToken: token).getHttpHeader() ] let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - AF.request(urlString, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers) - .responseDecodable(of: Issue.self, decoder: decoder) { response in - switch response.result { - case let .success(decodeData): - completion(.success(decodeData)) - case .failure(let error): - completion(.failure(.cannotCreateIssue)) + let globalThread = DispatchQueue.global(qos: .default) + + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [RepositoryIssue].self, + queue: globalThread, + decoder: decoder) { response in + switch response.result { + case .success(let data): + var result: [Issue] = [] + for entity in data { + // pullRequest 가 없으면(nil) 일반이슈, 있으면 PR 이슈 + if entity.pullRequest != nil { + continue + } + let issue = Issue(title: entity.title, body: entity.body, state: entity.state, labels: entity.labels, milestone: entity.milestone, repository: repo) + result.append(issue) } + completion(.success(result)) + case .failure(let error): + print(error) + completion(.failure(.issueNotFound)) } + } } - func requestRepos(accessToken: String, completion: @escaping (Result<[Repository], IssueError>) -> Void) { + func requestRepos(completion: @escaping (Result<[Repository], IssueError>) -> Void) { + guard let token = accessToken else { + return + } + let urlString = RequestURL.repos.description let headers: HTTPHeaders = [ NetworkHeader.acceptV3.getHttpHeader(), - NetworkHeader.authorization(accessToken: accessToken).getHttpHeader() + NetworkHeader.authorization(accessToken: token).getHttpHeader() + ] + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let globalThread = DispatchQueue.global(qos: .default) + + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [Repository].self, + queue: globalThread, + decoder: decoder) { response in + switch response.result { + case .success(let data): + completion(.success(data)) + case .failure: + completion(.failure(.repoNotFound)) + } + } + } + + func requestRepositoryLabels(repo: Repository, completion: @escaping (Result<[Label], OptionError>) -> Void) { + guard let token = accessToken else { + return + } + + let urlString = RequestURL.repositoryLabels(owner: repo.owner.login, repo: repo.name).description + let headers: HTTPHeaders = [ + NetworkHeader.acceptV3.getHttpHeader(), + NetworkHeader.authorization(accessToken: token).getHttpHeader() + ] + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let globalThread = DispatchQueue.global(qos: .default) + + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [Label].self, + queue: globalThread, + decoder: decoder) { response in + switch response.result { + case .success(let data): + completion(.success(data)) + case .failure: + completion(.failure(.labelNotFound)) + } + } + } + + + func requestRepositoryMilestones(repo: Repository, completion: @escaping (Result<[Milestone], OptionError>) -> Void) { + guard let token = accessToken else { + return + } + + let urlString = RequestURL.repositoryMilestones(owner: repo.owner.login, repo: repo.name).description + let headers: HTTPHeaders = [ + NetworkHeader.acceptV3.getHttpHeader(), + NetworkHeader.authorization(accessToken: token).getHttpHeader() + ] + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let globalThread = DispatchQueue.global(qos: .default) + + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [Milestone].self, + queue: globalThread, + decoder: decoder) { response in + switch response.result { + case .success(let data): + completion(.success(data)) + case .failure: + completion(.failure(.milestonesNotFound)) + } + } + } + + + func requestRepositoryAssigness(repo: Repository, completion: @escaping (Result<[Assignee], OptionError>) -> Void) { + guard let token = accessToken else { + return + } + + let urlString = RequestURL.repositoryAssignees(owner: repo.owner.login, repo: repo.name).description + let headers: HTTPHeaders = [ + NetworkHeader.acceptV3.getHttpHeader(), + NetworkHeader.authorization(accessToken: token).getHttpHeader() ] let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase + let globalThread = DispatchQueue.global(qos: .default) - AF.request(urlString, method: .get, headers: headers) - .responseData(queue: DispatchQueue.global(qos: .default)) { response in - print("statusCode == ", response.response?.statusCode) + AF.request(urlString, + method: .get, + headers: headers) + .responseDecodable(of: [Assignee].self, + queue: globalThread, + decoder: decoder) { response in + switch response.result { + case .success(let data): + completion(.success(data)) + case .failure(let error): + print(error) + completion(.failure(.assigneesNotFound)) } + } + } +} + +fileprivate struct RepositoryIssue: Codable { + let title: String + let body: String? + let state: String + let labels: [Label] + let milestone: Milestone? + let pullRequest: PullRequest? + + struct PullRequest: Codable { + let url: String + let htmlUrl: String + let diffUrl: String + let patchUrl: String + let mergedAt: String? } } + +enum IssueError: Error { + case issueNotFound + case cannotCreateIssue + case repoNotFound +} + +enum OptionError: Error { + case labelNotFound + case milestonesNotFound + case assigneesNotFound +} diff --git a/IssueTracker/IssueTracker/Issue/View/IssueListCell.swift b/IssueTracker/IssueTracker/Issue/View/IssueListCell.swift index e8c2e12fb7..f55e2eb86b 100644 --- a/IssueTracker/IssueTracker/Issue/View/IssueListCell.swift +++ b/IssueTracker/IssueTracker/Issue/View/IssueListCell.swift @@ -16,7 +16,6 @@ final class IssueListCell: UICollectionViewCell { self.titleLabel.text = title self.descriptionLabel.text = description self.milestoneLabel.text = milestone - // TODO: - 스택뷰를 ScrollView 로 바꾸기 self.labelsStackView.clearSubviews() for label in labels { let view = CapsuleTextView(title: label.name, hexColor: label.color) diff --git a/IssueTracker/IssueTracker/Login/LoginModel.swift b/IssueTracker/IssueTracker/Login/LoginModel.swift new file mode 100644 index 0000000000..a8d8b72306 --- /dev/null +++ b/IssueTracker/IssueTracker/Login/LoginModel.swift @@ -0,0 +1,27 @@ +// +// LoginModel.swift +// IssueTracker +// +// Created by Bibi on 2022/07/22. +// + +import Foundation + +class LoginModel { + + private let environment: LoginModelEnvironment + + init(environment: LoginModelEnvironment) { + self.environment = environment + } + + func requestCode(completion: @escaping (Result) -> Void) { + environment.requestCode { result in + completion(result) + } + } +} + +struct LoginModelEnvironment { + let requestCode: (@escaping (Result) -> Void) -> Void +} diff --git a/IssueTracker/IssueTracker/Login/LoginViewController.swift b/IssueTracker/IssueTracker/Login/LoginViewController.swift index 466b3faa6a..c97d462d39 100644 --- a/IssueTracker/IssueTracker/Login/LoginViewController.swift +++ b/IssueTracker/IssueTracker/Login/LoginViewController.swift @@ -9,22 +9,38 @@ import UIKit import SnapKit import Alamofire +protocol LoginViewControllerDelegate: AnyObject { + func login() +} + class LoginViewController: UIViewController { + + weak var delegate: LoginViewControllerDelegate? + + private var model: LoginModel - private var oauthService: OAuthService? + init(model: LoginModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + self.model = LoginModel(environment: .init(requestCode: { completion in + })) + super.init(coder: coder) + } - convenience init(service: OAuthService) { - self.init() - self.oauthService = service + deinit { + print("-- \(type(of: self)) is deinited") } override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .white + view.backgroundColor = .white setupView() } - private func setupView() { + func setupView() { view.addSubview(loginButton) loginButton.snp.makeConstraints { make in make.center.equalToSuperview() @@ -43,19 +59,19 @@ class LoginViewController: UIViewController { configuration.buttonSize = .large configuration.image = UIImage(named: "GitHub-Mark") configuration.imagePadding = 8 - let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { _ in - self.touchedLoginButton() + let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] _ in + self?.loginButtonTapped() })) return button }() - private func touchedLoginButton() { - oauthService?.requestCode { result in + func loginButtonTapped() { + model.requestCode { [weak self] result in // TODO: delegate로 옮기기 switch result { case .success(let url): UIApplication.shared.open(url) + self?.delegate?.login() case .failure(let error): - // TODO: - 로그인 하지 못했을때 에러처리 print(error) } } diff --git a/IssueTracker/IssueTracker/NewIssue/Model/NewIssueFormat.swift b/IssueTracker/IssueTracker/NewIssue/Model/NewIssueFormat.swift new file mode 100644 index 0000000000..0518014816 --- /dev/null +++ b/IssueTracker/IssueTracker/NewIssue/Model/NewIssueFormat.swift @@ -0,0 +1,18 @@ +// +// NewIssueFormat.swift +// IssueTracker +// +// Created by Bibi on 2022/08/31. +// + +import Foundation + +struct NewIssueFormat { + let title: String + let repo: Repository + let content: String + let label: Label? + let milestone: Milestone? + let assignee: Assignee? + +} diff --git a/IssueTracker/IssueTracker/NewIssue/Model/NewIssueModel.swift b/IssueTracker/IssueTracker/NewIssue/Model/NewIssueModel.swift new file mode 100644 index 0000000000..07083f7b05 --- /dev/null +++ b/IssueTracker/IssueTracker/NewIssue/Model/NewIssueModel.swift @@ -0,0 +1,42 @@ +// +// NewIssueModel.swift +// IssueTracker +// +// Created by Bibi on 2022/06/30. +// + +import Foundation + +class NewIssueModel { + + private let environment: NewIssueModelEnvironment + + init(environment: NewIssueModelEnvironment) { + self.environment = environment + } + + func createIssue(newIssue: NewIssueFormat, completion: @escaping (Bool) -> Void) { + environment.createIssue(newIssue) { boolResult in + completion(boolResult) + } + } + + func requestIssue(completion: @escaping ([String]?) -> Void) { + environment.requestRepositoryIssues() { result in + switch result { + case .success(let issues): + let issuesTitleArr = issues.map{ $0.title } + completion(issuesTitleArr) + case .failure(let error): + print(error.localizedDescription) + completion(nil) + } + } + } +} + +struct NewIssueModelEnvironment { + let createIssue: (NewIssueFormat, @escaping (Bool) -> Void) -> Void + + let requestRepositoryIssues: (@escaping (Result<[Issue], IssueError>) -> Void) -> Void +} diff --git a/IssueTracker/IssueTracker/NewIssue/NewIssueViewController.swift b/IssueTracker/IssueTracker/NewIssue/NewIssueViewController.swift new file mode 100644 index 0000000000..3cba70d93c --- /dev/null +++ b/IssueTracker/IssueTracker/NewIssue/NewIssueViewController.swift @@ -0,0 +1,304 @@ +// +// NewIssueViewController.swift +// IssueTracker +// +// Created by Bibi on 2022/06/22. +// + +import UIKit +import SwiftUI + +protocol NewIssueViewControllerDelegate: AnyObject { + func goBackToPreviousVC(repo: Repository) + + func touchedOption(option: Option, repo: Repository) +} + +class NewIssueViewController: UIViewController { + + weak var delegate: NewIssueViewControllerDelegate? + + private let model: NewIssueModel + + private let optionList = Option.allCases + private var selectedList = Array(repeating: "", count: Option.allCases.count) + + private var selectedLabel: Label? + private var selectedMilestone: Milestone? + private var selectedAssignee: Assignee? + + private let repo: Repository + + init(repo: Repository, model: NewIssueModel) { + self.repo = repo + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required convenience init?(coder: NSCoder) { + self.init(coder: coder) + } + + deinit { + print("-- \(type(of: self)) is deinited") + } + + private lazy var navSegmentedControl: UISegmentedControl = { + let buttonList = ["마크다운", "미리보기"] + var control = UISegmentedControl(items: buttonList) + + return control + }() + + private lazy var titleLabel: UILabel = { + var label = UILabel() + label.text = "제목" + label.font = UIFont.systemFont(ofSize: 20, weight: .bold) + return label + }() + + private lazy var titleField: UITextField = { + var textField = UITextField() + return textField + }() + + private lazy var contentField: UITextView = { + var contentField = UITextView() + return contentField + }() + + private lazy var horizontalDevider: UIView = { + var devider = Devider(direction: .horizontal(width: self.view.bounds.width), color: .systemGray) + return devider + }() + + private let optionTableCellIdentifier = "optionTableCellIdentifier" + + private lazy var optionTable: UITableView = { + var tableView = UITableView() + tableView.delegate = self + tableView.dataSource = self + tableView.register(UITableViewCell.self, + forCellReuseIdentifier: optionTableCellIdentifier) + return tableView + }() + + private lazy var optionTableHeader: UILabel = { + var label = UILabel() + label.text = "추가옵션" + label.font = UIFont.systemFont(ofSize: 20, weight: .bold) + label.textAlignment = .left + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setUpViews() + self.navigationController?.navigationBar.prefersLargeTitles = false + } + + func reloadOptions() { + DispatchQueue.main.async { [weak self] in + self?.optionTable.reloadData() + } + } + + private func setupNavigationBar() { + self.navigationItem.titleView = navSegmentedControl + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: createButton) + } + + private func setUpViews() { + self.view.backgroundColor = .white + + self.view.addSubview(titleLabel) + titleLabel.snp.makeConstraints { [weak self] make in + guard let self = self else { + return + } + make.top.leading.equalTo(self.view.safeAreaLayoutGuide).offset(10) + make.width.equalTo(50) + } + + self.view.addSubview(titleField) + titleField.snp.makeConstraints { [weak self] make in + guard let self = self else { + return + } + make.top.trailing.equalTo(self.view.safeAreaLayoutGuide).offset(10) + make.leading.equalTo(titleLabel.snp.trailing) + } + + self.view.addSubview(horizontalDevider) + horizontalDevider.snp.makeConstraints { [weak self] make in + guard let self = self else { + return + } + make.top.equalTo(titleLabel.snp.bottom).offset(5) + make.leading.equalTo(self.view.safeAreaLayoutGuide).offset(5) + make.trailing.equalTo(self.view.safeAreaLayoutGuide).offset(-5) + make.height.equalTo(1) + } + + optionTable.tableHeaderView = optionTableHeader + optionTable.tableHeaderView?.frame.size.height = 30 + + self.view.addSubview(optionTable) + optionTable.isScrollEnabled = true + optionTable.snp.makeConstraints { [weak self] make in + guard let self = self else { + return + } + make.leading.trailing.bottom.equalTo(self.view.safeAreaLayoutGuide) + make.height.equalTo(optionTable + .contentSize + .height + 30) + } + + self.view.addSubview(contentField) + contentField.snp.makeConstraints { [weak self] make in + guard let self = self else { + return + } + make.top.equalTo(horizontalDevider.snp.bottom) + make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide) + make.bottom.equalTo(optionTable.snp.top) + } + } + + private lazy var createButton: UIButton = { + var configuration = UIButton.Configuration.plain() + var container = AttributeContainer() + container.font = UIFont.systemFont(ofSize: 14) + configuration.attributedTitle = AttributedString("저장", attributes: container) + + configuration.buttonSize = .small + configuration.image = UIImage(systemName: "folder.badge.plus") + configuration.imagePadding = 4 + let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] action in + self?.touchedCreateButton() + })) + return button + }() + + private func touchedCreateButton() { + guard let titleString = self.titleField.text, + !titleString.isEmpty else { + // TODO: - 타이틀 입력 값이 없다 => 얼럿 + return + } + + guard let contentString = contentField.text else { + return + } + + let newIssueFormat = NewIssueFormat(title: titleString, repo: repo, content: contentString, label: selectedLabel, milestone: selectedMilestone, assignee: selectedAssignee) + + model.createIssue(newIssue: newIssueFormat) { [weak self] boolResult in + if boolResult { + self?.reloadIssues(title: titleString) + } else { + // TODO: 이슈 생성 실패 얼럿 띄우기 + } + } + } + + func fetchIssue(title: String) { + model.requestIssue { [weak self] titleArr in + guard let titleArr = titleArr, + let self = self, + let delegate = self.delegate else { + return + } + + // TODO: Indicator - 되긴 하는데 (1)너무 느리고 (2) 여러 개 생성되어 오래 기다릴땐 빙글빙글 도는 게 안보임.. + DispatchQueue.main.async { + let indicator = UIActivityIndicatorView() + self.createButton.addSubview(indicator) + if !titleArr.contains(title) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + indicator.startAnimating() + self.fetchIssue(title: title) + } + } else { + delegate.goBackToPreviousVC(repo: self.repo) + } + } + } + } + + func reloadIssues(title: String) { + var indicator: UIActivityIndicatorView? + DispatchQueue.main.async { [weak self] in + indicator = UIActivityIndicatorView() + if let indicator = indicator { + self?.createButton.addSubview(indicator) + } + + let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in + print("🌀루프 도는 중..") + self?.model.requestIssue { titleArr in + guard let titleArr = titleArr, + let indicator = indicator, + let delegate = self?.delegate, + let repo = self?.repo else { + return + } + DispatchQueue.main.async { + indicator.startAnimating() + } + if titleArr.contains(title) { + timer.invalidate() + delegate.goBackToPreviousVC(repo: repo) + } + } + } + timer.tolerance = 0.1 + } + + } + + func setSelectedOption(item: Optionable, option: Option) { + guard let optionIndex = optionList.firstIndex(of: option) else { + return + } + + switch option { + case .label: + selectedLabel = item as? Label + case .milestone: + selectedMilestone = item as? Milestone + case .assignee: + selectedAssignee = item as? Assignee + } + + selectedList[optionIndex] = item.subTitle + } +} + +extension NewIssueViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let option = optionList[indexPath.row] + self.delegate?.touchedOption(option: option, repo: repo) + } +} + +extension NewIssueViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return optionList.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: optionTableCellIdentifier, + for: indexPath) + var sidebarCell = UIListContentConfiguration.sidebarCell() + sidebarCell.text = optionList[indexPath.item].description + sidebarCell.secondaryText = selectedList[indexPath.item] + sidebarCell.prefersSideBySideTextAndSecondaryText = true + + cell.contentConfiguration = sidebarCell + cell.accessoryType = .disclosureIndicator + return cell + } +} diff --git a/IssueTracker/IssueTracker/NewIssue/Option.swift b/IssueTracker/IssueTracker/NewIssue/Option.swift new file mode 100644 index 0000000000..715e129229 --- /dev/null +++ b/IssueTracker/IssueTracker/NewIssue/Option.swift @@ -0,0 +1,25 @@ +// +// Option.swift +// IssueTracker +// +// Created by Bibi on 2022/06/30. +// + +import Foundation + +enum Option: CaseIterable { + case label + case milestone + case assignee + + var description: String { + switch self { + case .label: + return "레이블" + case .milestone: + return "마일스톤" + case .assignee: + return "담당자" + } + } +} diff --git a/IssueTracker/IssueTracker/OptionSelect/Model/OptionSelectModel.swift b/IssueTracker/IssueTracker/OptionSelect/Model/OptionSelectModel.swift new file mode 100644 index 0000000000..87a1003d48 --- /dev/null +++ b/IssueTracker/IssueTracker/OptionSelect/Model/OptionSelectModel.swift @@ -0,0 +1,72 @@ +// +// OptionSelectModel.swift +// IssueTracker +// +// Created by Bibi on 2022/06/30. +// + +import Foundation + +class OptionSelectModel { + + private let environment: OptionSelectModelEnvironment + private var options: [Optionable] { + didSet { + updatedOptions?() + } + } + + var updatedOptions: (() -> Void)? + + init(environment: OptionSelectModelEnvironment) { + self.environment = environment + self.options = [] + } + + func getOptionsCount() -> Int { + return options.count + } + + func getOption(index: Int) -> Optionable { + return options[index] + } + + func requestOptions(_ option: Option, repo: Repository) { + switch option { + case .label: + environment.requestRepositoryLabels(repo) { [weak self] result in + switch result { + case .success(let repositoryList): + self?.options = repositoryList + case .failure(let error): + print(error) + } + } + case .milestone: + environment.requestRepositoryMilestones(repo) { [weak self] result in + switch result { + case .success(let repositoryList): + self?.options = repositoryList + case .failure(let error): + print(error) + } + } + case .assignee: + environment.requestRepositoryAssigness(repo) { [weak self] result in + switch result { + case .success(let repositoryList): + self?.options = repositoryList + case .failure(let error): + print(error) + } + } + } + + } +} + +struct OptionSelectModelEnvironment { + let requestRepositoryLabels: (Repository, @escaping (Result<[Label], OptionError>) -> Void) -> Void + let requestRepositoryMilestones: (Repository, @escaping (Result<[Milestone], OptionError>) -> Void) -> Void + let requestRepositoryAssigness: (Repository, @escaping (Result<[Assignee], OptionError>) -> Void) -> Void +} diff --git a/IssueTracker/IssueTracker/OptionSelect/OptionSelectViewController.swift b/IssueTracker/IssueTracker/OptionSelect/OptionSelectViewController.swift new file mode 100644 index 0000000000..becaf6c210 --- /dev/null +++ b/IssueTracker/IssueTracker/OptionSelect/OptionSelectViewController.swift @@ -0,0 +1,98 @@ +import UIKit +import SnapKit + +protocol OptionSelectViewControllerDelegate: AnyObject { + func selected(item: Optionable, option: Option) +} + +class OptionSelectViewController: UIViewController { + + weak var delegate: OptionSelectViewControllerDelegate? + + private let model: OptionSelectModel + + private let tableViewCellIdentifier = "tableViewCellIdentifier" + private let option: Option + private let repo: Repository + + init(model: OptionSelectModel, option: Option, repo: Repository) { + self.model = model + self.option = option + self.repo = repo + super.init(nibName: nil, bundle: nil) + } + + required convenience init?(coder: NSCoder) { + let repo = Repository(name: "", owner: Owner(login: "")) + self.init(model: + OptionSelectModel(environment: + .init(requestRepositoryLabels: { repo, completion in }, + requestRepositoryMilestones: { repo, completion in }, + requestRepositoryAssigness: { repo, completion in } + ) + ), + option: .label, + repo: repo + ) + } + + deinit { + print("-- \(type(of: self)) is deinited") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + self.view.backgroundColor = .white + + model.requestOptions(option, repo: repo) + model.updatedOptions = { [weak self] in + self?.reloadData() + } + } + + private func setupViews() { + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func reloadData() { + DispatchQueue.main.async { [weak self] in + self?.tableView.reloadData() + } + } + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(UITableViewCell.self, + forCellReuseIdentifier: tableViewCellIdentifier) + tableView.delegate = self + tableView.dataSource = self + return tableView + }() + +} + +extension OptionSelectViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let selectedItem = model.getOption(index: indexPath.row) + delegate?.selected(item: selectedItem, option: option) + } +} + +extension OptionSelectViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return model.getOptionsCount() + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: tableViewCellIdentifier, + for: indexPath) + var content = cell.defaultContentConfiguration() + content.attributedText = NSAttributedString(string: model.getOption(index: indexPath.row).subTitle) + cell.contentConfiguration = content + return cell + } +} diff --git a/IssueTracker/IssueTracker/Repos/Model/ReposModel.swift b/IssueTracker/IssueTracker/Repos/Model/ReposModel.swift new file mode 100644 index 0000000000..bb9af5ff73 --- /dev/null +++ b/IssueTracker/IssueTracker/Repos/Model/ReposModel.swift @@ -0,0 +1,47 @@ +import Foundation + +class ReposModel { + + private let environment: ReposModelEnvironment + var updated: (([Repository]) -> Void)? + + private var reposList: [Repository] { + didSet { + updated?(reposList) + } + } + + init(environment: ReposModelEnvironment) { + self.environment = environment + self.reposList = [] + } + + var count: Int { + reposList.count + } + + func getViewData(index: Int) -> Repository { + return reposList[index] + } + + func fetchViewData(completion: @escaping (Bool) -> Void) { + environment.requestRepos() { [weak self] result in + switch result { + case .success(let repositoryList): + self?.reposList = repositoryList + completion(true) + case .failure(let error): + completion(false) + print(error) + } + } + } +} + +struct ReposModelEnvironment { + // ReposModel에 필요한 환경 : IssueService의 requestRepos()뿐이므로, IssueService전체를 넘겨줄 필요가 없다. + // 기존 service.requestRepos()하던 코드를 보자. (Result<[Repository], IssueError>) -> Void 클로저를 받아쓰고, 리턴값은 없다. + // 따라서 ReposModel의 환경은 (@escaping ((Result<[Repository], IssueError>)) -> Void) -> Void 가 된다. + let requestRepos: (@escaping (Result<[Repository], IssueError>) -> Void) -> Void + +} diff --git a/IssueTracker/IssueTracker/Repos/ReposViewController.swift b/IssueTracker/IssueTracker/Repos/ReposViewController.swift new file mode 100644 index 0000000000..49596fa591 --- /dev/null +++ b/IssueTracker/IssueTracker/Repos/ReposViewController.swift @@ -0,0 +1,88 @@ +import UIKit +import SnapKit + +protocol ReposViewControllerDelegate: AnyObject { + func showIssue(didSelectRowAt indexPath: IndexPath) +} + +class ReposViewController: UIViewController { + + weak var delegate: ReposViewControllerDelegate? + + private let model: ReposModel + private let tableViewCellIdentifier = "tableViewCellIdentifier" + + init(model: ReposModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required convenience init?(coder: NSCoder) { + self.init(coder: coder) + } + + deinit { + print("-- \(type(of: self)) is deinited") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .white + setupViews() + + model.updated = { [weak self] repos in + self?.reloadTableView() + } + + model.fetchViewData { [weak self] bool in + if bool { + self?.reloadTableView() + } + } + + } + + func reloadTableView() { + DispatchQueue.main.async { [weak self] in + self?.tableView.reloadData() + } + } + + func setupViews() { + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(UITableViewCell.self, + forCellReuseIdentifier: tableViewCellIdentifier) + tableView.delegate = self + tableView.dataSource = self + return tableView + }() + +} + +extension ReposViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + delegate?.showIssue(didSelectRowAt: indexPath) + } +} + +extension ReposViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return model.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let data = model.getViewData(index: indexPath.row) + let cell = tableView.dequeueReusableCell(withIdentifier: tableViewCellIdentifier, for: indexPath) + var content = cell.defaultContentConfiguration() + content.attributedText = NSAttributedString(string: data.name) + cell.contentConfiguration = content + return cell + } +} diff --git a/IssueTracker/IssueTracker/Storage/GithubUserDefaults.swift b/IssueTracker/IssueTracker/Storage/GithubUserDefaults.swift index d0ad2ed938..acfa82a621 100644 --- a/IssueTracker/IssueTracker/Storage/GithubUserDefaults.swift +++ b/IssueTracker/IssueTracker/Storage/GithubUserDefaults.swift @@ -2,13 +2,13 @@ import Foundation struct GithubUserDefaults { - private static let key = "github_access_token" + private let key = "github_access_token" - static func setToken(_ token: String) { + func setToken(_ token: String) { UserDefaults.standard.set(token, forKey: key) } - static func getToken() -> String? { - return UserDefaults.standard.string(forKey: self.key) + func getToken() -> String? { + return UserDefaults.standard.string(forKey: key) } } diff --git a/README.md b/README.md index 83b9e6e1ad..27680635b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # issue-tracker -그룹 프로젝트#5 + +![iOS 15.4+](https://img.shields.io/badge/iOS-15.4%2B-lightgrey) ![Xcode 13.3](https://img.shields.io/badge/Xcode-13.3-blue) + +> 기한: 2022.06.13 ~ 07.01 (3주) +> 프로젝트에 대한 자세한 내용은 [👉 Notion]() 에서 확인 + +## 앱 소개 + +[Github API](https://docs.github.com/en/rest/issues/issues) 을 사용해 issue 를 관리할 수 있는 iOS Application을 만들어 보았습니다. +구현된 기능은 다음과 같습니다: +- Github OAuth 로그인 +- 사용자의 Repository 목록 보여주기 +- Repository 에 해당하는 issue 목록 보여주기 +- issue 생성하기 + +| 로그인 | Repository 목록 | +| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| ![login](https://user-images.githubusercontent.com/12508578/176852816-ffe59c4c-0beb-43ab-8bef-94ddfd6b23db.gif) | ![Repository목록](https://user-images.githubusercontent.com/12508578/176852931-e5b5f3f5-fab4-4337-96d1-9ea90b11bf58.png) | +| - Github OAuth 를 이용해 로그인 합니다.
- 로그인을 하면 Github 으로 부터 인증을 하고 받아온 사용자 access token 를 local(UserDefaults) 에 저장해 API 호출에 사용합니다.
- 한번 로그인을 하면 앱 종료 후 다시 실행해도 로그인 상태를 유지합니다. | - 로그인한 사용자의 Repository 목록을 조회하여 화면에 보여줍니다.
- Repository 를 선택하면, 해당 Repository 에 속한 issue 리스트 화면을 보여줍니다. | + +| issue 목록 | issue 만들기 | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| ![issue 목록](https://user-images.githubusercontent.com/12508578/176856686-1ae0ef62-aa92-4112-81d0-2914db6c0885.png) | ![issue 만들기](https://user-images.githubusercontent.com/12508578/176856729-cf49bac0-811a-4be7-8399-a695b2a40929.gif) | +| - Repository 목록에서 Repository 선택시, API 를 통해 목록을 불러와 화면에 보여줍니다. | - 선택한 Repository 에 issue 를 만들 수 있습니다.
- 타이틀과 함께 레이블, 마일스톤, 담당자 정보를 입력해 issue를 생성할 수 있습니다.
- issue 를 생성 완료하면 목록화면으로 돌아갑니다. | + +### 사용한 기술 + +- [설계](https://github.com/Jinsujin/issue-tracker/wiki/2%EC%A3%BC%EC%B0%A8.-%EC%84%A4%EA%B3%84) +- [DIContainer](https://github.com/Jinsujin/issue-tracker/wiki/DIContainer-%EC%82%AC%EC%9A%A9%EA%B8%B0) +- [Coordinator 적용 계획](https://github.com/Jinsujin/issue-tracker/wiki/Coordinator-%EC%A0%81%EC%9A%A9-%EA%B3%84%ED%9A%8D) + +### Library + +| | Version | | +| ---------------------- | ------- | --------- | +| Alamofire | 5.6.1 | SPM | +| SnapKit | 5.6.0 | SPM | + + +## 팀원 +|`iOS` [@bibi](https://github.com/bibi6666667)| `iOS` [@Rosa](https://github.com/Jinsujin)| +|--|--| +||| +|[👉 회고](https://github.com/Jinsujin/issue-tracker/wiki/%ED%9A%8C%EA%B3%A0%23bibi)|[👉 회고](https://github.com/Jinsujin/issue-tracker/wiki/%ED%9A%8C%EA%B3%A0%23Rosa)| +