From b2cd6110f06b67f4223d154ff74f7acfc8b43e2c Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Sun, 10 Sep 2017 11:09:41 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20API,=20Issues=20API,?= =?UTF-8?q?=20Model=20(Issue,=20User)=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 41 +++ GithubIssues.xcodeproj/project.pbxproj | 252 ++++++++++++++++++ .../xcschemes/GithubIssues.xcscheme | 65 +++++ .../contents.xcworkspacedata | 10 + GithubIssues/API.swift | 38 +++ GithubIssues/DataRequest+extension.swift | 55 ++++ GithubIssues/Issue.swift | 62 +++++ GithubIssues/Model.swift | 13 + GithubIssues/Router.swift | 66 +++++ GithubIssues/User.swift | 26 ++ GithubIssues/ViewController.swift | 20 ++ Podfile | 20 ++ 12 files changed, 668 insertions(+) create mode 100644 .gitignore create mode 100644 GithubIssues.xcworkspace/contents.xcworkspacedata create mode 100644 GithubIssues/API.swift create mode 100644 GithubIssues/DataRequest+extension.swift create mode 100644 GithubIssues/Issue.swift create mode 100644 GithubIssues/Model.swift create mode 100644 GithubIssues/Router.swift create mode 100644 GithubIssues/User.swift create mode 100644 Podfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84bb4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ + +## OS X files +.DS_Store +.DS_Store? +.Trashes +.Spotlight-V100 +*.swp + +## Xcode build files +DerivedData/ +build/ + +## Xcode private settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +xcuserdata/ + +## Other +*.xccheckout +*.moved-aside +*.xcuserstate +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Swift Package Manager +.build/ + +Pods/ +Podfile.lock \ No newline at end of file diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 965c4eb..85bfc2e 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C9911DA0C2D7555BCE11A163 /* Pods_GithubIssues.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */; }; D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DD1F64118400380CEE /* AppDelegate.swift */; }; D7CE58E01F64118400380CEE /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DF1F64118400380CEE /* ViewController.swift */; }; D7CE58E31F64118400380CEE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E11F64118400380CEE /* Main.storyboard */; }; @@ -14,6 +15,14 @@ D7CE58E81F64118400380CEE /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */; }; D7CE58F31F64118400380CEE /* GithubIssuesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58F21F64118400380CEE /* GithubIssuesTests.swift */; }; D7CE58FE1F64118400380CEE /* GithubIssuesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58FD1F64118400380CEE /* GithubIssuesUITests.swift */; }; + D7CE590C1F64127C00380CEE /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE590B1F64127C00380CEE /* Router.swift */; }; + D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */; }; + D7CE59131F64CBD800380CEE /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59121F64CBD800380CEE /* API.swift */; }; + D7CE59151F64CC0B00380CEE /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59141F64CC0B00380CEE /* Model.swift */; }; + D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59171F64CC6B00380CEE /* Issue.swift */; }; + D7CE591A1F64CFD500380CEE /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59191F64CFD500380CEE /* User.swift */; }; + DC2DDE695A94509B0524CD3F /* Pods_GithubIssuesUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */; }; + F3AD9071E2E92DE33C2D8987 /* Pods_GithubIssuesTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C0D2DC2642FE096CC2AE419 /* Pods_GithubIssuesTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +43,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0C0D2DC2642FE096CC2AE419 /* Pods_GithubIssuesTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssuesTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D78869E0B7334E9DAE20592 /* Pods-GithubIssuesTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssuesTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssuesTests/Pods-GithubIssuesTests.release.xcconfig"; sourceTree = ""; }; + 492D35EB46E1B4B1FE6B6DEB /* Pods-GithubIssuesUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssuesUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssuesUITests/Pods-GithubIssuesUITests.release.xcconfig"; sourceTree = ""; }; + 68D57E3F4AB118717745ACCC /* Pods-GithubIssuesTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssuesTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssuesTests/Pods-GithubIssuesTests.debug.xcconfig"; sourceTree = ""; }; + 9D6A46598E99AC07BF1C6DE5 /* Pods-GithubIssues.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssues.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssues/Pods-GithubIssues.release.xcconfig"; sourceTree = ""; }; + A999D5F615EE2A6E92606DFD /* Pods-GithubIssues.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssues.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssues/Pods-GithubIssues.debug.xcconfig"; sourceTree = ""; }; + AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssues.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B1F6DF003FFB6DA72643444B /* Pods-GithubIssuesUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssuesUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssuesUITests/Pods-GithubIssuesUITests.debug.xcconfig"; sourceTree = ""; }; + C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssuesUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DA1F64118400380CEE /* GithubIssues.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubIssues.app; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DD1F64118400380CEE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D7CE58DF1F64118400380CEE /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -47,6 +65,12 @@ D7CE58F91F64118400380CEE /* GithubIssuesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GithubIssuesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58FD1F64118400380CEE /* GithubIssuesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubIssuesUITests.swift; sourceTree = ""; }; D7CE58FF1F64118400380CEE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D7CE590B1F64127C00380CEE /* Router.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DataRequest+extension.swift"; sourceTree = ""; }; + D7CE59121F64CBD800380CEE /* API.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + D7CE59141F64CC0B00380CEE /* Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; + D7CE59171F64CC6B00380CEE /* Issue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Issue.swift; sourceTree = ""; }; + D7CE59191F64CFD500380CEE /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -54,6 +78,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C9911DA0C2D7555BCE11A163 /* Pods_GithubIssues.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -61,6 +86,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F3AD9071E2E92DE33C2D8987 /* Pods_GithubIssuesTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -68,12 +94,36 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DC2DDE695A94509B0524CD3F /* Pods_GithubIssuesUITests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4664875EB8A21371C4BAF803 /* Frameworks */ = { + isa = PBXGroup; + children = ( + AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */, + 0C0D2DC2642FE096CC2AE419 /* Pods_GithubIssuesTests.framework */, + C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 5331741D128BA823DDB27D05 /* Pods */ = { + isa = PBXGroup; + children = ( + A999D5F615EE2A6E92606DFD /* Pods-GithubIssues.debug.xcconfig */, + 9D6A46598E99AC07BF1C6DE5 /* Pods-GithubIssues.release.xcconfig */, + 68D57E3F4AB118717745ACCC /* Pods-GithubIssuesTests.debug.xcconfig */, + 3D78869E0B7334E9DAE20592 /* Pods-GithubIssuesTests.release.xcconfig */, + B1F6DF003FFB6DA72643444B /* Pods-GithubIssuesUITests.debug.xcconfig */, + 492D35EB46E1B4B1FE6B6DEB /* Pods-GithubIssuesUITests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; D7CE58D11F64118400380CEE = { isa = PBXGroup; children = ( @@ -81,6 +131,8 @@ D7CE58F11F64118400380CEE /* GithubIssuesTests */, D7CE58FC1F64118400380CEE /* GithubIssuesUITests */, D7CE58DB1F64118400380CEE /* Products */, + 5331741D128BA823DDB27D05 /* Pods */, + 4664875EB8A21371C4BAF803 /* Frameworks */, ); sourceTree = ""; }; @@ -97,6 +149,8 @@ D7CE58DC1F64118400380CEE /* GithubIssues */ = { isa = PBXGroup; children = ( + D7CE59161F64CC1B00380CEE /* Model */, + D7CE590D1F64CB4C00380CEE /* Util */, D7CE58DD1F64118400380CEE /* AppDelegate.swift */, D7CE58DF1F64118400380CEE /* ViewController.swift */, D7CE58E11F64118400380CEE /* Main.storyboard */, @@ -125,6 +179,42 @@ path = GithubIssuesUITests; sourceTree = ""; }; + D7CE590D1F64CB4C00380CEE /* Util */ = { + isa = PBXGroup; + children = ( + D7CE59111F64CBCB00380CEE /* API */, + D7CE590E1F64CB5400380CEE /* Extension */, + ); + name = Util; + sourceTree = ""; + }; + D7CE590E1F64CB5400380CEE /* Extension */ = { + isa = PBXGroup; + children = ( + D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */, + ); + name = Extension; + sourceTree = ""; + }; + D7CE59111F64CBCB00380CEE /* API */ = { + isa = PBXGroup; + children = ( + D7CE590B1F64127C00380CEE /* Router.swift */, + D7CE59121F64CBD800380CEE /* API.swift */, + ); + name = API; + sourceTree = ""; + }; + D7CE59161F64CC1B00380CEE /* Model */ = { + isa = PBXGroup; + children = ( + D7CE59141F64CC0B00380CEE /* Model.swift */, + D7CE59171F64CC6B00380CEE /* Issue.swift */, + D7CE59191F64CFD500380CEE /* User.swift */, + ); + name = Model; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -132,9 +222,12 @@ isa = PBXNativeTarget; buildConfigurationList = D7CE59021F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssues" */; buildPhases = ( + 6C91CFE4185593F1C5EE423E /* [CP] Check Pods Manifest.lock */, D7CE58D61F64118400380CEE /* Sources */, D7CE58D71F64118400380CEE /* Frameworks */, D7CE58D81F64118400380CEE /* Resources */, + 9B358046D0AF5ED9679EE94B /* [CP] Embed Pods Frameworks */, + 1C3E336DA6D59BAECEB6B9F0 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -149,9 +242,12 @@ isa = PBXNativeTarget; buildConfigurationList = D7CE59051F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssuesTests" */; buildPhases = ( + BAAE6777FF179A310317CFED /* [CP] Check Pods Manifest.lock */, D7CE58EA1F64118400380CEE /* Sources */, D7CE58EB1F64118400380CEE /* Frameworks */, D7CE58EC1F64118400380CEE /* Resources */, + E920720D5AEA69AE4B6C5C08 /* [CP] Embed Pods Frameworks */, + BB4AD020B55BABD834A7F956 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -167,9 +263,12 @@ isa = PBXNativeTarget; buildConfigurationList = D7CE59081F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssuesUITests" */; buildPhases = ( + F8EAD61254A56272E54236CA /* [CP] Check Pods Manifest.lock */, D7CE58F51F64118400380CEE /* Sources */, D7CE58F61F64118400380CEE /* Frameworks */, D7CE58F71F64118400380CEE /* Resources */, + 46076F4EAFB76DABA7E7789E /* [CP] Embed Pods Frameworks */, + FDD3DCF6031B043F0E918500 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -257,13 +356,157 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 1C3E336DA6D59BAECEB6B9F0 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssues/Pods-GithubIssues-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 46076F4EAFB76DABA7E7789E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssuesUITests/Pods-GithubIssuesUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 6C91CFE4185593F1C5EE423E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; + 9B358046D0AF5ED9679EE94B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssues/Pods-GithubIssues-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + BAAE6777FF179A310317CFED /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; + BB4AD020B55BABD834A7F956 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssuesTests/Pods-GithubIssuesTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E920720D5AEA69AE4B6C5C08 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssuesTests/Pods-GithubIssuesTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F8EAD61254A56272E54236CA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; + FDD3DCF6031B043F0E918500 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssuesUITests/Pods-GithubIssuesUITests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ D7CE58D61F64118400380CEE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D7CE58E01F64118400380CEE /* ViewController.swift in Sources */, + D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */, + D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */, + D7CE590C1F64127C00380CEE /* Router.swift in Sources */, + D7CE591A1F64CFD500380CEE /* User.swift in Sources */, + D7CE59151F64CC0B00380CEE /* Model.swift in Sources */, D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */, + D7CE59131F64CBD800380CEE /* API.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -415,6 +658,7 @@ }; D7CE59031F64118400380CEE /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A999D5F615EE2A6E92606DFD /* Pods-GithubIssues.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = CX2K28Z3E9; @@ -428,6 +672,7 @@ }; D7CE59041F64118400380CEE /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9D6A46598E99AC07BF1C6DE5 /* Pods-GithubIssues.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = CX2K28Z3E9; @@ -441,6 +686,7 @@ }; D7CE59061F64118400380CEE /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 68D57E3F4AB118717745ACCC /* Pods-GithubIssuesTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -456,6 +702,7 @@ }; D7CE59071F64118400380CEE /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 3D78869E0B7334E9DAE20592 /* Pods-GithubIssuesTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -471,6 +718,7 @@ }; D7CE59091F64118400380CEE /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B1F6DF003FFB6DA72643444B /* Pods-GithubIssuesUITests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; DEVELOPMENT_TEAM = CX2K28Z3E9; @@ -485,6 +733,7 @@ }; D7CE590A1F64118400380CEE /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 492D35EB46E1B4B1FE6B6DEB /* Pods-GithubIssuesUITests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; DEVELOPMENT_TEAM = CX2K28Z3E9; @@ -516,6 +765,7 @@ D7CE59041F64118400380CEE /* Release */, ); defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; }; D7CE59051F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssuesTests" */ = { isa = XCConfigurationList; @@ -524,6 +774,7 @@ D7CE59071F64118400380CEE /* Release */, ); defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; }; D7CE59081F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssuesUITests" */ = { isa = XCConfigurationList; @@ -532,6 +783,7 @@ D7CE590A1F64118400380CEE /* Release */, ); defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; diff --git a/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme b/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme index 015fcc0..1a94a09 100644 --- a/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme +++ b/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme @@ -5,6 +5,22 @@ + + + + + + + + + + + + + + + + + + @@ -26,6 +71,16 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + @@ -35,6 +90,16 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> + + + + diff --git a/GithubIssues.xcworkspace/contents.xcworkspacedata b/GithubIssues.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..d02e29b --- /dev/null +++ b/GithubIssues.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/GithubIssues/API.swift b/GithubIssues/API.swift new file mode 100644 index 0000000..afcf138 --- /dev/null +++ b/GithubIssues/API.swift @@ -0,0 +1,38 @@ +// +// API.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON + + +struct API { + static func getOauthKey(user: String, password: String, completionHandler: @escaping (DataResponse) -> Void) { + var headers: HTTPHeaders = [:] + if let authorizationHeader = Request.authorizationHeader(user: user, password: password) { + headers[authorizationHeader.key] = authorizationHeader.value + } + let parameters: Parameters = ["client_secret": Router.clientSecret , "scopes": ["public_repo"], "note": "admin script" ] + Alamofire.request(Router.authKey(parameters, headers)) + .responseSwiftyJSON { json in + print(json) + completionHandler(json) + } + } + + static func repoIssues(owner: String, repo: String, completionHandler: @escaping (DataResponse<[Model.Issue]>) -> Void) { + Alamofire.request(Router.repoIssues(owner: owner, repo: repo)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in + return json.arrayValue.map{ + Model.Issue(json: $0) + } + }) + completionHandler(result) + } + } +} diff --git a/GithubIssues/DataRequest+extension.swift b/GithubIssues/DataRequest+extension.swift new file mode 100644 index 0000000..d662d02 --- /dev/null +++ b/GithubIssues/DataRequest+extension.swift @@ -0,0 +1,55 @@ +// +// DataRequest+extension.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON + +enum BackendError: Error { + case network(error: Error) + case dataSerialization(reason: String) + case jsonSerialization(error: Error) + case objectSerialization(reason: String) + case xmlSerialization(error: Error) +} + +extension DataRequest { + @discardableResult + public func responseSwiftyJSON(_ completionHandler: @escaping (DataResponse) -> Void) -> Self { + let responseSerializer = DataResponseSerializer { request, response, data, error in + guard error == nil else { + DataRequest.errorMessage(response, error: error, data: data) + return .failure(error!) + } + + let result = DataRequest + .jsonResponseSerializer(options: .allowFragments) + .serializeResponse(request, response, data, error) + + switch result { + case .success(let value): + if let _ = response { + return .success(JSON(value)) + } else { + let failureReason = "JSON could not be serialized into response object: \(value)" + let error = BackendError.objectSerialization(reason: failureReason) + DataRequest.errorMessage(response, error: error, data: data) + return .failure(error) + } + case .failure(let error): + DataRequest.errorMessage(response, error: error, data: data) + return .failure(error) + } + } + return response(responseSerializer: responseSerializer, completionHandler: completionHandler) + } + + static func errorMessage(_ response: HTTPURLResponse?, error: Error?, data: Data?) { + debugPrint("status: \(response?.statusCode ?? -1), error message:\(error.debugDescription)") + } +} diff --git a/GithubIssues/Issue.swift b/GithubIssues/Issue.swift new file mode 100644 index 0000000..98eaf67 --- /dev/null +++ b/GithubIssues/Issue.swift @@ -0,0 +1,62 @@ +// +// Issue.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import SwiftyJSON + +extension Model { + public struct Issue { + let id: Int + let number: Int + let title: String + let user: Model.User + let state: State + let comments: Int + let body: String + let createdAt: Date? + let updatedAt: Date? + let closedAt: Date? + + public init(json: JSON) { + id = json["id"].intValue + number = json["number"].intValue + title = json["title"].stringValue + user = Model.User(json: json["user"]) + state = State(rawValue: json["state"].stringValue) ?? .none + comments = json["comments"].intValue + body = json["body"].stringValue + + let format = DateFormatter() + format.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + createdAt = format.date(from: json["created_at"].stringValue) + updatedAt = format.date(from: json["updated_at"].stringValue) + closedAt = format.date(from: json["closed_at"].stringValue) + } + } +} + +extension Model.Issue { + enum State: String { + case open = "open", close = "close", none = "none" + + var display: String { + switch self { + case .open: return "opened" + case .close: return "closed" + default: return "-" + } + } + } +} + + +extension Model.Issue: Equatable { + public static func ==(lhs: Model.Issue, rhs: Model.Issue) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/GithubIssues/Model.swift b/GithubIssues/Model.swift new file mode 100644 index 0000000..a502f70 --- /dev/null +++ b/GithubIssues/Model.swift @@ -0,0 +1,13 @@ +// +// Model.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation + +public struct Model { + +} diff --git a/GithubIssues/Router.swift b/GithubIssues/Router.swift new file mode 100644 index 0000000..1929577 --- /dev/null +++ b/GithubIssues/Router.swift @@ -0,0 +1,66 @@ +// +// Router.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 9.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON + +enum Router { + case authKey(Parameters, HTTPHeaders) + case repoIssues(owner: String, repo: String) + +} + +extension Router: URLRequestConvertible { + + static let baseURLString = "https://api.github.com" + static let clientID = "36c48adc3d1433fbd286" + static let clientSecret = "a911bfd178a79f25d14c858a1199cd76d9e92f3b" + + var method: HTTPMethod { + switch self { + case .authKey: + return .put + case .repoIssues: + return .get + } + } + + var path: String { + switch self { + case .authKey: + return "/authorizations/clients/\(Router.clientID)/\(Date().timeIntervalSince1970)" + case let .repoIssues(owner, repo): + return "/repos/\(owner)/\(repo)/issues" + } + } + + func asURLRequest() throws -> URLRequest { + let url = try Router.baseURLString.asURL() + + var urlRequest = URLRequest(url: url.appendingPathComponent(path)) + urlRequest.httpMethod = method.rawValue + if let token = UserDefaults.standard.string(forKey: "token") { + urlRequest.setValue("token \(token)", forHTTPHeaderField: "Authorization") + } + + switch self { + case let .authKey(parameters, headers): + headers.forEach{ (key, value) in urlRequest.addValue(value, forHTTPHeaderField: key) } + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) +// urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case let .repoIssues(_, _): + urlRequest = try URLEncoding.default.encode(urlRequest, with: nil) + } + + return urlRequest + } +} + + + diff --git a/GithubIssues/User.swift b/GithubIssues/User.swift new file mode 100644 index 0000000..ea0bd2a --- /dev/null +++ b/GithubIssues/User.swift @@ -0,0 +1,26 @@ +// +// User.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import SwiftyJSON + +extension Model { + public struct User { + let id: Int + let login: String + let avatarURL: URL? + + public init(json: JSON) { + id = json["id"].intValue + login = json["login"].stringValue + + avatarURL = URL(string: json["avatar_url"].stringValue) + } + + } +} diff --git a/GithubIssues/ViewController.swift b/GithubIssues/ViewController.swift index b23b975..e360056 100644 --- a/GithubIssues/ViewController.swift +++ b/GithubIssues/ViewController.swift @@ -7,12 +7,32 @@ // import UIKit +import Alamofire +import SwiftyJSON class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. +// API.getOauthKey(user: "intmain", password: "b57d3a") { (response: DataResponse) in +// switch response.result { +// case let .success(value): +// let token = value["token"].stringValue +// UserDefaults.standard.set(token, forKey: "accessToken") +// case let .failure(error): +// print(error) +// } +// } + + API.repoIssues(owner: "ArchitectureStudy", repo: "study") { (response: DataResponse<[Model.Issue]>) in + switch response.result { + case let .success(value): + print(value) + case let .failure(error): + print(error) + } + } } override func didReceiveMemoryWarning() { diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..265154f --- /dev/null +++ b/Podfile @@ -0,0 +1,20 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'GithubIssues' do + # Comment the next line if you're not using Swift and don't want to use dynamic frameworks + use_frameworks! + pod 'Alamofire', '~> 4.5' + pod 'SwiftyJSON' + + target 'GithubIssuesTests' do + inherit! :search_paths + # Pods for testing + end + + target 'GithubIssuesUITests' do + inherit! :search_paths + # Pods for testing + end + +end From 91e816ea480e13d0ab066d5f7d71de93047bbe8b Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Sun, 10 Sep 2017 18:58:09 +0900 Subject: [PATCH 2/9] =?UTF-8?q?IssuesViewController=20=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GithubIssues.xcodeproj/project.pbxproj | 36 +- GithubIssues/API.swift | 5 +- GithubIssues/AppDelegate.swift | 9 + .../AppIcon.appiconset/Contents.json | 25 ++ GithubIssues/Assets.xcassets/Contents.json | 6 + .../Octocat.imageset/Contents.json | 21 ++ .../Octocat.imageset/Octocat.png | Bin 0 -> 2131769 bytes .../ic_comment.imageset/Contents.json | 23 ++ .../ic_comment.imageset/ic_comment.png | Bin 0 -> 395 bytes .../ic_comment.imageset/ic_comment@2x.png | Bin 0 -> 771 bytes .../ic_comment.imageset/ic_comment@3x.png | Bin 0 -> 839 bytes .../ic_issues_closed.imageset/Contents.json | 23 ++ .../ic_issues_closed.png | Bin 0 -> 976 bytes .../ic_issues_closed@2x.png | Bin 0 -> 2550 bytes .../ic_issues_closed@3x.png | Bin 0 -> 4453 bytes .../ic_issues_open.imageset/Contents.json | 23 ++ .../ic_issues_open.png | Bin 0 -> 1048 bytes .../ic_issues_open@2x.png | Bin 0 -> 2644 bytes .../ic_issues_open@3x.png | Bin 0 -> 4649 bytes GithubIssues/Base.lproj/Main.storyboard | 339 +++++++++++++++++- GithubIssues/GlobalState.swift | 59 +++ GithubIssues/IssueCell.swift | 43 +++ GithubIssues/IssuesViewController.swift | 121 +++++++ GithubIssues/LayoutAttributable.swift | 42 +++ GithubIssues/LoginViewController.swift | 49 +++ GithubIssues/RepoViewController.swift | 46 +++ GithubIssues/Router.swift | 11 +- GithubIssues/ViewController.swift | 45 --- 28 files changed, 858 insertions(+), 68 deletions(-) create mode 100644 GithubIssues/Assets.xcassets/Contents.json create mode 100644 GithubIssues/Assets.xcassets/Octocat.imageset/Contents.json create mode 100644 GithubIssues/Assets.xcassets/Octocat.imageset/Octocat.png create mode 100644 GithubIssues/Assets.xcassets/ic_comment.imageset/Contents.json create mode 100644 GithubIssues/Assets.xcassets/ic_comment.imageset/ic_comment.png create mode 100644 GithubIssues/Assets.xcassets/ic_comment.imageset/ic_comment@2x.png create mode 100644 GithubIssues/Assets.xcassets/ic_comment.imageset/ic_comment@3x.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_closed.imageset/Contents.json create mode 100644 GithubIssues/Assets.xcassets/ic_issues_closed.imageset/ic_issues_closed.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_closed.imageset/ic_issues_closed@2x.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_closed.imageset/ic_issues_closed@3x.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_open.imageset/Contents.json create mode 100644 GithubIssues/Assets.xcassets/ic_issues_open.imageset/ic_issues_open.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_open.imageset/ic_issues_open@2x.png create mode 100644 GithubIssues/Assets.xcassets/ic_issues_open.imageset/ic_issues_open@3x.png create mode 100644 GithubIssues/GlobalState.swift create mode 100644 GithubIssues/IssueCell.swift create mode 100644 GithubIssues/IssuesViewController.swift create mode 100644 GithubIssues/LayoutAttributable.swift create mode 100644 GithubIssues/LoginViewController.swift create mode 100644 GithubIssues/RepoViewController.swift delete mode 100644 GithubIssues/ViewController.swift diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 85bfc2e..94a1c42 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ C9911DA0C2D7555BCE11A163 /* Pods_GithubIssues.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */; }; D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DD1F64118400380CEE /* AppDelegate.swift */; }; - D7CE58E01F64118400380CEE /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DF1F64118400380CEE /* ViewController.swift */; }; D7CE58E31F64118400380CEE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E11F64118400380CEE /* Main.storyboard */; }; D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E41F64118400380CEE /* Assets.xcassets */; }; D7CE58E81F64118400380CEE /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */; }; @@ -21,6 +20,12 @@ D7CE59151F64CC0B00380CEE /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59141F64CC0B00380CEE /* Model.swift */; }; D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59171F64CC6B00380CEE /* Issue.swift */; }; D7CE591A1F64CFD500380CEE /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59191F64CFD500380CEE /* User.swift */; }; + D7CE591D1F64D82E00380CEE /* IssuesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE591C1F64D82E00380CEE /* IssuesViewController.swift */; }; + D7CE591F1F64D99300380CEE /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE591E1F64D99300380CEE /* LoginViewController.swift */; }; + D7CE59211F64DBB800380CEE /* GlobalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59201F64DBB800380CEE /* GlobalState.swift */; }; + D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59231F65005D00380CEE /* RepoViewController.swift */; }; + D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59251F65077B00380CEE /* IssueCell.swift */; }; + D7CE59281F65209A00380CEE /* LayoutAttributable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59271F65209A00380CEE /* LayoutAttributable.swift */; }; DC2DDE695A94509B0524CD3F /* Pods_GithubIssuesUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */; }; F3AD9071E2E92DE33C2D8987 /* Pods_GithubIssuesTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C0D2DC2642FE096CC2AE419 /* Pods_GithubIssuesTests.framework */; }; /* End PBXBuildFile section */ @@ -54,7 +59,6 @@ C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssuesUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DA1F64118400380CEE /* GithubIssues.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubIssues.app; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DD1F64118400380CEE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D7CE58DF1F64118400380CEE /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; D7CE58E21F64118400380CEE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; D7CE58E41F64118400380CEE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D7CE58E71F64118400380CEE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -71,6 +75,12 @@ D7CE59141F64CC0B00380CEE /* Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; D7CE59171F64CC6B00380CEE /* Issue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Issue.swift; sourceTree = ""; }; D7CE59191F64CFD500380CEE /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + D7CE591C1F64D82E00380CEE /* IssuesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssuesViewController.swift; sourceTree = ""; }; + D7CE591E1F64D99300380CEE /* LoginViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; + D7CE59201F64DBB800380CEE /* GlobalState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlobalState.swift; sourceTree = ""; }; + D7CE59231F65005D00380CEE /* RepoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepoViewController.swift; sourceTree = ""; }; + D7CE59251F65077B00380CEE /* IssueCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueCell.swift; sourceTree = ""; }; + D7CE59271F65209A00380CEE /* LayoutAttributable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAttributable.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -149,10 +159,10 @@ D7CE58DC1F64118400380CEE /* GithubIssues */ = { isa = PBXGroup; children = ( + D7CE591B1F64D80F00380CEE /* View */, D7CE59161F64CC1B00380CEE /* Model */, D7CE590D1F64CB4C00380CEE /* Util */, D7CE58DD1F64118400380CEE /* AppDelegate.swift */, - D7CE58DF1F64118400380CEE /* ViewController.swift */, D7CE58E11F64118400380CEE /* Main.storyboard */, D7CE58E41F64118400380CEE /* Assets.xcassets */, D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */, @@ -184,6 +194,8 @@ children = ( D7CE59111F64CBCB00380CEE /* API */, D7CE590E1F64CB5400380CEE /* Extension */, + D7CE59201F64DBB800380CEE /* GlobalState.swift */, + D7CE59271F65209A00380CEE /* LayoutAttributable.swift */, ); name = Util; sourceTree = ""; @@ -215,6 +227,17 @@ name = Model; sourceTree = ""; }; + D7CE591B1F64D80F00380CEE /* View */ = { + isa = PBXGroup; + children = ( + D7CE591C1F64D82E00380CEE /* IssuesViewController.swift */, + D7CE591E1F64D99300380CEE /* LoginViewController.swift */, + D7CE59231F65005D00380CEE /* RepoViewController.swift */, + D7CE59251F65077B00380CEE /* IssueCell.swift */, + ); + name = View; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -499,13 +522,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D7CE58E01F64118400380CEE /* ViewController.swift in Sources */, D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */, + D7CE59281F65209A00380CEE /* LayoutAttributable.swift in Sources */, D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */, + D7CE591D1F64D82E00380CEE /* IssuesViewController.swift in Sources */, D7CE590C1F64127C00380CEE /* Router.swift in Sources */, D7CE591A1F64CFD500380CEE /* User.swift in Sources */, D7CE59151F64CC0B00380CEE /* Model.swift in Sources */, + D7CE59211F64DBB800380CEE /* GlobalState.swift in Sources */, + D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */, D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */, + D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */, + D7CE591F1F64D99300380CEE /* LoginViewController.swift in Sources */, D7CE59131F64CBD800380CEE /* API.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/GithubIssues/API.swift b/GithubIssues/API.swift index afcf138..a4d53ad 100644 --- a/GithubIssues/API.swift +++ b/GithubIssues/API.swift @@ -25,8 +25,9 @@ struct API { } } - static func repoIssues(owner: String, repo: String, completionHandler: @escaping (DataResponse<[Model.Issue]>) -> Void) { - Alamofire.request(Router.repoIssues(owner: owner, repo: repo)).responseSwiftyJSON { (dataResponse: DataResponse) in + static func repoIssues(owner: String, repo: String, page: Int, completionHandler: @escaping (DataResponse<[Model.Issue]>) -> Void) { + let parameters: Parameters = ["page": page] + Alamofire.request(Router.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in return json.arrayValue.map{ Model.Issue(json: $0) diff --git a/GithubIssues/AppDelegate.swift b/GithubIssues/AppDelegate.swift index 0a4b26c..4d15463 100644 --- a/GithubIssues/AppDelegate.swift +++ b/GithubIssues/AppDelegate.swift @@ -16,6 +16,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + + if !GlobalState.instance.isLoggedIn { + let loginViewController = LoginViewController.viewController + DispatchQueue.main.asyncAfter(deadline: .now() + 0.0, execute: { [weak self] in + self?.window?.rootViewController?.present(loginViewController, animated: false, completion: nil) + }) + + } + return true } diff --git a/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json b/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json index 36d2c80..1d060ed 100644 --- a/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,5 +1,15 @@ { "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, { "idiom" : "iphone", "size" : "29x29", @@ -30,6 +40,16 @@ "size" : "60x60", "scale" : "3x" }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, { "idiom" : "ipad", "size" : "29x29", @@ -59,6 +79,11 @@ "idiom" : "ipad", "size" : "76x76", "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" } ], "info" : { diff --git a/GithubIssues/Assets.xcassets/Contents.json b/GithubIssues/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/GithubIssues/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/GithubIssues/Assets.xcassets/Octocat.imageset/Contents.json b/GithubIssues/Assets.xcassets/Octocat.imageset/Contents.json new file mode 100644 index 0000000..d037f78 --- /dev/null +++ b/GithubIssues/Assets.xcassets/Octocat.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Octocat.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/GithubIssues/Assets.xcassets/Octocat.imageset/Octocat.png b/GithubIssues/Assets.xcassets/Octocat.imageset/Octocat.png new file mode 100644 index 0000000000000000000000000000000000000000..91057da49614551224149b182ae0fc2d843e9368 GIT binary patch literal 2131769 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 literal 0 HcmV?d00001 diff --git a/GithubIssues/Assets.xcassets/ic_comment.imageset/Contents.json b/GithubIssues/Assets.xcassets/ic_comment.imageset/Contents.json new file mode 100644 index 0000000..26c322d --- /dev/null +++ b/GithubIssues/Assets.xcassets/ic_comment.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_comment.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_comment@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_comment@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/GithubIssues/Assets.xcassets/ic_comment.imageset/ic_comment.png b/GithubIssues/Assets.xcassets/ic_comment.imageset/ic_comment.png new file mode 100644 index 0000000000000000000000000000000000000000..f4f2060af512e3d7be438425176f851facced083 GIT binary patch literal 395 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 M07*qoM6N<$f-0iLHvj+t literal 0 HcmV?d00001 diff --git a/GithubIssues/Assets.xcassets/ic_issues_closed.imageset/ic_issues_closed@3x.png b/GithubIssues/Assets.xcassets/ic_issues_closed.imageset/ic_issues_closed@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..4d10a06c6b26a1be691b4db77a097b38c983d50d GIT binary patch literal 4453 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 z - + + + + + - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + diff --git a/GithubIssues/GlobalState.swift b/GithubIssues/GlobalState.swift new file mode 100644 index 0000000..ec2dda4 --- /dev/null +++ b/GithubIssues/GlobalState.swift @@ -0,0 +1,59 @@ +// +// GlobalState.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation + +final class GlobalState { + static let instance = GlobalState() + + + struct constants { + static let tokenKey = "token" + static let ownerKey = "owner" + static let repoKey = "repo" + } + + var token: String? { + get { + let token = UserDefaults.standard.string(forKey: constants.tokenKey) + return token + } + set { + UserDefaults.standard.set(newValue, forKey: constants.tokenKey) + } + } + + var owner: String { + get { + let owner = UserDefaults.standard.string(forKey: constants.ownerKey) ?? "" + return owner + } + + set { + UserDefaults.standard.set(newValue, forKey: constants.ownerKey) + } + } + + var repo: String { + get { + let repo = UserDefaults.standard.string(forKey: constants.repoKey) ?? "" + return repo + } + + set { + UserDefaults.standard.set(newValue, forKey: constants.repoKey) + } + } + + var isLoggedIn: Bool { + get { + let isEmpty = token?.isEmpty ?? true + return !isEmpty + } + } +} diff --git a/GithubIssues/IssueCell.swift b/GithubIssues/IssueCell.swift new file mode 100644 index 0000000..085db92 --- /dev/null +++ b/GithubIssues/IssueCell.swift @@ -0,0 +1,43 @@ +// +// IssueCell.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +class IssueCell: UICollectionViewCell, LayoutEstimatable { + static var estimatedLayout: [IndexPath: CGSize] = [:] + + @IBOutlet var stateButton: UIButton! + @IBOutlet var titleLabel: UILabel! + @IBOutlet var contentLabel: UILabel! + @IBOutlet var commentCountButton: UIButton! + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + return self.estimateLayoutAttributes(layoutAttributes) + } +} + + +extension IssueCell { + func update(issue: Model.Issue) { + titleLabel.text = issue.title + contentLabel.text = issue.body + let createdAt = issue.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + contentLabel.text = "#\(issue.number) \(issue.state.display) on \(createdAt) by \(issue.user.login)" + commentCountButton.setTitle("\(issue.comments)", for: .normal) + stateButton.isSelected = issue.state == .close + } +} + +extension Date { + func string(dateFormat: String, locale: String = "en-US") -> String { + let format = DateFormatter() + format.dateFormat = dateFormat + format.locale = Locale(identifier: locale) + return format.string(from: self) + } +} diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift new file mode 100644 index 0000000..38d1f24 --- /dev/null +++ b/GithubIssues/IssuesViewController.swift @@ -0,0 +1,121 @@ +// +// IssuesViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import Alamofire + +class IssuesViewController: UIViewController { + + var owner: String = "" + var repo: String = "" + fileprivate var datasource: [Model.Issue] = [] + @IBOutlet var collectionView: UICollectionView! + let refreshControl = UIRefreshControl() + var page: Int = 1 + var canLoadMore: Bool = true + private var needRefreshDatasource: Bool = false + var isLoading: Bool = false + + func setNeedRefreshDatasource() { + needRefreshDatasource = true + } + func refreshDataSourceIfNeeded() { + if needRefreshDatasource { + self.datasource = [] + needRefreshDatasource = false + } + } + + override func viewDidLoad() { + super.viewDidLoad() + collectionView.addSubview(refreshControl) + collectionView.alwaysBounceVertical = true + refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) + if let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { + print("estimatedItemSize") + print("size : \(CGSize(width: UIScreen.main.bounds.width, height: 56))") + flowLayout.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 256) + } + load() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + } + + func load() { + isLoading = true + API.repoIssues(owner: owner, repo: repo, page: page) {[weak self] (response: DataResponse<[Model.Issue]>) in + guard let `self` = self else { return } + switch response.result { + case .success(let issues): + self.dataLoaded(issues: issues) + self.isLoading = false + case .failure(_): + self.isLoading = false + break + } + } + } + + func dataLoaded(issues: [Model.Issue]) { + refreshDataSourceIfNeeded() + datasource = datasource + issues + page = page + 1 + if issues.count == 0 { + canLoadMore = false + } + refreshControl.endRefreshing() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.collectionView.reloadData() + } + + collectionView.reloadData() + } + + func refresh() { + page = 1 + canLoadMore = true + setNeedRefreshDatasource() + load() + } + + func loadMore() { + if canLoadMore { + load() + } + } +} + +extension IssuesViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCell", for: indexPath) as! IssueCell + let issue = datasource[indexPath.item] + cell.update(issue: issue) + + return cell + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return datasource.count + } +} + +extension IssuesViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + print("item: \(indexPath.item), count: \(datasource.count)") + if indexPath.item == datasource.count - 1 && !isLoading{ + loadMore() + } + } +} diff --git a/GithubIssues/LayoutAttributable.swift b/GithubIssues/LayoutAttributable.swift new file mode 100644 index 0000000..250e5df --- /dev/null +++ b/GithubIssues/LayoutAttributable.swift @@ -0,0 +1,42 @@ +// +// LayoutAttributable.swift +// TaewanArchitectureStudy +// +// Created by taewan on 2017. 2. 11.. +// Copyright © 2017년 taewankim. All rights reserved. +// + +import UIKit + +protocol LayoutEstimatable: class { + static var estimatedLayout: [IndexPath: CGSize] { get set } + static func estimatedSizeReset(indexPath: IndexPath?) +} + +extension LayoutEstimatable where Self: UICollectionViewCell { + static func estimatedSizeReset(indexPath: IndexPath? = nil) { + if let key = indexPath { + estimatedLayout[key] = nil + } else { + estimatedLayout = [:] + } + } + + func estimateLayoutAttributes(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + if layoutAttributes.isHidden { + return layoutAttributes + } + + if let layoutSize = Self.estimatedLayout[layoutAttributes.indexPath] { + layoutAttributes.size = layoutSize + } else { + layoutAttributes.size = contentView.systemLayoutSizeFitting( + layoutAttributes.size, + withHorizontalFittingPriority: UILayoutPriorityRequired, + verticalFittingPriority: UILayoutPriorityDefaultLow) + Self.estimatedLayout[layoutAttributes.indexPath] = layoutAttributes.size + } + return layoutAttributes + } + +} diff --git a/GithubIssues/LoginViewController.swift b/GithubIssues/LoginViewController.swift new file mode 100644 index 0000000..64bd12d --- /dev/null +++ b/GithubIssues/LoginViewController.swift @@ -0,0 +1,49 @@ +// +// LoginViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import Alamofire +import SwiftyJSON + +class LoginViewController: UIViewController { + + @IBOutlet var idTextField: UITextField! + @IBOutlet var passwordTextField: UITextField! + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + static var viewController: LoginViewController { + let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "LoginViewController") as! LoginViewController + return viewController + } + @IBAction func loginButtonTapped(_ sender: Any) { + let id = idTextField.text ?? "" + let password = passwordTextField.text ?? "" + API.getOauthKey(user: id, password: password) { [weak self] (response: DataResponse) in + switch response.result { + case let .success(value): + print(value) + let token = value["token"].stringValue + GlobalState.instance.token = token + self?.dismiss(animated: true, completion: { + + }) + case let .failure(error): + print(error) + } + } + } +} diff --git a/GithubIssues/RepoViewController.swift b/GithubIssues/RepoViewController.swift new file mode 100644 index 0000000..963838d --- /dev/null +++ b/GithubIssues/RepoViewController.swift @@ -0,0 +1,46 @@ +// +// RepoViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +class RepoViewController: UIViewController { + + @IBOutlet var ownerTextField: UITextField! + @IBOutlet var repoTextField: UITextField! + + override func viewDidLoad() { + super.viewDidLoad() + + ownerTextField.text = GlobalState.instance.owner + repoTextField.text = GlobalState.instance.repo + // Do any additional setup after loading the view. + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + + + // MARK: - Navigation + + override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { + guard let owner = ownerTextField.text, let repo = repoTextField.text else { return false } + return !(owner.isEmpty || repo.isEmpty) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + guard let owner = ownerTextField.text, let repo = repoTextField.text else { return } + GlobalState.instance.owner = owner + GlobalState.instance.repo = repo + guard let issuesViewController = segue.destination as? IssuesViewController else { return } + issuesViewController.owner = owner + issuesViewController.repo = repo + } +} diff --git a/GithubIssues/Router.swift b/GithubIssues/Router.swift index 1929577..ea0a609 100644 --- a/GithubIssues/Router.swift +++ b/GithubIssues/Router.swift @@ -12,7 +12,7 @@ import SwiftyJSON enum Router { case authKey(Parameters, HTTPHeaders) - case repoIssues(owner: String, repo: String) + case repoIssues(owner: String, repo: String, parameters: Parameters) } @@ -35,7 +35,7 @@ extension Router: URLRequestConvertible { switch self { case .authKey: return "/authorizations/clients/\(Router.clientID)/\(Date().timeIntervalSince1970)" - case let .repoIssues(owner, repo): + case let .repoIssues(owner, repo, _): return "/repos/\(owner)/\(repo)/issues" } } @@ -45,7 +45,7 @@ extension Router: URLRequestConvertible { var urlRequest = URLRequest(url: url.appendingPathComponent(path)) urlRequest.httpMethod = method.rawValue - if let token = UserDefaults.standard.string(forKey: "token") { + if let token = GlobalState.instance.token, !token.isEmpty { urlRequest.setValue("token \(token)", forHTTPHeaderField: "Authorization") } @@ -53,9 +53,8 @@ extension Router: URLRequestConvertible { case let .authKey(parameters, headers): headers.forEach{ (key, value) in urlRequest.addValue(value, forHTTPHeaderField: key) } urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) -// urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) - case let .repoIssues(_, _): - urlRequest = try URLEncoding.default.encode(urlRequest, with: nil) + case let .repoIssues(_, _, parameters): + urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) } return urlRequest diff --git a/GithubIssues/ViewController.swift b/GithubIssues/ViewController.swift deleted file mode 100644 index e360056..0000000 --- a/GithubIssues/ViewController.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// ViewController.swift -// GithubIssues -// -// Created by Leonard on 2017. 9. 9.. -// Copyright © 2017년 intmain. All rights reserved. -// - -import UIKit -import Alamofire -import SwiftyJSON - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. -// API.getOauthKey(user: "intmain", password: "b57d3a") { (response: DataResponse) in -// switch response.result { -// case let .success(value): -// let token = value["token"].stringValue -// UserDefaults.standard.set(token, forKey: "accessToken") -// case let .failure(error): -// print(error) -// } -// } - - API.repoIssues(owner: "ArchitectureStudy", repo: "study") { (response: DataResponse<[Model.Issue]>) in - switch response.result { - case let .success(value): - print(value) - case let .failure(error): - print(error) - } - } - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } - - -} - From da76a5f1a35052c2a80af566b4ce8ffb0bd5816e Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Tue, 19 Sep 2017 00:09:18 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=EC=85=98=20reload=20=ED=95=B4=EA=B2=B0=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GithubIssues.xcodeproj/project.pbxproj | 36 ++ GithubIssues/API.swift | 61 ++- GithubIssues/AppDelegate.swift | 1 + GithubIssues/Base.lproj/Main.storyboard | 513 ++++++++++++++++++- GithubIssues/Comment.swift | 35 ++ GithubIssues/CreateIssueViewController.swift | 60 +++ GithubIssues/ExampleViewController.swift | 28 + GithubIssues/GlobalState.swift | 27 +- GithubIssues/Info.plist | 4 + GithubIssues/Issue.swift | 39 +- GithubIssues/IssueCell.swift | 7 +- GithubIssues/IssueCommentCell.swift | 38 ++ GithubIssues/IssueCommentCell.xib | 119 +++++ GithubIssues/IssueDetailHeaderView.swift | 71 +++ GithubIssues/IssueDetailViewController.swift | 302 +++++++++++ GithubIssues/IssuesViewController.swift | 106 ++-- GithubIssues/RepoViewController.swift | 36 +- GithubIssues/ReposViewController.swift | 56 ++ GithubIssues/Router.swift | 33 +- GithubIssues/UIColor+extension.swift | 37 ++ Podfile | 5 +- 21 files changed, 1548 insertions(+), 66 deletions(-) create mode 100644 GithubIssues/Comment.swift create mode 100644 GithubIssues/CreateIssueViewController.swift create mode 100644 GithubIssues/ExampleViewController.swift create mode 100644 GithubIssues/IssueCommentCell.swift create mode 100644 GithubIssues/IssueCommentCell.xib create mode 100644 GithubIssues/IssueDetailHeaderView.swift create mode 100644 GithubIssues/IssueDetailViewController.swift create mode 100644 GithubIssues/ReposViewController.swift create mode 100644 GithubIssues/UIColor+extension.swift diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 94a1c42..4cc6a43 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ C9911DA0C2D7555BCE11A163 /* Pods_GithubIssues.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */; }; + D7014DC61F6814A000F0F7F7 /* IssueDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */; }; + D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */; }; + D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D730D9951F6AE4DA0057276B /* ExampleViewController.swift */; }; D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DD1F64118400380CEE /* AppDelegate.swift */; }; D7CE58E31F64118400380CEE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E11F64118400380CEE /* Main.storyboard */; }; D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E41F64118400380CEE /* Assets.xcassets */; }; @@ -26,6 +29,12 @@ D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59231F65005D00380CEE /* RepoViewController.swift */; }; D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59251F65077B00380CEE /* IssueCell.swift */; }; D7CE59281F65209A00380CEE /* LayoutAttributable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59271F65209A00380CEE /* LayoutAttributable.swift */; }; + D7CE592A1F6546D200380CEE /* IssueDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59291F6546D200380CEE /* IssueDetailViewController.swift */; }; + D7CE592C1F655AF600380CEE /* IssueCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE592B1F655AF600380CEE /* IssueCommentCell.swift */; }; + D7CE592E1F655C1C00380CEE /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE592D1F655C1C00380CEE /* Comment.swift */; }; + D7CE59301F658CA900380CEE /* IssueCommentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D7CE592F1F658CA900380CEE /* IssueCommentCell.xib */; }; + D7ED99D41F6D71ED00E4B903 /* CreateIssueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */; }; + D7ED99D61F6E100F00E4B903 /* ReposViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */; }; DC2DDE695A94509B0524CD3F /* Pods_GithubIssuesUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */; }; F3AD9071E2E92DE33C2D8987 /* Pods_GithubIssuesTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C0D2DC2642FE096CC2AE419 /* Pods_GithubIssuesTests.framework */; }; /* End PBXBuildFile section */ @@ -57,6 +66,9 @@ AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssues.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B1F6DF003FFB6DA72643444B /* Pods-GithubIssuesUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubIssuesUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubIssuesUITests/Pods-GithubIssuesUITests.debug.xcconfig"; sourceTree = ""; }; C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssuesUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueDetailHeaderView.swift; sourceTree = ""; }; + D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+extension.swift"; sourceTree = ""; }; + D730D9951F6AE4DA0057276B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; D7CE58DA1F64118400380CEE /* GithubIssues.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubIssues.app; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DD1F64118400380CEE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D7CE58E21F64118400380CEE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -81,6 +93,12 @@ D7CE59231F65005D00380CEE /* RepoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepoViewController.swift; sourceTree = ""; }; D7CE59251F65077B00380CEE /* IssueCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueCell.swift; sourceTree = ""; }; D7CE59271F65209A00380CEE /* LayoutAttributable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAttributable.swift; sourceTree = ""; }; + D7CE59291F6546D200380CEE /* IssueDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueDetailViewController.swift; sourceTree = ""; }; + D7CE592B1F655AF600380CEE /* IssueCommentCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueCommentCell.swift; sourceTree = ""; }; + D7CE592D1F655C1C00380CEE /* Comment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + D7CE592F1F658CA900380CEE /* IssueCommentCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IssueCommentCell.xib; sourceTree = ""; }; + D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateIssueViewController.swift; sourceTree = ""; }; + D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReposViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +185,7 @@ D7CE58E41F64118400380CEE /* Assets.xcassets */, D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */, D7CE58E91F64118400380CEE /* Info.plist */, + D730D9951F6AE4DA0057276B /* ExampleViewController.swift */, ); path = GithubIssues; sourceTree = ""; @@ -204,6 +223,7 @@ isa = PBXGroup; children = ( D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */, + D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */, ); name = Extension; sourceTree = ""; @@ -223,6 +243,7 @@ D7CE59141F64CC0B00380CEE /* Model.swift */, D7CE59171F64CC6B00380CEE /* Issue.swift */, D7CE59191F64CFD500380CEE /* User.swift */, + D7CE592D1F655C1C00380CEE /* Comment.swift */, ); name = Model; sourceTree = ""; @@ -234,6 +255,12 @@ D7CE591E1F64D99300380CEE /* LoginViewController.swift */, D7CE59231F65005D00380CEE /* RepoViewController.swift */, D7CE59251F65077B00380CEE /* IssueCell.swift */, + D7CE59291F6546D200380CEE /* IssueDetailViewController.swift */, + D7CE592B1F655AF600380CEE /* IssueCommentCell.swift */, + D7CE592F1F658CA900380CEE /* IssueCommentCell.xib */, + D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */, + D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */, + D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */, ); name = View; sourceTree = ""; @@ -358,6 +385,7 @@ buildActionMask = 2147483647; files = ( D7CE58E81F64118400380CEE /* LaunchScreen.storyboard in Resources */, + D7CE59301F658CA900380CEE /* IssueCommentCell.xib in Resources */, D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */, D7CE58E31F64118400380CEE /* Main.storyboard in Resources */, ); @@ -522,9 +550,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D7014DC61F6814A000F0F7F7 /* IssueDetailHeaderView.swift in Sources */, D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */, D7CE59281F65209A00380CEE /* LayoutAttributable.swift in Sources */, + D7ED99D61F6E100F00E4B903 /* ReposViewController.swift in Sources */, + D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */, D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */, + D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */, + D7CE592A1F6546D200380CEE /* IssueDetailViewController.swift in Sources */, D7CE591D1F64D82E00380CEE /* IssuesViewController.swift in Sources */, D7CE590C1F64127C00380CEE /* Router.swift in Sources */, D7CE591A1F64CFD500380CEE /* User.swift in Sources */, @@ -532,8 +565,11 @@ D7CE59211F64DBB800380CEE /* GlobalState.swift in Sources */, D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */, D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */, + D7CE592E1F655C1C00380CEE /* Comment.swift in Sources */, D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */, + D7CE592C1F655AF600380CEE /* IssueCommentCell.swift in Sources */, D7CE591F1F64D99300380CEE /* LoginViewController.swift in Sources */, + D7ED99D41F6D71ED00E4B903 /* CreateIssueViewController.swift in Sources */, D7CE59131F64CBD800380CEE /* API.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/GithubIssues/API.swift b/GithubIssues/API.swift index a4d53ad..01508b3 100644 --- a/GithubIssues/API.swift +++ b/GithubIssues/API.swift @@ -26,7 +26,7 @@ struct API { } static func repoIssues(owner: String, repo: String, page: Int, completionHandler: @escaping (DataResponse<[Model.Issue]>) -> Void) { - let parameters: Parameters = ["page": page] + let parameters: Parameters = ["page": page, "state": "all"] Alamofire.request(Router.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in return json.arrayValue.map{ @@ -36,4 +36,63 @@ struct API { completionHandler(result) } } + + static func issueDetail(owner: String, repo: String, number: Int, page: Int, completionHandler: @escaping (DataResponse<[Model.Comment]>) -> Void) { + let parameters: Parameters = ["page": page] + Alamofire.request(Router.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> [Model.Comment] in + return json.arrayValue.map{ + Model.Comment(json: $0) + } + }) + completionHandler(result) + } + } + + static func createComment(owner: String, repo: String, number: Int, comment: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["body": comment] + Alamofire.request(Router.createComment(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> Model.Comment in + Model.Comment(json: json) + }) + completionHandler(result) + } + } + + static func createIssue(owner: String, repo: String, title: String, body: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["title": title, "body": body] + Alamofire.request(Router.createIssue(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + } + + static func closeIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.closed.display + Alamofire.request(Router.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + + } + + static func openIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.open.display + Alamofire.request(Router.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + + } } diff --git a/GithubIssues/AppDelegate.swift b/GithubIssues/AppDelegate.swift index 4d15463..08f5f2e 100644 --- a/GithubIssues/AppDelegate.swift +++ b/GithubIssues/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + if !GlobalState.instance.isLoggedIn { let loginViewController = LoginViewController.viewController diff --git a/GithubIssues/Base.lproj/Main.storyboard b/GithubIssues/Base.lproj/Main.storyboard index 2219fc2..72c68d0 100644 --- a/GithubIssues/Base.lproj/Main.storyboard +++ b/GithubIssues/Base.lproj/Main.storyboard @@ -6,6 +6,7 @@ + @@ -14,9 +15,15 @@ - + + + + + + + @@ -53,7 +60,7 @@ - + @@ -76,7 +83,7 @@ - + @@ -94,20 +101,35 @@ - + + + + - + + + + + + + @@ -117,6 +139,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -129,28 +322,28 @@ - + - - + + - - + + - + - + - + @@ -163,19 +356,19 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -299,7 +722,7 @@ - + @@ -333,6 +756,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GithubIssues/Comment.swift b/GithubIssues/Comment.swift new file mode 100644 index 0000000..15cb563 --- /dev/null +++ b/GithubIssues/Comment.swift @@ -0,0 +1,35 @@ +// +// Comment.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import SwiftyJSON + + +extension Model { + public struct Comment { + + let id: Int + let user: Model.User + + let body: String + let createdAt: Date? + let updatedAt: Date? + + public init(json: JSON) { + id = json["id"].intValue + user = Model.User(json: json["user"]) + body = json["body"].stringValue + + let format = DateFormatter() + format.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + createdAt = format.date(from: json["created_at"].stringValue) + updatedAt = format.date(from: json["updated_at"].stringValue) + } + + } +} diff --git a/GithubIssues/CreateIssueViewController.swift b/GithubIssues/CreateIssueViewController.swift new file mode 100644 index 0000000..d57b451 --- /dev/null +++ b/GithubIssues/CreateIssueViewController.swift @@ -0,0 +1,60 @@ +// +// CreateIssueViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 16.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import Alamofire + +class CreateIssueViewController: UIViewController { + + @IBOutlet var textView: UITextView! + @IBOutlet var titleTextField: UITextField! + + var createdIssue: Model.Issue? + var owner: String = "" + var repo: String = "" + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + + +} + + +extension CreateIssueViewController { + func setup() { + textView.layer.borderColor = UIColor(red:0.80, green:0.80, blue:0.80, alpha:1.00).cgColor + textView.layer.borderWidth = 1.0 / UIScreen.main.scale + textView.layer.cornerRadius = 5 + } + + func uploadIssue() { + let title = titleTextField.text ?? "" + let body = textView.text ?? "" + API.createIssue(owner: owner, repo: repo, title: title, body: body) { [weak self] (dataResponse: DataResponse) in + guard let `self` = self else { return } + switch dataResponse.result { + case .success(let issue): + print(issue) + self.createdIssue = issue + self.performSegue(withIdentifier: "UnwindToIssues", sender: self) + case .failure(_): + break + } + } + } +} + + +extension CreateIssueViewController { + @IBAction func DoneButtonTapped(_ sender: Any) { + uploadIssue() + } +} diff --git a/GithubIssues/ExampleViewController.swift b/GithubIssues/ExampleViewController.swift new file mode 100644 index 0000000..26cc5b9 --- /dev/null +++ b/GithubIssues/ExampleViewController.swift @@ -0,0 +1,28 @@ +// +// ExampleViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 15.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import RxSwift +import RxCocoa + +class ExampleViewController: UIViewController { + + @IBOutlet var textField: UITextField! + @IBOutlet var label: UILabel! + var disposeBag: DisposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + textField.rx.text.bind(to: label.rx.text).disposed(by: disposeBag) + + Observable.just(3) + + + } + +} diff --git a/GithubIssues/GlobalState.swift b/GithubIssues/GlobalState.swift index ec2dda4..e36c437 100644 --- a/GithubIssues/GlobalState.swift +++ b/GithubIssues/GlobalState.swift @@ -13,9 +13,10 @@ final class GlobalState { struct constants { - static let tokenKey = "token" - static let ownerKey = "owner" - static let repoKey = "repo" + static let tokenKey = "token" + static let ownerKey = "owner" + static let repoKey = "repo" + static let reposKey = "repos" } var token: String? { @@ -56,4 +57,24 @@ final class GlobalState { return !isEmpty } } + + func addRepo(owner:String, repo: String) { + let dict = ["owner": owner, "repo" : repo] + var repos: [[String: String]] = (UserDefaults.standard.array(forKey: constants.reposKey) as? [[String : String]]) ?? [] + repos.append(dict) + + UserDefaults.standard.set(NSSet(array: repos).allObjects, forKey: constants.reposKey) + } + + var repos: [(owner: String, repo:String)] { + get { + let repoDicts: [[String: String]] = (UserDefaults.standard.array(forKey: constants.reposKey) as? [[String : String]]) ?? [] + let repos = repoDicts.map { (repoDict: [String: String]) -> (String, String) in + let owner = repoDict["owner"] ?? "" + let repo = repoDict["repo"] ?? "" + return (owner, repo) + } + return repos + } + } } diff --git a/GithubIssues/Info.plist b/GithubIssues/Info.plist index d052473..f87b36b 100644 --- a/GithubIssues/Info.plist +++ b/GithubIssues/Info.plist @@ -28,6 +28,8 @@ armv7 + UIStatusBarStyle + UIStatusBarStyleLightContent UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -41,5 +43,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIViewControllerBasedStatusBarAppearance + diff --git a/GithubIssues/Issue.swift b/GithubIssues/Issue.swift index 98eaf67..241a1b2 100644 --- a/GithubIssues/Issue.swift +++ b/GithubIssues/Issue.swift @@ -23,6 +23,7 @@ extension Model { let closedAt: Date? public init(json: JSON) { + print("issue json: \(json)") id = json["id"].intValue number = json["number"].intValue title = json["title"].stringValue @@ -40,17 +41,51 @@ extension Model { } } +extension Model.Issue { + var toDict: [String: Any] { + let format = DateFormatter() + format.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + + + var dict = ["id": id, + "number": number, + "title": title, + "comments": comments, + "body": body, + "state": state.display, + "user": ["id": user.id, "login": user.login, "acatar_url": (user.avatarURL?.absoluteString ?? "")] + ] as [String : Any] + if let createdAt = createdAt { + dict["createdAt"] = format.string(from: createdAt) + } + if let updatedAt = updatedAt { + dict["updatedAt"] = format.string(from: updatedAt) + } + if let closedAt = closedAt { + dict["closedAt"] = format.string(from: closedAt) + } + + print("dict: \(dict)") + return dict + + } +} + extension Model.Issue { enum State: String { - case open = "open", close = "close", none = "none" + case open = "open" + case closed = "closed" + case none = "none" var display: String { switch self { case .open: return "opened" - case .close: return "closed" + case .closed: return "closed" default: return "-" } } + + } } diff --git a/GithubIssues/IssueCell.swift b/GithubIssues/IssueCell.swift index 085db92..ec54101 100644 --- a/GithubIssues/IssueCell.swift +++ b/GithubIssues/IssueCell.swift @@ -17,7 +17,10 @@ class IssueCell: UICollectionViewCell, LayoutEstimatable { @IBOutlet var commentCountButton: UIButton! override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { - return self.estimateLayoutAttributes(layoutAttributes) + let layoutAttributes = self.estimateLayoutAttributes(layoutAttributes) + + print("preferredLayoutAttributesFitting:\(layoutAttributes.bounds.size), item: \(layoutAttributes.indexPath.item)") + return layoutAttributes } } @@ -29,7 +32,7 @@ extension IssueCell { let createdAt = issue.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" contentLabel.text = "#\(issue.number) \(issue.state.display) on \(createdAt) by \(issue.user.login)" commentCountButton.setTitle("\(issue.comments)", for: .normal) - stateButton.isSelected = issue.state == .close + stateButton.isSelected = issue.state == .closed } } diff --git a/GithubIssues/IssueCommentCell.swift b/GithubIssues/IssueCommentCell.swift new file mode 100644 index 0000000..07a1567 --- /dev/null +++ b/GithubIssues/IssueCommentCell.swift @@ -0,0 +1,38 @@ +// +// IssueCommentCell.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import AlamofireImage + + +class IssueCommentCell: UICollectionViewCell { + @IBOutlet var bodyLabel: UILabel! + @IBOutlet var titleLabel: UILabel! + @IBOutlet var profileImageView: UIImageView! + @IBOutlet var commentContanerView: UIView! + + override func awakeFromNib() { + profileImageView.layer.cornerRadius = profileImageView.frame.size.width / 2 + commentContanerView.layer.borderWidth = 1 + commentContanerView.layer.borderColor = UIColor.groupTableViewBackground.cgColor + } + +} + +extension IssueCommentCell { + + func update(data: Model.Comment, withImage: Bool = true) { + if let url = data.user.avatarURL, withImage { + profileImageView.af_setImage(withURL: url) + } + + let createdAt = data.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + titleLabel.text = "\(data.user.login) commented on \(createdAt)" + bodyLabel.text = data.body + } +} diff --git a/GithubIssues/IssueCommentCell.xib b/GithubIssues/IssueCommentCell.xib new file mode 100644 index 0000000..f870a7b --- /dev/null +++ b/GithubIssues/IssueCommentCell.xib @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GithubIssues/IssueDetailHeaderView.swift b/GithubIssues/IssueDetailHeaderView.swift new file mode 100644 index 0000000..67c8235 --- /dev/null +++ b/GithubIssues/IssueDetailHeaderView.swift @@ -0,0 +1,71 @@ +// +// IssueDetailHeaderView.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 12.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +class IssueDetailHeaderView: UIView { + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var stateButton: UIButton! + @IBOutlet weak var infoLabel: UILabel! + + @IBOutlet weak var commentContainerView: UIView! + @IBOutlet weak var avatarImageView: UIImageView! + @IBOutlet weak var commentInfoLabel: UILabel! + @IBOutlet weak var commentBodyLabel: UILabel! + + + override func awakeFromNib() { + super.awakeFromNib() + setup() + print("awakeFromNib") + } +} + + +// MARK: - setup +extension IssueDetailHeaderView { + func setup() { + stateButton.clipsToBounds = true + stateButton.layer.cornerRadius = 2 + + stateButton.setTitle(Model.Issue.State.open.display, for: .normal) + stateButton.setBackgroundImage(UIColor.opened.toImage(), for: .normal) + stateButton.setTitle(Model.Issue.State.closed.display, for: .selected) + stateButton.setBackgroundImage(UIColor.closed.toImage(), for: .selected) + + avatarImageView.clipsToBounds = true + avatarImageView.layer.cornerRadius = avatarImageView.bounds.midX + + commentContainerView.clipsToBounds = true + commentContainerView.layer.cornerRadius = 2 + commentContainerView.layer.borderColor = UIColor.groupTableViewBackground.cgColor + commentContainerView.layer.borderWidth = 1 + + } + + +} + +extension IssueDetailHeaderView { + func update(data: Model.Issue, withImage: Bool = true) { + + let createdAt = data.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + titleLabel.text = data.title + stateButton.isSelected = data.state == .closed + infoLabel.text = "\(data.user.login) \(data.state.display) this issue on \(createdAt) · \(data.comments) comments" + + //body + if let url = data.user.avatarURL, withImage { + avatarImageView.af_setImage(withURL: url) + } + commentInfoLabel.text = "\(data.user.login) commented on \(createdAt)" + commentBodyLabel.text = data.body + } + +} + diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift new file mode 100644 index 0000000..564695f --- /dev/null +++ b/GithubIssues/IssueDetailViewController.swift @@ -0,0 +1,302 @@ +// +// IssueDetailViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 10.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit +import Alamofire + +protocol DatasourceRefreshable: class { + var datasource: [Model.Comment] { get set } + var needRefreshDatasource: Bool { get set } +} + +extension DatasourceRefreshable { + func setNeedRefreshDatasource() { + needRefreshDatasource = true + } + + func refreshDataSourceIfNeeded() { + if needRefreshDatasource { + datasource = [] + needRefreshDatasource = false + } + } +} + +class IssueDetailViewController: UIViewController, DatasourceRefreshable { + + @IBOutlet var collectionView: UICollectionView! + @IBOutlet var headerView: IssueDetailHeaderView! + @IBOutlet var commentInputBottomConstraint: NSLayoutConstraint! + + @IBOutlet var commentTextField: UITextField! + var owner: String = "" + var repo: String = "" + var issue: Model.Issue! + var datasource: [Model.Comment] = [] + var needRefreshDatasource: Bool = false + fileprivate let refreshControl = UIRefreshControl() + fileprivate var page: Int = 1 + fileprivate var canLoadMore: Bool = true + fileprivate var isLoading: Bool = false + fileprivate lazy var estimateCell: IssueCommentCell = { _ in + let cell = Bundle.main.loadNibNamed("IssueCommentCell", owner: nil, options: nil)?.first + return cell as! IssueCommentCell + }() + fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + addKeyboardNotification() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + removeKeyboardNOtification() + } + +} + +extension IssueDetailViewController { + + func addKeyboardNotification() { + NotificationCenter.default.addObserver(forName: NSNotification.Name.UIKeyboardWillChangeFrame, object: nil, queue: nil) { [weak self] (notifiaction: Notification) in + guard let `self` = self else { return } + guard let keyboardBounds = notifiaction.userInfo?[UIKeyboardFrameEndUserInfoKey] as? CGRect else { return } + guard let animationDuration = notifiaction.userInfo?[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval else { return } + guard let animationCurve = notifiaction.userInfo?[UIKeyboardAnimationCurveUserInfoKey] as? UInt else { return } + let animationOptions = UIViewAnimationOptions(rawValue: animationCurve) + + print(notifiaction.userInfo) + + + + + + let keyboardHeight = keyboardBounds.height + + + let inputBottom = self.view.frame.height - keyboardBounds.origin.y + print("inputBottom: \(inputBottom)") + print("keyboard: \(keyboardHeight)") + + + var inset = self.collectionView.contentInset + inset.bottom = inputBottom + 46 + self.collectionView.contentInset = inset + + + + self.commentInputBottomConstraint.constant = inputBottom + UIView.animate(withDuration: animationDuration, delay: 0, options: animationOptions, animations: { + self.view.layoutIfNeeded() + }, completion: nil) + + + } + } + + func removeKeyboardNOtification() { + NotificationCenter.default.removeObserver(self) + } + + func setup() { + collectionView.addSubview(refreshControl) + collectionView.alwaysBounceVertical = true + collectionView.register(UINib(nibName: "IssueCommentCell", bundle: nil), forCellWithReuseIdentifier: "IssueCommentCell") + refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) + + var inset = self.collectionView.contentInset + inset.bottom = 46 + self.collectionView.contentInset = inset + + title = "#\(issue!.number)" + loadHeaderView() + load() + } + func loadHeaderView() { + if let issue = issue { + headerView.update(data: issue) + collectionView.addSubview(headerView) + var targetSize = CGSize(width: collectionView.frame.width, height: 0) + + let size = headerView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: UILayoutPriorityRequired, + verticalFittingPriority: UILayoutPriorityDefaultLow + ) + + let width = size.width == 0 ? headerView.bounds.width : size.width + let height = size.height == 0 ? headerView.bounds.height : size.height + let viewSize = CGSize(width: width, height: height) + headerView.setNeedsLayout() + headerView.layoutIfNeeded() + headerView.frame.size.width = collectionView.frame.width + headerView.frame = CGRect(x: 0, y: -viewSize.height, width: viewSize.width, height: viewSize.height) + collectionView.contentInset = UIEdgeInsets(top: headerView.frame.height, left: 0, bottom: 0, right: 0) + } + } +} + +extension IssueDetailViewController { + @IBAction func sendButtonTapped(_ sender: Any) { + let comment = commentTextField.text ?? "" + API.createComment(owner: owner, repo: repo, number: issue.number, comment: comment) { [weak self] (dataResponse: DataResponse) in + guard let `self` = self else { return } + switch dataResponse.result { + case .success(let comment): + self.addComment(comment: comment) + self.commentTextField.text = "" + self.commentTextField.resignFirstResponder() + + break + case .failure(_): + break + } + } + } + + @IBAction func stateButtonTapped(_ sender: Any) { + chagneState() + } +} + +extension IssueDetailViewController { + func load() { + isLoading = true + API.issueDetail(owner: owner , repo: repo, number: issue.number, page: page) { [weak self] (response: DataResponse<[Model.Comment]>) in + guard let `self` = self else { return } + switch response.result { + case .success(let comments): + self.dataLoaded(comments: comments) + self.isLoading = false + case .failure(_): + self.isLoading = false + } + } + } + + func addComment(comment: Model.Comment) { + let newIndexPath = IndexPath(item: datasource.count, section: 0) + datasource.append(comment) + collectionView.insertItems(at: [newIndexPath]) + + collectionView.scrollToItem(at: newIndexPath, at: .bottom, animated: true) + + } + + func dataLoaded(comments: [Model.Comment]) { + refreshDataSourceIfNeeded() + datasource = datasource + comments + page = page + 1 + if comments.count == 0 { + canLoadMore = false + } + refreshControl.endRefreshing() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.collectionView.reloadData() + } + + collectionView.reloadData() + } + + func refresh() { + page = 1 + canLoadMore = true + setNeedRefreshDatasource() + load() + } + + func loadMore() { + if canLoadMore { + load() + } + } + + func chagneState() { + switch issue.state { + case .open, .none: + API.closeIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in + switch dataResponse.result { + case .success(let issue): + print("issue: \(issue)") + self?.issue = issue + self?.loadHeaderView() + case .failure(let error): + print(dataResponse.request) + print(error) + } + + }) + case .closed: + API.openIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in + switch dataResponse.result { + case .success(let issue): + print("issue: \(issue)") + self?.issue = issue + self?.loadHeaderView() + case .failure(let error): + print(dataResponse.request) + print(error) + } + }) + } + } +} + +extension IssueDetailViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCommentCell", for: indexPath) as! IssueCommentCell + let comment = datasource[indexPath.item] + cell.update(data: comment) + + return cell + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return datasource.count + } +} + +extension IssueDetailViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + print("item: \(indexPath.item), count: \(datasource.count)") + if indexPath.item == datasource.count - 1 && !isLoading{ + loadMore() + } + } +} + +extension IssueDetailViewController: UICollectionViewDelegateFlowLayout { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = collectionView.bounds.width - 8*2 + let targetSize = CGSize(width: width, height: 48) + + var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero + if estimatedSize != .zero { + return estimatedSize + } + + let data = datasource[indexPath.item] + estimateCell.update(data: data, withImage: false) + + + estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) + estimatedSizes[indexPath] = estimatedSize + + return estimatedSize + } +} diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift index 38d1f24..7547072 100644 --- a/GithubIssues/IssuesViewController.swift +++ b/GithubIssues/IssuesViewController.swift @@ -10,48 +10,79 @@ import UIKit import Alamofire class IssuesViewController: UIViewController { - + @IBOutlet var collectionView: UICollectionView! var owner: String = "" var repo: String = "" fileprivate var datasource: [Model.Issue] = [] - @IBOutlet var collectionView: UICollectionView! - let refreshControl = UIRefreshControl() - var page: Int = 1 - var canLoadMore: Bool = true - private var needRefreshDatasource: Bool = false - var isLoading: Bool = false - - func setNeedRefreshDatasource() { - needRefreshDatasource = true - } - func refreshDataSourceIfNeeded() { - if needRefreshDatasource { - self.datasource = [] - needRefreshDatasource = false - } - } - + fileprivate let refreshControl = UIRefreshControl() + fileprivate var page: Int = 0 + fileprivate var canLoadMore: Bool = true + fileprivate var needRefreshDatasource: Bool = false + fileprivate var isLoading: Bool = false + override func viewDidLoad() { super.viewDidLoad() + setup() + } +} + +extension IssuesViewController { + func setup() { collectionView.addSubview(refreshControl) collectionView.alwaysBounceVertical = true refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) if let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { print("estimatedItemSize") print("size : \(CGSize(width: UIScreen.main.bounds.width, height: 56))") - flowLayout.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 256) + flowLayout.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 56) } load() } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let detailViewController = segue.destination as? IssueDetailViewController, + let cell = sender as? IssueCell, + let indexPath = collectionView.indexPath(for: cell) { + let issue = datasource[indexPath.item] + detailViewController.issue = issue + detailViewController.repo = repo + detailViewController.owner = owner + } else if let navigationController = segue.destination as? UINavigationController, let createIssueViewController = navigationController.topViewController as? CreateIssueViewController { + createIssueViewController.repo = repo + createIssueViewController.owner = owner + } + } + + @IBAction func unwindFromCreate(_ segue: UIStoryboardSegue) { + if let createViewController = segue.source as? CreateIssueViewController, let createdIssue = createViewController.createdIssue { + datasource.insert(createdIssue, at: 0) + DispatchQueue.main.async { [weak self] in + self?.collectionView.reloadData() + } + + } + + } +} - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - +extension IssuesViewController { + func setNeedRefreshDatasource() { + needRefreshDatasource = true } + func refreshDataSourceIfNeeded() { + if needRefreshDatasource { + self.datasource = [] + collectionView.reloadData() + needRefreshDatasource = false + } + } +} + +extension IssuesViewController { func load() { isLoading = true - API.repoIssues(owner: owner, repo: repo, page: page) {[weak self] (response: DataResponse<[Model.Issue]>) in + API.repoIssues(owner: owner, repo: repo, page: page + 1) {[weak self] (response: DataResponse<[Model.Issue]>) in guard let `self` = self else { return } switch response.result { case .success(let issues): @@ -66,21 +97,26 @@ class IssuesViewController: UIViewController { func dataLoaded(issues: [Model.Issue]) { refreshDataSourceIfNeeded() - datasource = datasource + issues + page = page + 1 if issues.count == 0 { canLoadMore = false } refreshControl.endRefreshing() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.collectionView.reloadData() + + print("datasource.count,1:\(datasource.count)") + datasource.append(contentsOf: issues) + print("datasource.count,2:\(datasource.count)") + DispatchQueue.main.async { [weak self] in + self?.collectionView.reloadData() } - collectionView.reloadData() +// collectionView.reloadSections(IndexSet(integer: 0)) + } func refresh() { - page = 1 + page = 0 canLoadMore = true setNeedRefreshDatasource() load() @@ -95,10 +131,10 @@ class IssuesViewController: UIViewController { extension IssuesViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + print("item: \(indexPath.item), count: \(datasource.count)") let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCell", for: indexPath) as! IssueCell let issue = datasource[indexPath.item] cell.update(issue: issue) - return cell } @@ -109,13 +145,23 @@ extension IssuesViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return datasource.count } + + } extension IssuesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - print("item: \(indexPath.item), count: \(datasource.count)") +// print("item: \(indexPath.item), count: \(datasource.count)") if indexPath.item == datasource.count - 1 && !isLoading{ loadMore() } } } + +extension IssuesViewController: UICollectionViewDelegateFlowLayout { + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + + + return CGSize(width: collectionView.frame.size.width, height: 100) + } +} diff --git a/GithubIssues/RepoViewController.swift b/GithubIssues/RepoViewController.swift index 963838d..4bbc9bf 100644 --- a/GithubIssues/RepoViewController.swift +++ b/GithubIssues/RepoViewController.swift @@ -36,11 +36,35 @@ class RepoViewController: UIViewController { } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - guard let owner = ownerTextField.text, let repo = repoTextField.text else { return } - GlobalState.instance.owner = owner - GlobalState.instance.repo = repo - guard let issuesViewController = segue.destination as? IssuesViewController else { return } - issuesViewController.owner = owner - issuesViewController.repo = repo + + if segue.identifier == "EnterRepoSegue" { + guard let owner = ownerTextField.text, let repo = repoTextField.text else { return } + GlobalState.instance.owner = owner + GlobalState.instance.repo = repo + GlobalState.instance.addRepo(owner: owner, repo: repo) + guard let issuesViewController = segue.destination as? IssuesViewController else { return } + issuesViewController.owner = owner + issuesViewController.repo = repo + } + } + + + @IBAction func unwindFromRepos(_ segue: UIStoryboardSegue) { + if let reposViewController = segue.source as? ReposViewController, let (owner, repo) = reposViewController.selectedRepo { + ownerTextField.text = owner + repoTextField.text = repo + DispatchQueue.main.async { [weak self] in + self?.performSegue(withIdentifier: "EnterRepoSegue", sender: nil) + } + + } + } + + @IBAction func logoutButtonTapped(_ sender: Any?) { + GlobalState.instance.token = "" + let loginViewController = LoginViewController.viewController + DispatchQueue.main.asyncAfter(deadline: .now() + 0.0, execute: { [weak self] in + self?.present(loginViewController, animated: true, completion: nil) + }) } } diff --git a/GithubIssues/ReposViewController.swift b/GithubIssues/ReposViewController.swift new file mode 100644 index 0000000..2b85473 --- /dev/null +++ b/GithubIssues/ReposViewController.swift @@ -0,0 +1,56 @@ +// +// ReposViewController.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 17.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +class ReposViewController: UIViewController { + + @IBOutlet var tableView: UITableView! + var selectedRepo: (owner: String, repo: String)? + let datasource = GlobalState.instance.repos + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + // MARK: - Navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + + } + + +} + +extension ReposViewController { + func setup() { + tableView.dataSource = self + tableView.delegate = self + } +} + +extension ReposViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return datasource.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "RepoCell", for: indexPath) + let data = datasource[indexPath.row] + cell.textLabel?.text = "/repo/\(data.owner)/\(data.repo)" + return cell + } +} + +extension ReposViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let data = datasource[indexPath.row] + selectedRepo = data + self.performSegue(withIdentifier: "unwindToIssue", sender: self) + } +} diff --git a/GithubIssues/Router.swift b/GithubIssues/Router.swift index ea0a609..f56f606 100644 --- a/GithubIssues/Router.swift +++ b/GithubIssues/Router.swift @@ -13,6 +13,12 @@ import SwiftyJSON enum Router { case authKey(Parameters, HTTPHeaders) case repoIssues(owner: String, repo: String, parameters: Parameters) + case issueDetail(owner: String, repo: String, number: Int, parameters: Parameters) + case createComment(owner: String, repo: String, number: Int, parameters: Parameters) + case createIssue(owner: String, repo: String, parameters: Parameters) + case editIssue(owner: String, repo: String, number: Int, parameters: Parameters) + +// PATCH /repos/:owner/:repo/issues/:number } @@ -26,8 +32,17 @@ extension Router: URLRequestConvertible { switch self { case .authKey: return .put - case .repoIssues: + case .repoIssues, + .issueDetail + : return .get + case .createComment, + .createIssue + : + return .post + case .editIssue + : + return .patch } } @@ -37,6 +52,14 @@ extension Router: URLRequestConvertible { return "/authorizations/clients/\(Router.clientID)/\(Date().timeIntervalSince1970)" case let .repoIssues(owner, repo, _): return "/repos/\(owner)/\(repo)/issues" + case let .issueDetail(owner, repo, number, _): + return "/repos/\(owner)/\(repo)/issues/\(number)/comments" + case let .createComment(owner, repo, number, _): + return "/repos/\(owner)/\(repo)/issues/\(number)/comments" + case let .createIssue(owner, repo, _): + return "/repos/\(owner)/\(repo)/issues" + case let .editIssue(owner, repo, number, _): + return "/repos/\(owner)/\(repo)/issues/\(number)" } } @@ -55,6 +78,14 @@ extension Router: URLRequestConvertible { urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) case let .repoIssues(_, _, parameters): urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case let .issueDetail(_, _, _, parameters): + urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case let .createComment(_, _, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + case let .createIssue(_, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + case let .editIssue(_, _, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) } return urlRequest diff --git a/GithubIssues/UIColor+extension.swift b/GithubIssues/UIColor+extension.swift new file mode 100644 index 0000000..e79ee97 --- /dev/null +++ b/GithubIssues/UIColor+extension.swift @@ -0,0 +1,37 @@ +// +// UIColor+extension.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 12.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +extension UIColor { + static var opened: UIColor { + return UIColor(red: 131/255, green: 189/255, blue: 71/255, alpha: 1) + } + + static var closed: UIColor { + return UIColor(red: 176/255, green: 65/255, blue: 32/255, alpha: 1) + } + +} + +extension UIColor { + public func toImage(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + UIGraphicsBeginImageContext(rect.size) + + if let context = UIGraphicsGetCurrentContext() { + context.setFillColor(self.cgColor) + context.fill(rect) + if let image = UIGraphicsGetImageFromCurrentImageContext() { + UIGraphicsEndImageContext() + return image + } + } + return UIImage() + } +} diff --git a/Podfile b/Podfile index 265154f..dcb70c8 100644 --- a/Podfile +++ b/Podfile @@ -1,11 +1,14 @@ # Uncomment the next line to define a global platform for your project -# platform :ios, '9.0' + platform :ios, '10.0' target 'GithubIssues' do # Comment the next line if you're not using Swift and don't want to use dynamic frameworks use_frameworks! pod 'Alamofire', '~> 4.5' + pod 'AlamofireImage' pod 'SwiftyJSON' + pod 'RxSwift' + pod 'RxCocoa' target 'GithubIssuesTests' do inherit! :search_paths From fe7e01689df0af9e2d8b927671ddd2720421e021 Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Wed, 20 Sep 2017 15:31:39 +0900 Subject: [PATCH 4/9] =?UTF-8?q?loadmore=20=EB=B7=B0=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GithubIssues.xcodeproj/project.pbxproj | 4 + GithubIssues/Base.lproj/Main.storyboard | 126 ++++++------------- GithubIssues/IssueCell.swift | 19 ++- GithubIssues/IssueCell.xib | 103 +++++++++++++++ GithubIssues/IssueDetailViewController.swift | 3 +- GithubIssues/IssuesViewController.swift | 86 ++++++++++--- 6 files changed, 225 insertions(+), 116 deletions(-) create mode 100644 GithubIssues/IssueCell.xib diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 4cc6a43..9379e8c 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ D7014DC61F6814A000F0F7F7 /* IssueDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */; }; D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */; }; D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D730D9951F6AE4DA0057276B /* ExampleViewController.swift */; }; + D7412BD31F701F4A00E63DF6 /* IssueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D7412BD21F701F4A00E63DF6 /* IssueCell.xib */; }; D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DD1F64118400380CEE /* AppDelegate.swift */; }; D7CE58E31F64118400380CEE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E11F64118400380CEE /* Main.storyboard */; }; D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E41F64118400380CEE /* Assets.xcassets */; }; @@ -69,6 +70,7 @@ D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueDetailHeaderView.swift; sourceTree = ""; }; D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+extension.swift"; sourceTree = ""; }; D730D9951F6AE4DA0057276B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; + D7412BD21F701F4A00E63DF6 /* IssueCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IssueCell.xib; sourceTree = ""; }; D7CE58DA1F64118400380CEE /* GithubIssues.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubIssues.app; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DD1F64118400380CEE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D7CE58E21F64118400380CEE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -261,6 +263,7 @@ D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderView.swift */, D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */, D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */, + D7412BD21F701F4A00E63DF6 /* IssueCell.xib */, ); name = View; sourceTree = ""; @@ -385,6 +388,7 @@ buildActionMask = 2147483647; files = ( D7CE58E81F64118400380CEE /* LaunchScreen.storyboard in Resources */, + D7412BD31F701F4A00E63DF6 /* IssueCell.xib in Resources */, D7CE59301F658CA900380CEE /* IssueCommentCell.xib in Resources */, D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */, D7CE58E31F64118400380CEE /* Main.storyboard in Resources */, diff --git a/GithubIssues/Base.lproj/Main.storyboard b/GithubIssues/Base.lproj/Main.storyboard index 72c68d0..7b0f758 100644 --- a/GithubIssues/Base.lproj/Main.storyboard +++ b/GithubIssues/Base.lproj/Main.storyboard @@ -331,91 +331,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -439,9 +355,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -458,7 +409,7 @@ - + @@ -809,8 +760,5 @@ - - - diff --git a/GithubIssues/IssueCell.swift b/GithubIssues/IssueCell.swift index ec54101..a16d34c 100644 --- a/GithubIssues/IssueCell.swift +++ b/GithubIssues/IssueCell.swift @@ -8,22 +8,21 @@ import UIKit -class IssueCell: UICollectionViewCell, LayoutEstimatable { - static var estimatedLayout: [IndexPath: CGSize] = [:] - +class IssueCell: UICollectionViewCell { + @IBOutlet var stateButton: UIButton! @IBOutlet var titleLabel: UILabel! @IBOutlet var contentLabel: UILabel! @IBOutlet var commentCountButton: UIButton! - - override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { - let layoutAttributes = self.estimateLayoutAttributes(layoutAttributes) - - print("preferredLayoutAttributesFitting:\(layoutAttributes.bounds.size), item: \(layoutAttributes.indexPath.item)") - return layoutAttributes - } } +extension IssueCell { + static var cellFromNib: IssueCell { + get { + return Bundle.main.loadNibNamed("IssueCell", owner: nil, options: nil)?.first as! IssueCell + } + } +} extension IssueCell { func update(issue: Model.Issue) { diff --git a/GithubIssues/IssueCell.xib b/GithubIssues/IssueCell.xib new file mode 100644 index 0000000..2e2099c --- /dev/null +++ b/GithubIssues/IssueCell.xib @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift index 564695f..0f6a8f5 100644 --- a/GithubIssues/IssueDetailViewController.swift +++ b/GithubIssues/IssueDetailViewController.swift @@ -10,7 +10,8 @@ import UIKit import Alamofire protocol DatasourceRefreshable: class { - var datasource: [Model.Comment] { get set } + associatedtype Item + var datasource: [Item] { get set } var needRefreshDatasource: Bool { get set } } diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift index 7547072..46ae234 100644 --- a/GithubIssues/IssuesViewController.swift +++ b/GithubIssues/IssuesViewController.swift @@ -9,6 +9,33 @@ import UIKit import Alamofire +class LoadMoreView: UIView { + @IBOutlet var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet var doneView: UIView! + +} + +extension LoadMoreView { + func loadDone() { + activityIndicatorView.isHidden = true + doneView.isHidden = false + } + + func load() { + activityIndicatorView.isHidden = false + doneView.isHidden = true + } +} + + +protocol Feed: class { + var collectionView: UICollectionView! { get set } + var datasource: [Model.Issue] { get set } + var refreshControl: UIRefreshControl { get set } + var canLoadMore: Bool { get set } + +} + class IssuesViewController: UIViewController { @IBOutlet var collectionView: UICollectionView! var owner: String = "" @@ -19,7 +46,9 @@ class IssuesViewController: UIViewController { fileprivate var canLoadMore: Bool = true fileprivate var needRefreshDatasource: Bool = false fileprivate var isLoading: Bool = false - + fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] + fileprivate let estimateCell: IssueCell = IssueCell.cellFromNib + @IBOutlet fileprivate var loadMoreView: LoadMoreView! override func viewDidLoad() { super.viewDidLoad() setup() @@ -30,13 +59,23 @@ extension IssuesViewController { func setup() { collectionView.addSubview(refreshControl) collectionView.alwaysBounceVertical = true + collectionView.register(UINib(nibName: "IssueCell", bundle: nil), forCellWithReuseIdentifier: "IssueCell") refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - if let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { - print("estimatedItemSize") - print("size : \(CGSize(width: UIScreen.main.bounds.width, height: 56))") - flowLayout.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 56) - } load() + footer() + loadMoreView.load() + } + + func layoutFooter() { + loadMoreView.frame.origin.y = collectionView.contentSize.height + loadMoreView.frame.size.width = collectionView.frame.width + loadMoreView.frame.size.height = 50 + } + func footer() { + collectionView.addSubview(loadMoreView) + var inset = collectionView.contentInset + inset.bottom = 50 + collectionView.contentInset = inset } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { @@ -101,23 +140,28 @@ extension IssuesViewController { page = page + 1 if issues.count == 0 { canLoadMore = false + loadMoreView.loadDone() + } refreshControl.endRefreshing() - print("datasource.count,1:\(datasource.count)") datasource.append(contentsOf: issues) - print("datasource.count,2:\(datasource.count)") + + print("collectionView.frame1: \(collectionView.contentSize)") + collectionView.reloadData() + print("collectionView.frame2: \(collectionView.contentSize)") + DispatchQueue.main.async { [weak self] in - self?.collectionView.reloadData() + self?.layoutFooter() } - -// collectionView.reloadSections(IndexSet(integer: 0)) + print("collectionView.frame3: \(collectionView.contentSize)") } func refresh() { page = 0 canLoadMore = true + loadMoreView.load() setNeedRefreshDatasource() load() } @@ -131,13 +175,12 @@ extension IssuesViewController { extension IssuesViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - print("item: \(indexPath.item), count: \(datasource.count)") let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCell", for: indexPath) as! IssueCell let issue = datasource[indexPath.item] cell.update(issue: issue) return cell } - + func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } @@ -151,7 +194,6 @@ extension IssuesViewController: UICollectionViewDataSource { extension IssuesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { -// print("item: \(indexPath.item), count: \(datasource.count)") if indexPath.item == datasource.count - 1 && !isLoading{ loadMore() } @@ -160,8 +202,20 @@ extension IssuesViewController: UICollectionViewDelegate { extension IssuesViewController: UICollectionViewDelegateFlowLayout { public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - - return CGSize(width: collectionView.frame.size.width, height: 100) + var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero + if estimatedSize != .zero { + return estimatedSize + } + let data = datasource[indexPath.item] + + estimateCell.update(issue: data) + + let targetSize = CGSize(width: collectionView.frame.size.width, height: 50) + + estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) + estimatedSizes[indexPath] = estimatedSize + + return estimatedSize } } From d7dc08100f732302640a17292f80117d7ad8ffe0 Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Sat, 23 Sep 2017 01:42:06 +0900 Subject: [PATCH 5/9] =?UTF-8?q?1=EC=B0=A8=20=EC=B6=94=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GithubIssues.xcodeproj/project.pbxproj | 10 ++ GithubIssues/API.swift | 18 +++ GithubIssues/Base.lproj/Main.storyboard | 30 ++-- GithubIssues/IssueDetailViewController.swift | 22 +-- GithubIssues/IssuesViewController.swift | 151 ++----------------- GithubIssues/ListViewController.swift | 137 +++++++++++++++++ GithubIssues/LoadMoreView.swift | 27 ++++ 7 files changed, 219 insertions(+), 176 deletions(-) create mode 100644 GithubIssues/ListViewController.swift create mode 100644 GithubIssues/LoadMoreView.swift diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 9379e8c..b0ef72f 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */; }; D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D730D9951F6AE4DA0057276B /* ExampleViewController.swift */; }; D7412BD31F701F4A00E63DF6 /* IssueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D7412BD21F701F4A00E63DF6 /* IssueCell.xib */; }; + D7CB7A351F74A3C700816E33 /* ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB7A341F74A3C700816E33 /* ListViewController.swift */; }; + D7CB7A371F74A41900816E33 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB7A361F74A41900816E33 /* LoadMoreView.swift */; }; D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58DD1F64118400380CEE /* AppDelegate.swift */; }; D7CE58E31F64118400380CEE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E11F64118400380CEE /* Main.storyboard */; }; D7CE58E51F64118400380CEE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E41F64118400380CEE /* Assets.xcassets */; }; @@ -71,6 +73,8 @@ D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+extension.swift"; sourceTree = ""; }; D730D9951F6AE4DA0057276B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; D7412BD21F701F4A00E63DF6 /* IssueCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IssueCell.xib; sourceTree = ""; }; + D7CB7A341F74A3C700816E33 /* ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewController.swift; sourceTree = ""; }; + D7CB7A361F74A41900816E33 /* LoadMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreView.swift; sourceTree = ""; }; D7CE58DA1F64118400380CEE /* GithubIssues.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubIssues.app; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58DD1F64118400380CEE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D7CE58E21F64118400380CEE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -253,6 +257,7 @@ D7CE591B1F64D80F00380CEE /* View */ = { isa = PBXGroup; children = ( + D7CB7A341F74A3C700816E33 /* ListViewController.swift */, D7CE591C1F64D82E00380CEE /* IssuesViewController.swift */, D7CE591E1F64D99300380CEE /* LoginViewController.swift */, D7CE59231F65005D00380CEE /* RepoViewController.swift */, @@ -264,6 +269,7 @@ D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */, D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */, D7412BD21F701F4A00E63DF6 /* IssueCell.xib */, + D7CB7A361F74A41900816E33 /* LoadMoreView.swift */, ); name = View; sourceTree = ""; @@ -570,8 +576,10 @@ D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */, D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */, D7CE592E1F655C1C00380CEE /* Comment.swift in Sources */, + D7CB7A351F74A3C700816E33 /* ListViewController.swift in Sources */, D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */, D7CE592C1F655AF600380CEE /* IssueCommentCell.swift in Sources */, + D7CB7A371F74A41900816E33 /* LoadMoreView.swift in Sources */, D7CE591F1F64D99300380CEE /* LoginViewController.swift in Sources */, D7ED99D41F6D71ED00E4B903 /* CreateIssueViewController.swift in Sources */, D7CE59131F64CBD800380CEE /* API.swift in Sources */, @@ -734,6 +742,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.iosDeveloper.GithubIssues; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 3.0; }; name = Debug; @@ -748,6 +757,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.iosDeveloper.GithubIssues; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; SWIFT_VERSION = 3.0; }; name = Release; diff --git a/GithubIssues/API.swift b/GithubIssues/API.swift index 01508b3..1a5521d 100644 --- a/GithubIssues/API.swift +++ b/GithubIssues/API.swift @@ -37,6 +37,24 @@ struct API { } } + typealias IssueResponsesHandler = (DataResponse<[Model.Issue]>) -> Void + static func repoIssues(owner: String, repo: String) -> (Int, @escaping IssueResponsesHandler) -> Void { + + return { (page: Int, handler: @escaping IssueResponsesHandler) in + let parameters: Parameters = ["page": page, "state": "all"] + Alamofire.request(Router.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in + return json.arrayValue.map{ + Model.Issue(json: $0) + } + }) + handler(result) + } + } + } + + + static func issueDetail(owner: String, repo: String, number: Int, page: Int, completionHandler: @escaping (DataResponse<[Model.Comment]>) -> Void) { let parameters: Parameters = ["page": page] Alamofire.request(Router.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in diff --git a/GithubIssues/Base.lproj/Main.storyboard b/GithubIssues/Base.lproj/Main.storyboard index 7b0f758..87bc129 100644 --- a/GithubIssues/Base.lproj/Main.storyboard +++ b/GithubIssues/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + @@ -17,7 +17,7 @@ - + @@ -126,7 +126,7 @@ - + @@ -203,7 +203,7 @@ - + @@ -294,7 +294,7 @@ - + @@ -482,16 +482,16 @@ - + - + @@ -409,15 +378,23 @@ - + - - + + + + + + + + + + @@ -474,147 +451,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/GithubIssues/Comment.swift b/GithubIssues/Comment.swift index 15cb563..5a6dfeb 100644 --- a/GithubIssues/Comment.swift +++ b/GithubIssues/Comment.swift @@ -33,3 +33,9 @@ extension Model { } } + +extension Model.Comment { + func a() { + } +} + diff --git a/GithubIssues/IssueCell.swift b/GithubIssues/IssueCell.swift index a16d34c..122610a 100644 --- a/GithubIssues/IssueCell.swift +++ b/GithubIssues/IssueCell.swift @@ -8,7 +8,13 @@ import UIKit -class IssueCell: UICollectionViewCell { +protocol CellProtocol { + associatedtype Item + func update(data: Item) + static var cellFromNib: Self { get } +} + +final class IssueCell: UICollectionViewCell { @IBOutlet var stateButton: UIButton! @IBOutlet var titleLabel: UILabel! @@ -16,16 +22,16 @@ class IssueCell: UICollectionViewCell { @IBOutlet var commentCountButton: UIButton! } -extension IssueCell { +extension IssueCell: CellProtocol { + typealias Item = Model.Issue + static var cellFromNib: IssueCell { get { return Bundle.main.loadNibNamed("IssueCell", owner: nil, options: nil)?.first as! IssueCell } } -} -extension IssueCell { - func update(issue: Model.Issue) { + func update(data issue: Model.Issue) { titleLabel.text = issue.title contentLabel.text = issue.body let createdAt = issue.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" diff --git a/GithubIssues/IssueCell.xib b/GithubIssues/IssueCell.xib index 2e2099c..089a7a6 100644 --- a/GithubIssues/IssueCell.xib +++ b/GithubIssues/IssueCell.xib @@ -1,17 +1,17 @@ - + - + - + diff --git a/GithubIssues/IssueCommentCell.swift b/GithubIssues/IssueCommentCell.swift index 07a1567..1c32a30 100644 --- a/GithubIssues/IssueCommentCell.swift +++ b/GithubIssues/IssueCommentCell.swift @@ -10,7 +10,9 @@ import UIKit import AlamofireImage -class IssueCommentCell: UICollectionViewCell { +final class IssueCommentCell: UICollectionViewCell, CellProtocol { + + @IBOutlet var bodyLabel: UILabel! @IBOutlet var titleLabel: UILabel! @IBOutlet var profileImageView: UIImageView! @@ -25,14 +27,25 @@ class IssueCommentCell: UICollectionViewCell { } extension IssueCommentCell { + func update(data: Model.Comment) { + update(data: data, withImage: true) + } + + typealias Item = Model.Comment - func update(data: Model.Comment, withImage: Bool = true) { - if let url = data.user.avatarURL, withImage { + func update(data comment: Model.Comment, withImage: Bool = true) { + if let url = comment.user.avatarURL { profileImageView.af_setImage(withURL: url) } - let createdAt = data.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" - titleLabel.text = "\(data.user.login) commented on \(createdAt)" - bodyLabel.text = data.body + let createdAt = comment.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + titleLabel.text = "\(comment.user.login) commented on \(createdAt)" + bodyLabel.text = comment.body + } + + static var cellFromNib: IssueCommentCell { + get { + return Bundle.main.loadNibNamed("IssueCommentCell", owner: nil, options: nil)?.first as! IssueCommentCell + } } } diff --git a/GithubIssues/IssueDetailHeaderView.swift b/GithubIssues/IssueDetailHeaderCell.swift similarity index 51% rename from GithubIssues/IssueDetailHeaderView.swift rename to GithubIssues/IssueDetailHeaderCell.swift index 67c8235..19270df 100644 --- a/GithubIssues/IssueDetailHeaderView.swift +++ b/GithubIssues/IssueDetailHeaderCell.swift @@ -8,7 +8,9 @@ import UIKit -class IssueDetailHeaderView: UIView { +@IBDesignable +class IssueDetailHeaderCell: UICollectionReusableView { + @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var stateButton: UIButton! @IBOutlet weak var infoLabel: UILabel! @@ -18,17 +20,45 @@ class IssueDetailHeaderView: UIView { @IBOutlet weak var commentInfoLabel: UILabel! @IBOutlet weak var commentBodyLabel: UILabel! - override func awakeFromNib() { super.awakeFromNib() setup() print("awakeFromNib") } + + public func loadNib() -> UIView { + let bundle = Bundle(for: type(of: self)) + let nib = UINib(nibName: "IssueDetailHeaderCell", bundle: bundle) + return nib.instantiate(withOwner: self, options: nil)[0] as! UIView // swiftlint:disable:this force_cast + } + + override public init(frame: CGRect) { + super.init(frame: frame) + self.setupNib() + } + + // MARK: - NSCoding + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.setupNib() + } + + fileprivate func setupNib() { + let view = self.loadNib() + + self.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + let bindings = ["view": view] + self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:[], metrics:nil, views: bindings)) + self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:[], metrics:nil, views: bindings)) + } + + static let estimateSizeCell: IssueDetailHeaderCell = IssueDetailHeaderCell() } // MARK: - setup -extension IssueDetailHeaderView { +extension IssueDetailHeaderCell { func setup() { stateButton.clipsToBounds = true stateButton.layer.cornerRadius = 2 @@ -47,11 +77,25 @@ extension IssueDetailHeaderView { commentContainerView.layer.borderWidth = 1 } - - + static func headerSize(issue: Model.Issue, width: CGFloat) -> CGSize { + + IssueDetailHeaderCell.estimateSizeCell.update(data: issue) + let targetSize = CGSize(width: width, height: 0) + let size = IssueDetailHeaderCell.estimateSizeCell.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: UILayoutPriorityRequired, + verticalFittingPriority: UILayoutPriorityDefaultLow + ) + + + let width = size.width == 0 ? IssueDetailHeaderCell.estimateSizeCell.bounds.width : size.width + let height = size.height == 0 ? IssueDetailHeaderCell.estimateSizeCell.bounds.height : size.height + let cellSize = CGSize(width: width, height: height) + return cellSize + } } -extension IssueDetailHeaderView { +extension IssueDetailHeaderCell { func update(data: Model.Issue, withImage: Bool = true) { let createdAt = data.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" diff --git a/GithubIssues/IssueDetailHeaderCell.xib b/GithubIssues/IssueDetailHeaderCell.xib new file mode 100644 index 0000000..8f4fa8a --- /dev/null +++ b/GithubIssues/IssueDetailHeaderCell.xib @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift index 62e8cfb..521bd14 100644 --- a/GithubIssues/IssueDetailViewController.swift +++ b/GithubIssues/IssueDetailViewController.swift @@ -9,34 +9,22 @@ import UIKit import Alamofire +class IssueDetailViewController: ListViewController { -class IssueDetailViewController: UIViewController, DatasourceRefreshable { - - @IBOutlet var collectionView: UICollectionView! - @IBOutlet var headerView: IssueDetailHeaderView! @IBOutlet var commentInputBottomConstraint: NSLayoutConstraint! @IBOutlet var commentTextField: UITextField! var owner: String = "" var repo: String = "" var issue: Model.Issue! - var datasource: [Model.Comment] = [] - var needRefreshDatasource: Bool = false - fileprivate let refreshControl = UIRefreshControl() - fileprivate var page: Int = 1 - fileprivate var canLoadMore: Bool = true - fileprivate var isLoading: Bool = false - fileprivate lazy var estimateCell: IssueCommentCell = { _ in - let cell = Bundle.main.loadNibNamed("IssueCommentCell", owner: nil, options: nil)?.first - return cell as! IssueCommentCell - }() - fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] override func viewDidLoad() { super.viewDidLoad() + api = API.issueComment(owner: owner, repo: repo, number: issue.number) setup() + } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) addKeyboardNotification() @@ -47,6 +35,66 @@ class IssueDetailViewController: UIViewController, DatasourceRefreshable { removeKeyboardNOtification() } + override func setup() { + super.setup() + var inset = self.collectionView.contentInset + inset.bottom = 46 + self.collectionView.contentInset = inset + collectionView.register(UINib(nibName: "IssueCommentCell", bundle: nil), forCellWithReuseIdentifier: "IssueCommentCell") + title = "#\(issue!.number)" + } + + @IBAction func sendButtonTapped(_ sender: Any) { + let comment = commentTextField.text ?? "" + API.createComment(owner: owner, repo: repo, number: issue.number, comment: comment) { [weak self] (dataResponse: DataResponse) in + guard let `self` = self else { return } + switch dataResponse.result { + case .success(let comment): + self.addComment(comment: comment) + self.commentTextField.text = "" + self.commentTextField.resignFirstResponder() + + break + case .failure(_): + break + } + } + } + + @IBAction func stateButtonTapped(_ sender: Any) { + chagneState() + } + + override func cellIdentifier() -> String { + return "IssueCommentCell" + } + + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + switch kind { + + case UICollectionElementKindSectionHeader: + + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderCell", for: indexPath) as? IssueDetailHeaderCell ?? IssueDetailHeaderCell() + + headerView.update(data: issue) + return headerView + + case UICollectionElementKindSectionFooter: + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "LoadMoreCell", for: indexPath) as? LoadMoreCell ?? LoadMoreCell() + + loadMoreCell = footerView + return footerView + + default: + + assert(false, "Unexpected element kind") + } + } + + override func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + + return IssueDetailHeaderCell.headerSize(issue: issue, width: collectionView.frame.width) + } } extension IssueDetailViewController { @@ -59,12 +107,6 @@ extension IssueDetailViewController { guard let animationCurve = notifiaction.userInfo?[UIKeyboardAnimationCurveUserInfoKey] as? UInt else { return } let animationOptions = UIViewAnimationOptions(rawValue: animationCurve) - print(notifiaction.userInfo) - - - - - let keyboardHeight = keyboardBounds.height @@ -92,82 +134,14 @@ extension IssueDetailViewController { NotificationCenter.default.removeObserver(self) } - func setup() { - collectionView.addSubview(refreshControl) - collectionView.alwaysBounceVertical = true - collectionView.register(UINib(nibName: "IssueCommentCell", bundle: nil), forCellWithReuseIdentifier: "IssueCommentCell") - refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - - var inset = self.collectionView.contentInset - inset.bottom = 46 - self.collectionView.contentInset = inset - - title = "#\(issue!.number)" - loadHeaderView() - load() - } - func loadHeaderView() { - if let issue = issue { - headerView.update(data: issue) - collectionView.addSubview(headerView) - let targetSize = CGSize(width: collectionView.frame.width, height: 0) - - let size = headerView.systemLayoutSizeFitting( - targetSize, - withHorizontalFittingPriority: UILayoutPriorityRequired, - verticalFittingPriority: UILayoutPriorityDefaultLow - ) - - let width = size.width == 0 ? headerView.bounds.width : size.width - let height = size.height == 0 ? headerView.bounds.height : size.height - let viewSize = CGSize(width: width, height: height) - headerView.setNeedsLayout() - headerView.layoutIfNeeded() - headerView.frame.size.width = collectionView.frame.width - headerView.frame = CGRect(x: 0, y: -viewSize.height, width: viewSize.width, height: viewSize.height) - collectionView.contentInset = UIEdgeInsets(top: headerView.frame.height, left: 0, bottom: 0, right: 0) - } - } + } extension IssueDetailViewController { - @IBAction func sendButtonTapped(_ sender: Any) { - let comment = commentTextField.text ?? "" - API.createComment(owner: owner, repo: repo, number: issue.number, comment: comment) { [weak self] (dataResponse: DataResponse) in - guard let `self` = self else { return } - switch dataResponse.result { - case .success(let comment): - self.addComment(comment: comment) - self.commentTextField.text = "" - self.commentTextField.resignFirstResponder() - - break - case .failure(_): - break - } - } - } - - @IBAction func stateButtonTapped(_ sender: Any) { - chagneState() - } + } extension IssueDetailViewController { - func load() { - isLoading = true - API.issueDetail(owner: owner , repo: repo, number: issue.number, page: page) { [weak self] (response: DataResponse<[Model.Comment]>) in - guard let `self` = self else { return } - switch response.result { - case .success(let comments): - self.dataLoaded(comments: comments) - self.isLoading = false - case .failure(_): - self.isLoading = false - } - } - } - func addComment(comment: Model.Comment) { let newIndexPath = IndexPath(item: datasource.count, section: 0) datasource.append(comment) @@ -176,35 +150,7 @@ extension IssueDetailViewController { collectionView.scrollToItem(at: newIndexPath, at: .bottom, animated: true) } - - func dataLoaded(comments: [Model.Comment]) { - refreshDataSourceIfNeeded() - datasource = datasource + comments - page = page + 1 - if comments.count == 0 { - canLoadMore = false - } - refreshControl.endRefreshing() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.collectionView.reloadData() - } - - collectionView.reloadData() - } - - @objc func refresh() { - page = 1 - canLoadMore = true - setNeedRefreshDatasource() - load() - } - - func loadMore() { - if canLoadMore { - load() - } - } - + func chagneState() { switch issue.state { case .open, .none: @@ -213,7 +159,7 @@ extension IssueDetailViewController { case .success(let issue): print("issue: \(issue)") self?.issue = issue - self?.loadHeaderView() +// self?.loadHeaderView() case .failure(let error): print(dataResponse.request) print(error) @@ -226,7 +172,7 @@ extension IssueDetailViewController { case .success(let issue): print("issue: \(issue)") self?.issue = issue - self?.loadHeaderView() +// self?.loadHeaderView() case .failure(let error): print(dataResponse.request) print(error) @@ -236,50 +182,3 @@ extension IssueDetailViewController { } } -extension IssueDetailViewController: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCommentCell", for: indexPath) as! IssueCommentCell - let comment = datasource[indexPath.item] - cell.update(data: comment) - - return cell - } - - func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 - } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return datasource.count - } -} - -extension IssueDetailViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - print("item: \(indexPath.item), count: \(datasource.count)") - if indexPath.item == datasource.count - 1 && !isLoading{ - loadMore() - } - } -} - -extension IssueDetailViewController: UICollectionViewDelegateFlowLayout { - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width = collectionView.bounds.width - 8*2 - let targetSize = CGSize(width: width, height: 48) - - var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero - if estimatedSize != .zero { - return estimatedSize - } - - let data = datasource[indexPath.item] - estimateCell.update(data: data, withImage: false) - - - estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) - estimatedSizes[indexPath] = estimatedSize - - return estimatedSize - } -} diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift index 59d4682..c45bb31 100644 --- a/GithubIssues/IssuesViewController.swift +++ b/GithubIssues/IssuesViewController.swift @@ -9,27 +9,26 @@ import UIKit import Alamofire -final class IssuesViewController: ListViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { - +final class IssuesViewController: ListViewController { var owner: String = "" var repo: String = "" - - - fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] - fileprivate let estimateCell: IssueCell = IssueCell.cellFromNib override func viewDidLoad() { super.viewDidLoad() - api = API.repoIssues(owner: owner, repo: repo)// as? ((Int, @escaping (DataResponse<[Model.Issue]>) -> Void) -> Void) - + api = API.repoIssues(owner: owner, repo: repo) + collectionView.delegate = self + collectionView.dataSource = self setup() + } + override func setup() { + super.setup() + collectionView.register(UINib(nibName: "IssueCell", bundle: nil), forCellWithReuseIdentifier: "IssueCell") + + } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if let detailViewController = segue.destination as? IssueDetailViewController, - let cell = sender as? IssueCell, - let indexPath = collectionView.indexPath(for: cell) { - let issue = datasource[indexPath.item] + if let detailViewController = segue.destination as? IssueDetailViewController,let issue = sender as? Model.Issue { detailViewController.issue = issue detailViewController.repo = repo detailViewController.owner = owner @@ -49,42 +48,31 @@ final class IssuesViewController: ListViewController, UICollectionV } } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "IssueCell", for: indexPath) as! IssueCell - let issue = datasource[indexPath.item] - cell.update(issue: issue) - return cell - } - - func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let data = datasource[indexPath.item] + self.performSegue(withIdentifier: "PushIssueDetail", sender: data) } - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return datasource.count - } - func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - - loadMore(indexPath: indexPath) - + override func cellIdentifier() -> String { + return "IssueCell" } - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - - var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero - if estimatedSize != .zero { - return estimatedSize + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + switch kind { + + case UICollectionElementKindSectionHeader: + assert(false, "Unexpected element kind") + + case UICollectionElementKindSectionFooter: + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "LoadMoreCell", for: indexPath) as? LoadMoreCell ?? LoadMoreCell() + + loadMoreCell = footerView + return footerView + + default: + + assert(false, "Unexpected element kind") } - let data = datasource[indexPath.item] - - estimateCell.update(issue: data) - - let targetSize = CGSize(width: collectionView.frame.size.width, height: 50) - - estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) - estimatedSizes[indexPath] = estimatedSize - - return estimatedSize } } diff --git a/GithubIssues/ListViewController.swift b/GithubIssues/ListViewController.swift index 3ff15e1..ab7327d 100644 --- a/GithubIssues/ListViewController.swift +++ b/GithubIssues/ListViewController.swift @@ -28,37 +28,19 @@ extension DatasourceRefreshable { } } -protocol Loadmoreable: class { - associatedtype Item - associatedtype PageIndicator - var datasource: [Item] { get set } - var canLoadMore: Bool { get set } - var page: PageIndicator { get set } - var isLoading: Bool { get set } - func load() -} - -extension Loadmoreable { - func loadMore() { - if canLoadMore { - load() - } - } -} - -class ListViewController: UIViewController, DatasourceRefreshable { +class ListViewController: UIViewController, DatasourceRefreshable, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + typealias Item = CellType.Item var isLoading: Bool = false - @IBOutlet var loadMoreView: LoadMoreView! - + var loadMoreCell: LoadMoreCell? + @IBOutlet var collectionView: UICollectionView! var needRefreshDatasource: Bool = false - typealias PageIndicator = Int - - @IBOutlet var collectionView: UICollectionView! var datasource: [Item] = [] let refreshControl = UIRefreshControl() var page: Int = 1 var canLoadMore: Bool = true + fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] + fileprivate let estimateCell: CellType = CellType.cellFromNib typealias IssueResponsesHandler = (DataResponse<[Item]>) -> Void var api: ((Int, @escaping IssueResponsesHandler) -> Void)? @@ -69,28 +51,16 @@ class ListViewController: UIViewController, DatasourceRefreshable { } func setup() { - collectionView.addSubview(refreshControl) + collectionView.refreshControl = refreshControl collectionView.alwaysBounceVertical = true - collectionView.register(UINib(nibName: "IssueCell", bundle: nil), forCellWithReuseIdentifier: "IssueCell") refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) load() - footer() - loadMoreView.load() - } - - func layoutFooter() { - loadMoreView.frame.origin.y = collectionView.contentSize.height - loadMoreView.frame.size.width = collectionView.frame.width - loadMoreView.frame.size.height = 50 - } - func footer() { - collectionView.addSubview(loadMoreView) - var inset = collectionView.contentInset - inset.bottom = 50 - collectionView.contentInset = inset + loadMoreCell?.load() + } func load() { + guard isLoading == false else {return } isLoading = true api?(page, {[weak self] (response: DataResponse<[Item]>) -> Void in guard let `self` = self else { return } @@ -107,31 +77,92 @@ class ListViewController: UIViewController, DatasourceRefreshable { } + func dataLoaded(items: [Item]) { refreshDataSourceIfNeeded() page = page + 1 if items.count == 0 { canLoadMore = false - loadMoreView.loadDone() + loadMoreCell?.loadDone() } refreshControl.endRefreshing() datasource.append(contentsOf: items) collectionView.reloadData() - DispatchQueue.main.async { [weak self] in - self?.layoutFooter() - } - } @objc func refresh() { - page = 0 + page = 1 canLoadMore = true - loadMoreView.load() + loadMoreCell?.load() setNeedRefreshDatasource() load() } + func cellIdentifier() -> String { + return "" + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + /* + 사이즈 + + 인덱싱된 사이즈가 있으면 리턴. + 데이터를 가져옴. + estimateCell에 데이트 업데이트함. + 가로 사이즈를 받아, 쎌 사이즈를 잼. + 가져온 사이즈를 인덱싱. + 그 사이즈를 리턴. + + */ + + + var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero + if estimatedSize != .zero { + return estimatedSize + } + let data = datasource[indexPath.item] + + estimateCell.update(data: data) + + let targetSize = CGSize(width: collectionView.frame.size.width, height: 50) + + estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) + estimatedSizes[indexPath] = estimatedSize + return estimatedSize + + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return datasource.count + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + loadMore(indexPath: indexPath) + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier(), for: indexPath) as! CellType + let issue = datasource[indexPath.item] + cell.update(data: issue) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + return UICollectionReusableView() + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + + return CGSize.zero + } } diff --git a/GithubIssues/LoadMoreCell.swift b/GithubIssues/LoadMoreCell.swift new file mode 100644 index 0000000..5fa97d7 --- /dev/null +++ b/GithubIssues/LoadMoreCell.swift @@ -0,0 +1,63 @@ +// +// LoadMoreView.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 22.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import UIKit + +@IBDesignable +class LoadMoreCell: UICollectionReusableView { + @IBOutlet var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet var doneView: UIView! + + public func loadNib() -> UIView { + let bundle = Bundle(for: type(of: self)) + let nib = UINib(nibName: "LoadMoreCell", bundle: bundle) + return nib.instantiate(withOwner: self, options: nil)[0] as! UIView + } + + override public init(frame: CGRect) { + super.init(frame: frame) + self.setupNib() + } + + // MARK: - NSCoding + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.setupNib() + } + + fileprivate func setupNib() { + let view = self.loadNib() + + self.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + let bindings = ["view": view] + self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:[], metrics:nil, views: bindings)) + self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:[], metrics:nil, views: bindings)) + } + +} + +extension LoadMoreCell { + func loadDone() { + activityIndicatorView.isHidden = true + doneView.isHidden = false + } + + func load() { + activityIndicatorView.isHidden = false + doneView.isHidden = true + } +} + +extension LoadMoreCell { + static var view: LoadMoreCell { + get { + return Bundle.main.loadNibNamed("LoadMoreCell", owner: nil, options: nil)?.first as! LoadMoreCell + } + } +} diff --git a/GithubIssues/LoadMoreCell.xib b/GithubIssues/LoadMoreCell.xib new file mode 100644 index 0000000..5e1c1b3 --- /dev/null +++ b/GithubIssues/LoadMoreCell.xib @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GithubIssues/LoadMoreView.swift b/GithubIssues/LoadMoreView.swift deleted file mode 100644 index 0f973a2..0000000 --- a/GithubIssues/LoadMoreView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// LoadMoreView.swift -// GithubIssues -// -// Created by Leonard on 2017. 9. 22.. -// Copyright © 2017년 intmain. All rights reserved. -// - -import UIKit - -class LoadMoreView: UIView { - @IBOutlet var activityIndicatorView: UIActivityIndicatorView! - @IBOutlet var doneView: UIView! - -} - -extension LoadMoreView { - func loadDone() { - activityIndicatorView.isHidden = true - doneView.isHidden = false - } - - func load() { - activityIndicatorView.isHidden = false - doneView.isHidden = true - } -} diff --git a/GithubIssues/RepoViewController.swift b/GithubIssues/RepoViewController.swift index 4bbc9bf..702bb9d 100644 --- a/GithubIssues/RepoViewController.swift +++ b/GithubIssues/RepoViewController.swift @@ -18,6 +18,8 @@ class RepoViewController: UIViewController { ownerTextField.text = GlobalState.instance.owner repoTextField.text = GlobalState.instance.repo + +// repoTextField.layer.frame = CGRect(x: 100, y: 100, width: 100, height: 100) // Do any additional setup after loading the view. } From bd491c133f20007ea2ee429ca08283ef88a89aa6 Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Tue, 26 Sep 2017 23:06:20 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=85=80=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EA=B3=84=EC=82=B0=20=EC=BA=90=EC=8B=9C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/WorkspaceSettings.xcsettings | 8 +++++++ GithubIssues/IssueCommentCell.xib | 21 ++++++++++--------- GithubIssues/IssueDetailViewController.swift | 7 ++++++- 3 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..3ddf867 --- /dev/null +++ b/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + BuildSystemType + Latest + + diff --git a/GithubIssues/IssueCommentCell.xib b/GithubIssues/IssueCommentCell.xib index f870a7b..ec7bf36 100644 --- a/GithubIssues/IssueCommentCell.xib +++ b/GithubIssues/IssueCommentCell.xib @@ -1,11 +1,11 @@ - + - + @@ -21,7 +21,7 @@ - + @@ -32,13 +32,13 @@ - + - + - + @@ -105,7 +106,7 @@ - + diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift index 521bd14..00f3bb2 100644 --- a/GithubIssues/IssueDetailViewController.swift +++ b/GithubIssues/IssueDetailViewController.swift @@ -17,6 +17,7 @@ class IssueDetailViewController: ListViewController { var owner: String = "" var repo: String = "" var issue: Model.Issue! + var headerSize: CGSize = CGSize.zero override func viewDidLoad() { super.viewDidLoad() @@ -93,7 +94,11 @@ class IssueDetailViewController: ListViewController { override func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - return IssueDetailHeaderCell.headerSize(issue: issue, width: collectionView.frame.width) + if headerSize == CGSize.zero { + headerSize = IssueDetailHeaderCell.headerSize(issue: issue, width: collectionView.frame.width) + + } + return headerSize } } From 61c3bbef7a0787633cf1d589d3ec8701af6647c9 Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Sun, 1 Oct 2017 22:11:17 +0900 Subject: [PATCH 8/9] =?UTF-8?q?bitbucket=20=EC=A7=80=EC=9B=90=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GithubIssues.xcodeproj/project.pbxproj | 46 +++- .../xcshareddata/WorkspaceSettings.xcsettings | 5 +- GithubIssues/API.swift | 133 ++--------- GithubIssues/AppDelegate.swift | 9 +- .../AppIcon.appiconset/Contents.json | 5 + .../bitbucket.imageset/Contents.json | 21 ++ .../bitbucket.imageset/bitbucket_512.png | Bin 0 -> 21870 bytes GithubIssues/Base.lproj/Main.storyboard | 118 ++++------ GithubIssues/BitbucketAPI.swift | 215 ++++++++++++++++++ GithubIssues/BitbucketRouter.swift | 88 +++++++ GithubIssues/CreateIssueViewController.swift | 6 +- GithubIssues/ExampleViewController.swift | 14 +- GithubIssues/GithubAPI.swift | 137 +++++++++++ .../{Router.swift => GithubRouter.swift} | 24 +- GithubIssues/GlobalState.swift | 28 +++ GithubIssues/Info.plist | 11 + GithubIssues/Issue.swift | 30 +-- GithubIssues/IssueCell.swift | 4 +- GithubIssues/IssueCommentCell.swift | 2 +- GithubIssues/IssueDetailHeaderCell.swift | 2 +- GithubIssues/IssueDetailViewController.swift | 8 +- GithubIssues/IssuesViewController.swift | 3 +- GithubIssues/ListViewController.swift | 1 - GithubIssues/LoadMoreCell.swift | 21 +- GithubIssues/LoadMoreCell.xib | 2 +- GithubIssues/LoginViewController.swift | 35 ++- GithubIssues/User.swift | 4 +- Podfile | 3 +- 28 files changed, 696 insertions(+), 279 deletions(-) create mode 100644 GithubIssues/Assets.xcassets/bitbucket.imageset/Contents.json create mode 100644 GithubIssues/Assets.xcassets/bitbucket.imageset/bitbucket_512.png create mode 100644 GithubIssues/BitbucketAPI.swift create mode 100644 GithubIssues/BitbucketRouter.swift create mode 100644 GithubIssues/GithubAPI.swift rename GithubIssues/{Router.swift => GithubRouter.swift} (86%) diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index 91fa6fa..c95fd55 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ C9911DA0C2D7555BCE11A163 /* Pods_GithubIssues.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE8C82EDEECF19666BE564C8 /* Pods_GithubIssues.framework */; }; D7014DC61F6814A000F0F7F7 /* IssueDetailHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderCell.swift */; }; D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */; }; + D71E7FD11F7FADF800FD5DA0 /* BitbucketRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD01F7FADF800FD5DA0 /* BitbucketRouter.swift */; }; + D71E7FD31F7FAE4C00FD5DA0 /* GithubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD21F7FAE4C00FD5DA0 /* GithubAPI.swift */; }; + D71E7FD51F7FAE6100FD5DA0 /* BitbucketAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD41F7FAE6100FD5DA0 /* BitbucketAPI.swift */; }; D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D730D9951F6AE4DA0057276B /* ExampleViewController.swift */; }; D7412BD31F701F4A00E63DF6 /* IssueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D7412BD21F701F4A00E63DF6 /* IssueCell.xib */; }; D776DCC51F76286300E61284 /* LoadMoreCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D776DCC41F76286300E61284 /* LoadMoreCell.xib */; }; @@ -22,7 +25,7 @@ D7CE58E81F64118400380CEE /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */; }; D7CE58F31F64118400380CEE /* GithubIssuesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58F21F64118400380CEE /* GithubIssuesTests.swift */; }; D7CE58FE1F64118400380CEE /* GithubIssuesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE58FD1F64118400380CEE /* GithubIssuesUITests.swift */; }; - D7CE590C1F64127C00380CEE /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE590B1F64127C00380CEE /* Router.swift */; }; + D7CE590C1F64127C00380CEE /* GithubRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE590B1F64127C00380CEE /* GithubRouter.swift */; }; D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */; }; D7CE59131F64CBD800380CEE /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59121F64CBD800380CEE /* API.swift */; }; D7CE59151F64CC0B00380CEE /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CE59141F64CC0B00380CEE /* Model.swift */; }; @@ -73,6 +76,9 @@ C987F46EEBF6AB7C71701BE6 /* Pods_GithubIssuesUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubIssuesUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7014DC51F6814A000F0F7F7 /* IssueDetailHeaderCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueDetailHeaderCell.swift; sourceTree = ""; }; D7014DC71F6814ED00F0F7F7 /* UIColor+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+extension.swift"; sourceTree = ""; }; + D71E7FD01F7FADF800FD5DA0 /* BitbucketRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitbucketRouter.swift; sourceTree = ""; }; + D71E7FD21F7FAE4C00FD5DA0 /* GithubAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubAPI.swift; sourceTree = ""; }; + D71E7FD41F7FAE6100FD5DA0 /* BitbucketAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitbucketAPI.swift; sourceTree = ""; }; D730D9951F6AE4DA0057276B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; D7412BD21F701F4A00E63DF6 /* IssueCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IssueCell.xib; sourceTree = ""; }; D776DCC41F76286300E61284 /* LoadMoreCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LoadMoreCell.xib; sourceTree = ""; }; @@ -91,7 +97,7 @@ D7CE58F91F64118400380CEE /* GithubIssuesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GithubIssuesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D7CE58FD1F64118400380CEE /* GithubIssuesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubIssuesUITests.swift; sourceTree = ""; }; D7CE58FF1F64118400380CEE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - D7CE590B1F64127C00380CEE /* Router.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + D7CE590B1F64127C00380CEE /* GithubRouter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GithubRouter.swift; sourceTree = ""; }; D7CE590F1F64CB6F00380CEE /* DataRequest+extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DataRequest+extension.swift"; sourceTree = ""; }; D7CE59121F64CBD800380CEE /* API.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; D7CE59141F64CC0B00380CEE /* Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; @@ -241,8 +247,11 @@ D7CE59111F64CBCB00380CEE /* API */ = { isa = PBXGroup; children = ( - D7CE590B1F64127C00380CEE /* Router.swift */, D7CE59121F64CBD800380CEE /* API.swift */, + D71E7FD21F7FAE4C00FD5DA0 /* GithubAPI.swift */, + D7CE590B1F64127C00380CEE /* GithubRouter.swift */, + D71E7FD41F7FAE6100FD5DA0 /* BitbucketAPI.swift */, + D71E7FD01F7FADF800FD5DA0 /* BitbucketRouter.swift */, ); name = API; sourceTree = ""; @@ -265,7 +274,6 @@ D7CE591C1F64D82E00380CEE /* IssuesViewController.swift */, D7CE591E1F64D99300380CEE /* LoginViewController.swift */, D7CE59231F65005D00380CEE /* RepoViewController.swift */, - D7CE59251F65077B00380CEE /* IssueCell.swift */, D7CE59291F6546D200380CEE /* IssueDetailViewController.swift */, D7CE592B1F655AF600380CEE /* IssueCommentCell.swift */, D7CE592F1F658CA900380CEE /* IssueCommentCell.xib */, @@ -273,6 +281,7 @@ D776DD031F77E3D900E61284 /* IssueDetailHeaderCell.xib */, D7ED99D31F6D71ED00E4B903 /* CreateIssueViewController.swift */, D7ED99D51F6E100F00E4B903 /* ReposViewController.swift */, + D7CE59251F65077B00380CEE /* IssueCell.swift */, D7412BD21F701F4A00E63DF6 /* IssueCell.xib */, D7CB7A361F74A41900816E33 /* LoadMoreCell.swift */, D776DCC41F76286300E61284 /* LoadMoreCell.xib */, @@ -462,13 +471,16 @@ files = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GithubIssues-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 9B358046D0AF5ED9679EE94B /* [CP] Embed Pods Frameworks */ = { @@ -477,9 +489,18 @@ files = ( ); inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-GithubIssues/Pods-GithubIssues-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", + "${BUILT_PRODUCTS_DIR}/AlamofireImage/AlamofireImage.framework", + "${BUILT_PRODUCTS_DIR}/OAuthSwift/OAuthSwift.framework", + "${BUILT_PRODUCTS_DIR}/SwiftyJSON/SwiftyJSON.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AlamofireImage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OAuthSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -492,13 +513,16 @@ files = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GithubIssuesTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; BB4AD020B55BABD834A7F956 /* [CP] Copy Pods Resources */ = { @@ -537,13 +561,16 @@ files = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GithubIssuesUITests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; FDD3DCF6031B043F0E918500 /* [CP] Copy Pods Resources */ = { @@ -574,20 +601,23 @@ D7CE59181F64CC6B00380CEE /* Issue.swift in Sources */, D7CE59281F65209A00380CEE /* LayoutAttributable.swift in Sources */, D7ED99D61F6E100F00E4B903 /* ReposViewController.swift in Sources */, + D71E7FD51F7FAE6100FD5DA0 /* BitbucketAPI.swift in Sources */, D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */, D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */, D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */, D7CB7A351F74A3C700816E33 /* ListViewController.swift in Sources */, D7CE592A1F6546D200380CEE /* IssueDetailViewController.swift in Sources */, D7CE591D1F64D82E00380CEE /* IssuesViewController.swift in Sources */, - D7CE590C1F64127C00380CEE /* Router.swift in Sources */, + D7CE590C1F64127C00380CEE /* GithubRouter.swift in Sources */, D7CE591A1F64CFD500380CEE /* User.swift in Sources */, + D71E7FD11F7FADF800FD5DA0 /* BitbucketRouter.swift in Sources */, D7CE59211F64DBB800380CEE /* GlobalState.swift in Sources */, D7CE59241F65005D00380CEE /* RepoViewController.swift in Sources */, D7CE58DE1F64118400380CEE /* AppDelegate.swift in Sources */, D7CE59261F65077B00380CEE /* IssueCell.swift in Sources */, D7CE592C1F655AF600380CEE /* IssueCommentCell.swift in Sources */, D7CB7A371F74A41900816E33 /* LoadMoreCell.swift in Sources */, + D71E7FD31F7FAE4C00FD5DA0 /* GithubAPI.swift in Sources */, D7CE591F1F64D99300380CEE /* LoginViewController.swift in Sources */, D7ED99D41F6D71ED00E4B903 /* CreateIssueViewController.swift in Sources */, D7CE59131F64CBD800380CEE /* API.swift in Sources */, diff --git a/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index 3ddf867..0c67376 100644 --- a/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/GithubIssues.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -1,8 +1,5 @@ - - BuildSystemType - Latest - + diff --git a/GithubIssues/API.swift b/GithubIssues/API.swift index c570b2f..24314db 100644 --- a/GithubIssues/API.swift +++ b/GithubIssues/API.swift @@ -10,121 +10,26 @@ import Foundation import Alamofire import SwiftyJSON +struct App { + static var api: API = { + switch GlobalState.instance.serviceType { + case .github: + return GithubAPI() + case .bitbucket: + return BitbucketAPI() + } + }() +} -struct API { - static func getOauthKey(user: String, password: String, completionHandler: @escaping (DataResponse) -> Void) { - var headers: HTTPHeaders = [:] - if let authorizationHeader = Request.authorizationHeader(user: user, password: password) { - headers[authorizationHeader.key] = authorizationHeader.value - } - let parameters: Parameters = ["client_secret": Router.clientSecret , "scopes": ["public_repo"], "note": "admin script" ] - Alamofire.request(Router.authKey(parameters, headers)) - .responseSwiftyJSON { json in - print(json) - completionHandler(json) - } - } - - static func repoIssues(owner: String, repo: String, page: Int, completionHandler: @escaping (DataResponse<[Model.Issue]>) -> Void) { - let parameters: Parameters = ["page": page, "state": "all"] - Alamofire.request(Router.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in - return json.arrayValue.map{ - Model.Issue(json: $0) - } - }) - completionHandler(result) - } - } - +protocol API { typealias IssueResponsesHandler = (DataResponse<[Model.Issue]>) -> Void - static func repoIssues(owner: String, repo: String) -> (Int, @escaping IssueResponsesHandler) -> Void { - - return { (page: Int, handler: @escaping IssueResponsesHandler) in - let parameters: Parameters = ["page": page, "state": "all"] - Alamofire.request(Router.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in - return json.arrayValue.map{ - Model.Issue(json: $0) - } - }) - handler(result) - } - } - } - typealias CommentResponsesHandler = (DataResponse<[Model.Comment]>) -> Void - - static func issueComment(owner: String, repo: String, number: Int) -> (Int, @escaping CommentResponsesHandler) -> Void { - return { page, handler in - let parameters: Parameters = ["page": page] - Alamofire.request(Router.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - let result = dataResponse.map({ (json: JSON) -> [Model.Comment] in - return json.arrayValue.map{ - Model.Comment(json: $0) - } - }) - handler(result) - } - } - } - - static func issueDetail(owner: String, repo: String, number: Int, page: Int, completionHandler: @escaping (DataResponse<[Model.Comment]>) -> Void) { - let parameters: Parameters = ["page": page] - Alamofire.request(Router.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - let result = dataResponse.map({ (json: JSON) -> [Model.Comment] in - return json.arrayValue.map{ - Model.Comment(json: $0) - } - }) - completionHandler(result) - } - } - - static func createComment(owner: String, repo: String, number: Int, comment: String, completionHandler: @escaping (DataResponse) -> Void ) { - let parameters: Parameters = ["body": comment] - Alamofire.request(Router.createComment(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - let result = dataResponse.map({ (json: JSON) -> Model.Comment in - Model.Comment(json: json) - }) - completionHandler(result) - } - } - - static func createIssue(owner: String, repo: String, title: String, body: String, completionHandler: @escaping (DataResponse) -> Void ) { - let parameters: Parameters = ["title": title, "body": body] - Alamofire.request(Router.createIssue(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in - print(dataResponse.request?.url?.absoluteString) - let result = dataResponse.map({ (json: JSON) -> Model.Issue in - Model.Issue(json: json) - }) - completionHandler(result) - } - } - - static func closeIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { - var dict = issue.toDict - dict["state"] = Model.Issue.State.closed.display - Alamofire.request(Router.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in - print(dataResponse.request?.url?.absoluteString) - let result = dataResponse.map({ (json: JSON) -> Model.Issue in - Model.Issue(json: json) - }) - completionHandler(result) - } - - } - - static func openIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { - var dict = issue.toDict - dict["state"] = Model.Issue.State.open.display - Alamofire.request(Router.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in - print(dataResponse.request?.url?.absoluteString) - let result = dataResponse.map({ (json: JSON) -> Model.Issue in - Model.Issue(json: json) - }) - completionHandler(result) - } - - } + func getToekn(handler: @escaping (() -> Void)) + func tokenRefresh(handler: @escaping (() -> Void)) + func repoIssues(owner: String, repo: String) -> (Int, @escaping IssueResponsesHandler) -> Void + func issueComment(owner: String, repo: String, number: Int) -> (Int, @escaping CommentResponsesHandler) -> Void + func createComment(owner: String, repo: String, number: Int, comment: String, completionHandler: @escaping (DataResponse) -> Void ) + func createIssue(owner: String, repo: String, title: String, body: String, completionHandler: @escaping (DataResponse) -> Void ) + func closeIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) + func openIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) } diff --git a/GithubIssues/AppDelegate.swift b/GithubIssues/AppDelegate.swift index 08f5f2e..dfaa076 100644 --- a/GithubIssues/AppDelegate.swift +++ b/GithubIssues/AppDelegate.swift @@ -7,6 +7,7 @@ // import UIKit +import OAuthSwift @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -51,6 +52,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - + func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool { + print("url: \(url.absoluteString)") + if (url.host == "oauth-callback") { + OAuthSwift.handle(url: url) + } + return true + } } diff --git a/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json b/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json index 1d060ed..d8db8d6 100644 --- a/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/GithubIssues/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -84,6 +84,11 @@ "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" } ], "info" : { diff --git a/GithubIssues/Assets.xcassets/bitbucket.imageset/Contents.json b/GithubIssues/Assets.xcassets/bitbucket.imageset/Contents.json new file mode 100644 index 0000000..0573ff8 --- /dev/null +++ b/GithubIssues/Assets.xcassets/bitbucket.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bitbucket_512.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/GithubIssues/Assets.xcassets/bitbucket.imageset/bitbucket_512.png b/GithubIssues/Assets.xcassets/bitbucket.imageset/bitbucket_512.png new file mode 100644 index 0000000000000000000000000000000000000000..7440fbd1a293209853e67f2d171d1327a122b635 GIT binary patch literal 21870 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 S3msJ$_*k3UnU$D$F#ZIN`{3IE literal 0 HcmV?d00001 diff --git a/GithubIssues/Base.lproj/Main.storyboard b/GithubIssues/Base.lproj/Main.storyboard index fc38c92..74dbc08 100644 --- a/GithubIssues/Base.lproj/Main.storyboard +++ b/GithubIssues/Base.lproj/Main.storyboard @@ -6,7 +6,9 @@ + + @@ -322,7 +324,7 @@ - + @@ -469,81 +471,60 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + - - - - - + @@ -598,5 +579,6 @@ + diff --git a/GithubIssues/BitbucketAPI.swift b/GithubIssues/BitbucketAPI.swift new file mode 100644 index 0000000..0610978 --- /dev/null +++ b/GithubIssues/BitbucketAPI.swift @@ -0,0 +1,215 @@ +// +// BitbucketAPI.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 30.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON +import OAuthSwift + +struct BitbucketAPI: API { + + let bitbucketOAuth: OAuth2Swift = OAuth2Swift( + consumerKey: "vx2MD5uVaRyLgMxype", + consumerSecret: "CA9cZxqWEgRDpZCCYy353WG763J8McWH", + authorizeUrl: "https://bitbucket.org/site/oauth2/authorize", + accessTokenUrl: "https://bitbucket.org/site/oauth2/access_token", + responseType: "code" + ) + + func getOauthKey(user: String, password: String, completionHandler: @escaping (DataResponse) -> Void) { + var headers: HTTPHeaders = [:] + if let authorizationHeader = Request.authorizationHeader(user: user, password: password) { + headers[authorizationHeader.key] = authorizationHeader.value + } + let parameters: Parameters = ["client_secret": BitbucketRouter.clientSecret , "scopes": ["public_repo"], "note": "admin script" ] + Alamofire.request(BitbucketRouter.authKey(parameters, headers)) + .responseSwiftyJSON { json in + print(json) + completionHandler(json) + } + } + + func getToekn(handler: @escaping (() -> Void)) { + guard let url = URL(string:"ISSAPP://oauth-callback/bitbucket") else { return } + let _ = bitbucketOAuth.authorize( + withCallbackURL: url, + scope: "issue:write", state:"state", + success: {(credential, response, parameters) in + GlobalState.instance.token = credential.oauthToken + GlobalState.instance.refreshToken = credential.oauthRefreshToken + GlobalState.instance.serviceType = .bitbucket + App.api = BitbucketAPI() + handler() + }) { ( error ) in + print(error.localizedDescription) + } + } + + func tokenRefresh(handler: @escaping (() -> Void)) { + guard let refreshToken = GlobalState.instance.refreshToken else { return } + + bitbucketOAuth.renewAccessToken(withRefreshToken: refreshToken, success: { (credential, response, parameters) in + GlobalState.instance.token = credential.oauthToken + GlobalState.instance.refreshToken = credential.oauthRefreshToken + GlobalState.instance.serviceType = .bitbucket + App.api = BitbucketAPI() + handler() + }) { (error) in + print(error.localizedDescription) + } + } + + func repoIssues(owner: String, repo: String) -> (Int, @escaping IssueResponsesHandler) -> Void { + return { (page: Int, handler: @escaping IssueResponsesHandler) in + let parameters: Parameters = ["page": page, "state": "all"] + Alamofire.request(BitbucketRouter.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print("result: \(dataResponse.value)") + if dataResponse.response?.statusCode == 401 { + var retryCount = 1 + self.tokenRefresh { + if retryCount > 1 { + return + } + retryCount = retryCount + 1 + self.repoIssues(owner: owner, repo: repo)(page, handler) + } + return + } + let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in + return json["values"].arrayValue.map{ (json: JSON) -> Model.Issue in + Model.Issue(json: json.githubIssueToBitbucket) + } + }) + handler(result) + } + } + } + + func issueComment(owner: String, repo: String, number: Int) -> (Int, @escaping CommentResponsesHandler) -> Void { + return { page, handler in + let parameters: Parameters = ["page": page] + Alamofire.request(BitbucketRouter.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print("result: \(dataResponse.value)") + let result = dataResponse.map({ (json: JSON) -> [Model.Comment] in + return json["values"].arrayValue.map{ + Model.Comment(json: $0.githubCommentToBitbucket) + } + }) + handler(result) + } + } + } + + func createComment(owner: String, repo: String, number: Int, comment: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["body": comment] + Alamofire.request(BitbucketRouter.createComment(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> Model.Comment in + Model.Comment(json: json) + }) + completionHandler(result) + } + } + + func createIssue(owner: String, repo: String, title: String, body: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["title": title, "content": ["raw":body]] + Alamofire.request(BitbucketRouter.createIssue(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json.githubIssueToBitbucket) + }) + completionHandler(result) + } + } + + func closeIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.closed.display + Alamofire.request(BitbucketRouter.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + + } + + func openIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.open.display + Alamofire.request(BitbucketRouter.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + } + +} + +extension JSON { + /* + + id -> id + number -> id + title -> title + comments -> + body -> [content][raw] + createdAt -> created_on + closedAt -> state, new -> open, closed -> closed + + */ + var githubIssueToBitbucket: JSON { + var json = JSON() + json["id"] = self["id"] + json["number"] = self["id"] + json["title"] = self["title"] + json["body"] = self["content"]["raw"] + json["user"] = self["reporter"].githubUserToBitbucket + switch self["state"].stringValue { + case "new": + json["state"].string = "open" + case "closed": + json["state"].string = "closed" + default: + json["state"].string = "none" + } + let created_at = (self["created_on"].stringValue.components(separatedBy: ".").first ?? "")+"Z" + json["created_at"].string = created_at + return json + } + /*id -> uuid + login -> username + avatar_url -> ["links"]["avatar"]["href"]*/ + var githubUserToBitbucket: JSON { + var json = JSON() + json["id"] = self["uuid"] + json["login"] = self["username"] + json["avatar_url"] = self["links"]["avatar"]["href"] + return json + } + /* + id -> id + user -> user.git + body -> ["content"]["raw"] + createdAt -> created_on + updatedAt -> updated_on + */ + var githubCommentToBitbucket: JSON { + var json = JSON() + json["id"] = self["id"] + json["user"] = self["user"].githubUserToBitbucket + json["body"] = self["content"]["raw"] + let createdAt = (self["created_on"].stringValue.components(separatedBy: ".").first ?? "")+"Z" + json["created_at"].string = createdAt + let updatedAt = (self["updated_at"].stringValue.components(separatedBy: ".").first ?? "")+"Z" + json["updated_at"].string = updatedAt + return json + } +} diff --git a/GithubIssues/BitbucketRouter.swift b/GithubIssues/BitbucketRouter.swift new file mode 100644 index 0000000..eafdf4d --- /dev/null +++ b/GithubIssues/BitbucketRouter.swift @@ -0,0 +1,88 @@ +// +// BitbucketRouter.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 30.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON + +enum BitbucketRouter { + case authKey(Parameters, HTTPHeaders) + case repoIssues(owner: String, repo: String, parameters: Parameters) + case issueDetail(owner: String, repo: String, number: Int, parameters: Parameters) + case createComment(owner: String, repo: String, number: Int, parameters: Parameters) + case createIssue(owner: String, repo: String, parameters: Parameters) + case editIssue(owner: String, repo: String, number: Int, parameters: Parameters) +} + +extension BitbucketRouter: URLRequestConvertible { + static let baseURLString: String = "https://api.bitbucket.org" + static let clientID: String = "36c48adc3d1433fbd286" + static let clientSecret: String = "a911bfd178a79f25d14c858a1199cd76d9e92f3b" + + var method: HTTPMethod { + switch self { + case .authKey: + return .put + case .repoIssues, + .issueDetail + : + return .get + case .createComment, + .createIssue + : + return .post + case .editIssue + : + return .patch + } + } + + var path: String { + switch self { + case .authKey: + return "/authorizations/clients/\(BitbucketRouter.clientID)/\(Date().timeIntervalSince1970)" + case let .repoIssues(owner, repo, _): + return "/2.0/repositories/\(owner)/\(repo)/issues" + case let .issueDetail(owner, repo, number, _): + return "/2.0/repositories/\(owner)/\(repo)/issues/\(number)/comments" + case let .createComment(owner, repo, number, _): + return "/2.0/repositories/\(owner)/\(repo)/issues/\(number)/comments" + case let .createIssue(owner, repo, _): + return "/2.0/repositories/\(owner)/\(repo)/issues" + case let .editIssue(owner, repo, number, _): + return "/repos/\(owner)/\(repo)/issues/\(number)" + } + } + func asURLRequest() throws -> URLRequest { + let url = try BitbucketRouter.baseURLString.asURL() + + var urlRequest = URLRequest(url: url.appendingPathComponent(path)) + urlRequest.httpMethod = method.rawValue + if let token = GlobalState.instance.token, !token.isEmpty { + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + switch self { + case let .authKey(parameters, headers): + headers.forEach{ (key, value) in urlRequest.addValue(value, forHTTPHeaderField: key) } + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + case let .repoIssues(_, _, parameters): + urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case let .issueDetail(_, _, _, parameters): + urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case let .createComment(_, _, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + case let .createIssue(_, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + case let .editIssue(_, _, _, parameters): + urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) + } + + return urlRequest + } +} diff --git a/GithubIssues/CreateIssueViewController.swift b/GithubIssues/CreateIssueViewController.swift index d57b451..3b2c667 100644 --- a/GithubIssues/CreateIssueViewController.swift +++ b/GithubIssues/CreateIssueViewController.swift @@ -22,9 +22,6 @@ class CreateIssueViewController: UIViewController { super.viewDidLoad() setup() } - - - } @@ -38,7 +35,7 @@ extension CreateIssueViewController { func uploadIssue() { let title = titleTextField.text ?? "" let body = textView.text ?? "" - API.createIssue(owner: owner, repo: repo, title: title, body: body) { [weak self] (dataResponse: DataResponse) in + App.api.createIssue(owner: owner, repo: repo, title: title, body: body) { [weak self] (dataResponse: DataResponse) in guard let `self` = self else { return } switch dataResponse.result { case .success(let issue): @@ -52,7 +49,6 @@ extension CreateIssueViewController { } } - extension CreateIssueViewController { @IBAction func DoneButtonTapped(_ sender: Any) { uploadIssue() diff --git a/GithubIssues/ExampleViewController.swift b/GithubIssues/ExampleViewController.swift index 26cc5b9..723098d 100644 --- a/GithubIssues/ExampleViewController.swift +++ b/GithubIssues/ExampleViewController.swift @@ -7,22 +7,22 @@ // import UIKit -import RxSwift -import RxCocoa +//import RxSwift +//import RxCocoa class ExampleViewController: UIViewController { @IBOutlet var textField: UITextField! @IBOutlet var label: UILabel! - var disposeBag: DisposeBag = DisposeBag() +// var disposeBag: DisposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() - textField.rx.text.bind(to: label.rx.text).disposed(by: disposeBag) - - Observable.just(3) +// textField.rx.text.bind(to: label.rx.text).disposed(by: disposeBag) +// +// Observable.just(3) +// - } } diff --git a/GithubIssues/GithubAPI.swift b/GithubIssues/GithubAPI.swift new file mode 100644 index 0000000..f77b82d --- /dev/null +++ b/GithubIssues/GithubAPI.swift @@ -0,0 +1,137 @@ +// +// GithubAPI.swift +// GithubIssues +// +// Created by Leonard on 2017. 9. 30.. +// Copyright © 2017년 intmain. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON +import OAuthSwift + +struct GithubAPI: API { + + let githubOAuth: OAuth2Swift = OAuth2Swift( + consumerKey: "36c48adc3d1433fbd286", + consumerSecret: "a911bfd178a79f25d14c858a1199cd76d9e92f3b", + authorizeUrl: "https://github.com/login/oauth/authorize", + accessTokenUrl: "https://github.com/login/oauth/access_token", + responseType: "code" + ) + + func getOauthKey(user: String, password: String, completionHandler: @escaping (DataResponse) -> Void) { + var headers: HTTPHeaders = [:] + if let authorizationHeader = Request.authorizationHeader(user: user, password: password) { + headers[authorizationHeader.key] = authorizationHeader.value + } + let parameters: Parameters = ["client_secret": GithubRouter.clientSecret , "scopes": ["public_repo"], "note": "admin script" ] + Alamofire.request(GithubRouter.authKey(parameters, headers)) + .responseSwiftyJSON { json in + print(json) + completionHandler(json) + } + } + func getToekn(handler: @escaping (() -> Void)) { + let _ = githubOAuth.authorize( + withCallbackURL: URL(string: "ISSAPP://oauth-callback/github")!, + scope: "user,repo", state:"state", + success: { credential, response, parameters in + GlobalState.instance.token = credential.oauthToken + GlobalState.instance.serviceType = .github + App.api = GithubAPI() + handler() + }, + failure: { error in + print(error.localizedDescription) + }) + } + func tokenRefresh(handler: @escaping (() -> Void)) { + guard let refreshToken = GlobalState.instance.refreshToken else { return } + githubOAuth.renewAccessToken(withRefreshToken: refreshToken, success: { (credential, response, parameters) in + GlobalState.instance.token = credential.oauthToken + GlobalState.instance.serviceType = .github + App.api = GithubAPI() + handler() + }) { (error) in + print(error.localizedDescription) + } + } + + func repoIssues(owner: String, repo: String) -> (Int, @escaping IssueResponsesHandler) -> Void { + return { (page: Int, handler: @escaping IssueResponsesHandler) in + let parameters: Parameters = ["page": page, "state": "all"] + Alamofire.request(GithubRouter.repoIssues(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print("result: \(dataResponse.value)") + let result = dataResponse.map({ (json: JSON) -> [Model.Issue] in + return json.arrayValue.map{ + Model.Issue(json: $0) + } + }) + handler(result) + } + } + } + + func issueComment(owner: String, repo: String, number: Int) -> (Int, @escaping CommentResponsesHandler) -> Void { + return { page, handler in + let parameters: Parameters = ["page": page] + Alamofire.request(GithubRouter.issueDetail(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> [Model.Comment] in + return json.arrayValue.map{ + Model.Comment(json: $0) + } + }) + handler(result) + } + } + } + + func createComment(owner: String, repo: String, number: Int, comment: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["body": comment] + Alamofire.request(GithubRouter.createComment(owner: owner, repo: repo, number: number, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + let result = dataResponse.map({ (json: JSON) -> Model.Comment in + Model.Comment(json: json) + }) + completionHandler(result) + } + } + + func createIssue(owner: String, repo: String, title: String, body: String, completionHandler: @escaping (DataResponse) -> Void ) { + let parameters: Parameters = ["title": title, "body": body] + Alamofire.request(GithubRouter.createIssue(owner: owner, repo: repo, parameters: parameters)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + } + + func closeIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.closed.display + Alamofire.request(GithubRouter.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + + } + + func openIssue(owner: String, repo: String, number: Int, issue: Model.Issue, completionHandler: @escaping (DataResponse) -> Void) { + var dict = issue.toDict + dict["state"] = Model.Issue.State.open.display + Alamofire.request(GithubRouter.editIssue(owner: owner, repo: repo, number: number, parameters: dict)).responseSwiftyJSON { (dataResponse: DataResponse) in + print(dataResponse.request?.url?.absoluteString) + let result = dataResponse.map({ (json: JSON) -> Model.Issue in + Model.Issue(json: json) + }) + completionHandler(result) + } + + } +} diff --git a/GithubIssues/Router.swift b/GithubIssues/GithubRouter.swift similarity index 86% rename from GithubIssues/Router.swift rename to GithubIssues/GithubRouter.swift index f56f606..48c5e5f 100644 --- a/GithubIssues/Router.swift +++ b/GithubIssues/GithubRouter.swift @@ -10,23 +10,19 @@ import Foundation import Alamofire import SwiftyJSON -enum Router { +enum GithubRouter { case authKey(Parameters, HTTPHeaders) case repoIssues(owner: String, repo: String, parameters: Parameters) case issueDetail(owner: String, repo: String, number: Int, parameters: Parameters) case createComment(owner: String, repo: String, number: Int, parameters: Parameters) case createIssue(owner: String, repo: String, parameters: Parameters) case editIssue(owner: String, repo: String, number: Int, parameters: Parameters) - -// PATCH /repos/:owner/:repo/issues/:number - } -extension Router: URLRequestConvertible { - - static let baseURLString = "https://api.github.com" - static let clientID = "36c48adc3d1433fbd286" - static let clientSecret = "a911bfd178a79f25d14c858a1199cd76d9e92f3b" +extension GithubRouter: URLRequestConvertible { + static let baseURLString: String = "https://api.github.com" + static let clientID: String = "36c48adc3d1433fbd286" + static let clientSecret: String = "a911bfd178a79f25d14c858a1199cd76d9e92f3b" var method: HTTPMethod { switch self { @@ -49,7 +45,7 @@ extension Router: URLRequestConvertible { var path: String { switch self { case .authKey: - return "/authorizations/clients/\(Router.clientID)/\(Date().timeIntervalSince1970)" + return "/authorizations/clients/\(GithubRouter.clientID)/\(Date().timeIntervalSince1970)" case let .repoIssues(owner, repo, _): return "/repos/\(owner)/\(repo)/issues" case let .issueDetail(owner, repo, number, _): @@ -62,16 +58,15 @@ extension Router: URLRequestConvertible { return "/repos/\(owner)/\(repo)/issues/\(number)" } } - func asURLRequest() throws -> URLRequest { - let url = try Router.baseURLString.asURL() + let url = try GithubRouter.baseURLString.asURL() var urlRequest = URLRequest(url: url.appendingPathComponent(path)) urlRequest.httpMethod = method.rawValue if let token = GlobalState.instance.token, !token.isEmpty { urlRequest.setValue("token \(token)", forHTTPHeaderField: "Authorization") } - + switch self { case let .authKey(parameters, headers): headers.forEach{ (key, value) in urlRequest.addValue(value, forHTTPHeaderField: key) } @@ -91,6 +86,3 @@ extension Router: URLRequestConvertible { return urlRequest } } - - - diff --git a/GithubIssues/GlobalState.swift b/GithubIssues/GlobalState.swift index e36c437..fa4fcfa 100644 --- a/GithubIssues/GlobalState.swift +++ b/GithubIssues/GlobalState.swift @@ -11,12 +11,18 @@ import Foundation final class GlobalState { static let instance = GlobalState() + enum ServiceType: String { + case github + case bitbucket + } struct constants { static let tokenKey = "token" static let ownerKey = "owner" static let repoKey = "repo" static let reposKey = "repos" + static let serviceType = "serviceType" + static let refreshTokenKey = "refreshToken" } var token: String? { @@ -29,6 +35,16 @@ final class GlobalState { } } + var refreshToken: String? { + get { + let token = UserDefaults.standard.string(forKey: constants.refreshTokenKey) + return token + } + set { + UserDefaults.standard.set(newValue, forKey: constants.refreshTokenKey) + } + } + var owner: String { get { let owner = UserDefaults.standard.string(forKey: constants.ownerKey) ?? "" @@ -51,6 +67,18 @@ final class GlobalState { } } + var serviceType: ServiceType { + get { + let type = UserDefaults.standard.string(forKey: constants.serviceType) ?? "" + let serviceType = ServiceType(rawValue: type) ?? ServiceType.github + return serviceType + } + + set { + UserDefaults.standard.set(newValue.rawValue, forKey: constants.serviceType) + } + } + var isLoggedIn: Bool { get { let isEmpty = token?.isEmpty ?? true diff --git a/GithubIssues/Info.plist b/GithubIssues/Info.plist index f87b36b..ee44cc0 100644 --- a/GithubIssues/Info.plist +++ b/GithubIssues/Info.plist @@ -16,6 +16,17 @@ APPL CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ISSAPP + + + CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/GithubIssues/Issue.swift b/GithubIssues/Issue.swift index 241a1b2..776605f 100644 --- a/GithubIssues/Issue.swift +++ b/GithubIssues/Issue.swift @@ -45,16 +45,18 @@ extension Model.Issue { var toDict: [String: Any] { let format = DateFormatter() format.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" - - - var dict = ["id": id, - "number": number, - "title": title, - "comments": comments, - "body": body, - "state": state.display, - "user": ["id": user.id, "login": user.login, "acatar_url": (user.avatarURL?.absoluteString ?? "")] - ] as [String : Any] + var dict: [String : Any] = [ + "id": id, + "number": number, + "title": title, + "comments": comments, + "body": body, + "state": state.display, + "user": [ + "id": user.id, + "login": user.login, + "acatar_url": (user.avatarURL?.absoluteString ?? "")] + ] if let createdAt = createdAt { dict["createdAt"] = format.string(from: createdAt) } @@ -73,15 +75,15 @@ extension Model.Issue { extension Model.Issue { enum State: String { - case open = "open" - case closed = "closed" - case none = "none" + case open + case closed + case none var display: String { switch self { case .open: return "opened" case .closed: return "closed" - default: return "-" + case .none: return "-" } } diff --git a/GithubIssues/IssueCell.swift b/GithubIssues/IssueCell.swift index 122610a..61dfeb2 100644 --- a/GithubIssues/IssueCell.swift +++ b/GithubIssues/IssueCell.swift @@ -34,10 +34,12 @@ extension IssueCell: CellProtocol { func update(data issue: Model.Issue) { titleLabel.text = issue.title contentLabel.text = issue.body - let createdAt = issue.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + let createdAt = issue.createdAt?.string(dateFormat: "dd MMM yyyy") ?? "-" contentLabel.text = "#\(issue.number) \(issue.state.display) on \(createdAt) by \(issue.user.login)" commentCountButton.setTitle("\(issue.comments)", for: .normal) stateButton.isSelected = issue.state == .closed + let commentCountHidden: Bool = issue.comments == 0 + commentCountButton.isHidden = commentCountHidden } } diff --git a/GithubIssues/IssueCommentCell.swift b/GithubIssues/IssueCommentCell.swift index 1c32a30..26845a3 100644 --- a/GithubIssues/IssueCommentCell.swift +++ b/GithubIssues/IssueCommentCell.swift @@ -38,7 +38,7 @@ extension IssueCommentCell { profileImageView.af_setImage(withURL: url) } - let createdAt = comment.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + let createdAt = comment.createdAt?.string(dateFormat: "dd MMM yyyy") ?? "-" titleLabel.text = "\(comment.user.login) commented on \(createdAt)" bodyLabel.text = comment.body } diff --git a/GithubIssues/IssueDetailHeaderCell.swift b/GithubIssues/IssueDetailHeaderCell.swift index 19270df..1a83c77 100644 --- a/GithubIssues/IssueDetailHeaderCell.swift +++ b/GithubIssues/IssueDetailHeaderCell.swift @@ -98,7 +98,7 @@ extension IssueDetailHeaderCell { extension IssueDetailHeaderCell { func update(data: Model.Issue, withImage: Bool = true) { - let createdAt = data.createdAt?.string(dateFormat: "DD MMM yyyy") ?? "-" + let createdAt = data.createdAt?.string(dateFormat: "dd MMM yyyy") ?? "-" titleLabel.text = data.title stateButton.isSelected = data.state == .closed infoLabel.text = "\(data.user.login) \(data.state.display) this issue on \(createdAt) · \(data.comments) comments" diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift index 00f3bb2..c4a32aa 100644 --- a/GithubIssues/IssueDetailViewController.swift +++ b/GithubIssues/IssueDetailViewController.swift @@ -21,7 +21,7 @@ class IssueDetailViewController: ListViewController { override func viewDidLoad() { super.viewDidLoad() - api = API.issueComment(owner: owner, repo: repo, number: issue.number) + api = App.api.issueComment(owner: owner, repo: repo, number: issue.number) setup() } @@ -47,7 +47,7 @@ class IssueDetailViewController: ListViewController { @IBAction func sendButtonTapped(_ sender: Any) { let comment = commentTextField.text ?? "" - API.createComment(owner: owner, repo: repo, number: issue.number, comment: comment) { [weak self] (dataResponse: DataResponse) in + App.api.createComment(owner: owner, repo: repo, number: issue.number, comment: comment) { [weak self] (dataResponse: DataResponse) in guard let `self` = self else { return } switch dataResponse.result { case .success(let comment): @@ -159,7 +159,7 @@ extension IssueDetailViewController { func chagneState() { switch issue.state { case .open, .none: - API.closeIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in + App.api.closeIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in switch dataResponse.result { case .success(let issue): print("issue: \(issue)") @@ -172,7 +172,7 @@ extension IssueDetailViewController { }) case .closed: - API.openIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in + App.api.openIssue(owner: owner, repo: repo, number: issue.number, issue: issue, completionHandler: { [weak self] (dataResponse: DataResponse) in switch dataResponse.result { case .success(let issue): print("issue: \(issue)") diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift index c45bb31..365f336 100644 --- a/GithubIssues/IssuesViewController.swift +++ b/GithubIssues/IssuesViewController.swift @@ -15,7 +15,7 @@ final class IssuesViewController: ListViewController { override func viewDidLoad() { super.viewDidLoad() - api = API.repoIssues(owner: owner, repo: repo) + api = App.api.repoIssues(owner: owner, repo: repo) collectionView.delegate = self collectionView.dataSource = self setup() @@ -66,7 +66,6 @@ final class IssuesViewController: ListViewController { case UICollectionElementKindSectionFooter: let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "LoadMoreCell", for: indexPath) as? LoadMoreCell ?? LoadMoreCell() - loadMoreCell = footerView return footerView diff --git a/GithubIssues/ListViewController.swift b/GithubIssues/ListViewController.swift index ab7327d..78638c4 100644 --- a/GithubIssues/ListViewController.swift +++ b/GithubIssues/ListViewController.swift @@ -77,7 +77,6 @@ class ListViewController: UIViewCo } - func dataLoaded(items: [Item]) { refreshDataSourceIfNeeded() diff --git a/GithubIssues/LoadMoreCell.swift b/GithubIssues/LoadMoreCell.swift index 5fa97d7..ab42466 100644 --- a/GithubIssues/LoadMoreCell.swift +++ b/GithubIssues/LoadMoreCell.swift @@ -38,8 +38,14 @@ class LoadMoreCell: UICollectionReusableView { let bindings = ["view": view] self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:[], metrics:nil, views: bindings)) self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:[], metrics:nil, views: bindings)) + +// activityIndicatorView.addObserver(self, forKeyPath: "hidden", options: [ .new], context: nil) } +// override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { +// print("keypath: \(keyPath), \(change)") +// } + } extension LoadMoreCell { @@ -54,10 +60,11 @@ extension LoadMoreCell { } } -extension LoadMoreCell { - static var view: LoadMoreCell { - get { - return Bundle.main.loadNibNamed("LoadMoreCell", owner: nil, options: nil)?.first as! LoadMoreCell - } - } -} +//extension LoadMoreCell { +// static var view: LoadMoreCell { +// get { +// return Bundle.main.loadNibNamed("LoadMoreCell", owner: nil, options: nil)?.first as! LoadMoreCell +// } +// } +//} + diff --git a/GithubIssues/LoadMoreCell.xib b/GithubIssues/LoadMoreCell.xib index 5e1c1b3..49f3ac1 100644 --- a/GithubIssues/LoadMoreCell.xib +++ b/GithubIssues/LoadMoreCell.xib @@ -21,7 +21,7 @@ - + diff --git a/GithubIssues/LoginViewController.swift b/GithubIssues/LoginViewController.swift index 64bd12d..2983b4c 100644 --- a/GithubIssues/LoginViewController.swift +++ b/GithubIssues/LoginViewController.swift @@ -9,17 +9,16 @@ import UIKit import Alamofire import SwiftyJSON +import OAuthSwift class LoginViewController: UIViewController { - - @IBOutlet var idTextField: UITextField! - @IBOutlet var passwordTextField: UITextField! + override func viewDidLoad() { super.viewDidLoad() - + // Do any additional setup after loading the view. } - + override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. @@ -29,21 +28,17 @@ class LoginViewController: UIViewController { let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "LoginViewController") as! LoginViewController return viewController } - @IBAction func loginButtonTapped(_ sender: Any) { - let id = idTextField.text ?? "" - let password = passwordTextField.text ?? "" - API.getOauthKey(user: id, password: password) { [weak self] (response: DataResponse) in - switch response.result { - case let .success(value): - print(value) - let token = value["token"].stringValue - GlobalState.instance.token = token - self?.dismiss(animated: true, completion: { - - }) - case let .failure(error): - print(error) - } + @IBAction func githubLoginButtonTapped(_ sender: Any) { + App.api.getToekn { [weak self] in + self?.dismiss(animated: true, completion: { + }) + } + } + + @IBAction func bitbucketLoginButtonTapped(_ sender: Any) { + App.api.getToekn { [weak self] in + self?.dismiss(animated: true, completion: { + }) } } } diff --git a/GithubIssues/User.swift b/GithubIssues/User.swift index ea0bd2a..6d57a6a 100644 --- a/GithubIssues/User.swift +++ b/GithubIssues/User.swift @@ -11,12 +11,12 @@ import SwiftyJSON extension Model { public struct User { - let id: Int + let id: String let login: String let avatarURL: URL? public init(json: JSON) { - id = json["id"].intValue + id = json["id"].stringValue login = json["login"].stringValue avatarURL = URL(string: json["avatar_url"].stringValue) diff --git a/Podfile b/Podfile index dcb70c8..03f4ad6 100644 --- a/Podfile +++ b/Podfile @@ -7,8 +7,7 @@ target 'GithubIssues' do pod 'Alamofire', '~> 4.5' pod 'AlamofireImage' pod 'SwiftyJSON' - pod 'RxSwift' - pod 'RxCocoa' + pod 'OAuthSwift', '~> 1.1.2' target 'GithubIssuesTests' do inherit! :search_paths From 67fcdec0ace8db418a62c54056a82a209004e34b Mon Sep 17 00:00:00 2001 From: Leonard-happy Date: Mon, 2 Oct 2017 01:11:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?swiftlint=20=EC=A0=81=EC=9A=A9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 44 +++++++++++ GithubIssues.xcodeproj/project.pbxproj | 34 ++++++-- .../xcschemes/GithubIssues.xcscheme | 4 +- GithubIssues/API.swift | 2 +- GithubIssues/AppDelegate.swift | 18 ++--- GithubIssues/Base.lproj/Main.storyboard | 77 +++---------------- GithubIssues/BitbucketAPI.swift | 40 +++------- GithubIssues/BitbucketRouter.swift | 9 +-- GithubIssues/Comment.swift | 12 --- GithubIssues/CreateIssueViewController.swift | 9 +-- GithubIssues/DataRequest+extension.swift | 3 - GithubIssues/ExampleViewController.swift | 28 ------- GithubIssues/GithubAPI.swift | 21 ++--- GithubIssues/GithubRouter.swift | 4 +- GithubIssues/GlobalState.swift | 55 +++++-------- GithubIssues/Issue.swift | 5 +- GithubIssues/IssueCell.swift | 5 +- GithubIssues/IssueCommentCell.swift | 7 +- GithubIssues/IssueCommentCell.xib | 3 +- GithubIssues/IssueDetailHeaderCell.swift | 4 - GithubIssues/IssueDetailViewController.swift | 19 +---- GithubIssues/IssuesViewController.swift | 3 +- GithubIssues/ListViewController.swift | 39 ++-------- GithubIssues/LoadMoreCell.swift | 20 +---- GithubIssues/LoginViewController.swift | 2 +- GithubIssues/RepoViewController.swift | 3 - GithubIssues/ReposViewController.swift | 2 - Podfile | 2 +- 28 files changed, 161 insertions(+), 313 deletions(-) create mode 100644 .swiftlint.yml delete mode 100644 GithubIssues/ExampleViewController.swift diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..54c3258 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,44 @@ + +disabled_rules: # 실행에서 제외할 룰 식별자들 + - colon + - comma + - control_statement + - unused_optional_binding + - trailing_whitespace + - discarded_notification_center_observer +opt_in_rules: # 일부 룰은 옵트 인 형태로 제공 + - empty_count + # 사용 가능한 모든 룰은 swiftlint rules 명령으로 확인 가능 +included: # 린트 과정에 포함할 파일 경로. 이 항목이 존재하면 `--path`는 무시됨 + - GithubIssues +excluded: # 린트 과정에서 무시할 파일 경로. `included`보다 우선순위 높음 + - Pods + +# 설정 가능한 룰은 이 설정 파일에서 커스터마이징 가능 +# 경고나 에러 중 하나를 발생시키는 룰은 위반 수준을 설정 가능 +force_cast: warning # 암시적으로 지정 +force_try: + severity: warning # 명시적으로 지정 +# 경고 및 에러 둘 다 존재하는 룰의 경우 값을 하나만 지정하면 암시적으로 경고 수준에 설정됨 +line_length: 280 + +# 둘 다 명시적으로 지정할 수도 있음 +file_length: + warning: 500 + error: 1200 +# 네이밍 룰은 경고/에러에 min_length와 max_length를 각각 설정 가능 +# 제외할 이름을 설정할 수 있음 +type_name: + min_length: 3 # 경고에만 적용됨 + max_length: # 경고와 에러 둘 다 적용 + warning: 40 + error: 50 + excluded: iPhone # 제외할 문자열 값 사용 +identifier_name: + min_length: # min_length에서 + error: 3 # 에러만 적용 + excluded: # 제외할 문자열 목록 사용 + - id + - URL + - GlobalAPIKey +reporter: "xcode" # 보고 유형 (xcode, json, csv, checkstyle, junit, html, emoji) \ No newline at end of file diff --git a/GithubIssues.xcodeproj/project.pbxproj b/GithubIssues.xcodeproj/project.pbxproj index c95fd55..4e172c7 100644 --- a/GithubIssues.xcodeproj/project.pbxproj +++ b/GithubIssues.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ D71E7FD11F7FADF800FD5DA0 /* BitbucketRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD01F7FADF800FD5DA0 /* BitbucketRouter.swift */; }; D71E7FD31F7FAE4C00FD5DA0 /* GithubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD21F7FAE4C00FD5DA0 /* GithubAPI.swift */; }; D71E7FD51F7FAE6100FD5DA0 /* BitbucketAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71E7FD41F7FAE6100FD5DA0 /* BitbucketAPI.swift */; }; - D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D730D9951F6AE4DA0057276B /* ExampleViewController.swift */; }; D7412BD31F701F4A00E63DF6 /* IssueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D7412BD21F701F4A00E63DF6 /* IssueCell.xib */; }; D776DCC51F76286300E61284 /* LoadMoreCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D776DCC41F76286300E61284 /* LoadMoreCell.xib */; }; D776DD041F77E3D900E61284 /* IssueDetailHeaderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D776DD031F77E3D900E61284 /* IssueDetailHeaderCell.xib */; }; @@ -79,7 +78,6 @@ D71E7FD01F7FADF800FD5DA0 /* BitbucketRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitbucketRouter.swift; sourceTree = ""; }; D71E7FD21F7FAE4C00FD5DA0 /* GithubAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubAPI.swift; sourceTree = ""; }; D71E7FD41F7FAE6100FD5DA0 /* BitbucketAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitbucketAPI.swift; sourceTree = ""; }; - D730D9951F6AE4DA0057276B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = ""; }; D7412BD21F701F4A00E63DF6 /* IssueCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IssueCell.xib; sourceTree = ""; }; D776DCC41F76286300E61284 /* LoadMoreCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LoadMoreCell.xib; sourceTree = ""; }; D776DD031F77E3D900E61284 /* IssueDetailHeaderCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueDetailHeaderCell.xib; sourceTree = ""; }; @@ -201,7 +199,6 @@ D7CE58E41F64118400380CEE /* Assets.xcassets */, D7CE58E61F64118400380CEE /* LaunchScreen.storyboard */, D7CE58E91F64118400380CEE /* Info.plist */, - D730D9951F6AE4DA0057276B /* ExampleViewController.swift */, ); path = GithubIssues; sourceTree = ""; @@ -297,6 +294,7 @@ buildConfigurationList = D7CE59021F64118400380CEE /* Build configuration list for PBXNativeTarget "GithubIssues" */; buildPhases = ( 6C91CFE4185593F1C5EE423E /* [CP] Check Pods Manifest.lock */, + D7D7832A1F81243800B247DD /* ShellScript */, D7CE58D61F64118400380CEE /* Sources */, D7CE58D71F64118400380CEE /* Frameworks */, D7CE58D81F64118400380CEE /* Resources */, @@ -361,7 +359,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0830; - LastUpgradeCheck = 0830; + LastUpgradeCheck = 0900; ORGANIZATIONNAME = intmain; TargetAttributes = { D7CE58D91F64118400380CEE = { @@ -540,6 +538,19 @@ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-GithubIssuesTests/Pods-GithubIssuesTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; + D7D7832A1F81243800B247DD /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${PODS_ROOT}/SwiftLint/swiftlint"; + }; E920720D5AEA69AE4B6C5C08 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -604,7 +615,6 @@ D71E7FD51F7FAE6100FD5DA0 /* BitbucketAPI.swift in Sources */, D7014DC81F6814ED00F0F7F7 /* UIColor+extension.swift in Sources */, D7CE59101F64CB6F00380CEE /* DataRequest+extension.swift in Sources */, - D730D9961F6AE4DA0057276B /* ExampleViewController.swift in Sources */, D7CB7A351F74A3C700816E33 /* ListViewController.swift in Sources */, D7CE592A1F6546D200380CEE /* IssueDetailViewController.swift in Sources */, D7CE591D1F64D82E00380CEE /* IssuesViewController.swift in Sources */, @@ -685,7 +695,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -693,7 +705,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; @@ -736,7 +752,9 @@ CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -744,7 +762,11 @@ CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; @@ -777,6 +799,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = CX2K28Z3E9; INFOPLIST_FILE = GithubIssues/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.iosDeveloper.GithubIssues; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -792,6 +815,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = CX2K28Z3E9; INFOPLIST_FILE = GithubIssues/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.iosDeveloper.GithubIssues; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme b/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme index 1a94a09..26c80eb 100644 --- a/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme +++ b/GithubIssues.xcodeproj/xcuserdata/leonard.xcuserdatad/xcschemes/GithubIssues.xcscheme @@ -1,6 +1,6 @@ Bool { // Override point for customization after application launch. - - if !GlobalState.instance.isLoggedIn { let loginViewController = LoginViewController.viewController DispatchQueue.main.asyncAfter(deadline: .now() + 0.0, execute: { [weak self] in self?.window?.rootViewController?.present(loginViewController, animated: false, completion: nil) }) - } - return true } func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool { @@ -60,4 +53,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } } - diff --git a/GithubIssues/Base.lproj/Main.storyboard b/GithubIssues/Base.lproj/Main.storyboard index 74dbc08..f21e9a0 100644 --- a/GithubIssues/Base.lproj/Main.storyboard +++ b/GithubIssues/Base.lproj/Main.storyboard @@ -55,7 +55,7 @@ @@ -108,6 +108,7 @@ + diff --git a/GithubIssues/IssueDetailHeaderCell.swift b/GithubIssues/IssueDetailHeaderCell.swift index 1a83c77..3410890 100644 --- a/GithubIssues/IssueDetailHeaderCell.swift +++ b/GithubIssues/IssueDetailHeaderCell.swift @@ -56,7 +56,6 @@ class IssueDetailHeaderCell: UICollectionReusableView { static let estimateSizeCell: IssueDetailHeaderCell = IssueDetailHeaderCell() } - // MARK: - setup extension IssueDetailHeaderCell { func setup() { @@ -86,8 +85,6 @@ extension IssueDetailHeaderCell { withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow ) - - let width = size.width == 0 ? IssueDetailHeaderCell.estimateSizeCell.bounds.width : size.width let height = size.height == 0 ? IssueDetailHeaderCell.estimateSizeCell.bounds.height : size.height let cellSize = CGSize(width: width, height: height) @@ -112,4 +109,3 @@ extension IssueDetailHeaderCell { } } - diff --git a/GithubIssues/IssueDetailViewController.swift b/GithubIssues/IssueDetailViewController.swift index c4a32aa..1f31385 100644 --- a/GithubIssues/IssueDetailViewController.swift +++ b/GithubIssues/IssueDetailViewController.swift @@ -56,7 +56,7 @@ class IssueDetailViewController: ListViewController { self.commentTextField.resignFirstResponder() break - case .failure(_): + case .failure: break } } @@ -111,35 +111,23 @@ extension IssueDetailViewController { guard let animationDuration = notifiaction.userInfo?[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval else { return } guard let animationCurve = notifiaction.userInfo?[UIKeyboardAnimationCurveUserInfoKey] as? UInt else { return } let animationOptions = UIViewAnimationOptions(rawValue: animationCurve) - let keyboardHeight = keyboardBounds.height - - let inputBottom = self.view.frame.height - keyboardBounds.origin.y print("inputBottom: \(inputBottom)") print("keyboard: \(keyboardHeight)") - - var inset = self.collectionView.contentInset inset.bottom = inputBottom + 46 self.collectionView.contentInset = inset - - - self.commentInputBottomConstraint.constant = inputBottom UIView.animate(withDuration: animationDuration, delay: 0, options: animationOptions, animations: { self.view.layoutIfNeeded() }, completion: nil) - - } } func removeKeyboardNOtification() { NotificationCenter.default.removeObserver(self) } - - } extension IssueDetailViewController { @@ -164,9 +152,7 @@ extension IssueDetailViewController { case .success(let issue): print("issue: \(issue)") self?.issue = issue -// self?.loadHeaderView() case .failure(let error): - print(dataResponse.request) print(error) } @@ -177,13 +163,10 @@ extension IssueDetailViewController { case .success(let issue): print("issue: \(issue)") self?.issue = issue -// self?.loadHeaderView() case .failure(let error): - print(dataResponse.request) print(error) } }) } } } - diff --git a/GithubIssues/IssuesViewController.swift b/GithubIssues/IssuesViewController.swift index 365f336..1fc177d 100644 --- a/GithubIssues/IssuesViewController.swift +++ b/GithubIssues/IssuesViewController.swift @@ -9,7 +9,7 @@ import UIKit import Alamofire -final class IssuesViewController: ListViewController { +final class IssuesViewController: ListViewController { var owner: String = "" var repo: String = "" @@ -53,7 +53,6 @@ final class IssuesViewController: ListViewController { self.performSegue(withIdentifier: "PushIssueDetail", sender: data) } - override func cellIdentifier() -> String { return "IssueCell" } diff --git a/GithubIssues/ListViewController.swift b/GithubIssues/ListViewController.swift index 78638c4..5a09029 100644 --- a/GithubIssues/ListViewController.swift +++ b/GithubIssues/ListViewController.swift @@ -19,7 +19,6 @@ extension DatasourceRefreshable { func setNeedRefreshDatasource() { needRefreshDatasource = true } - func refreshDataSourceIfNeeded() { if needRefreshDatasource { datasource = [] @@ -41,24 +40,19 @@ class ListViewController: UIViewCo var canLoadMore: Bool = true fileprivate var estimatedSizes: [IndexPath: CGSize] = [:] fileprivate let estimateCell: CellType = CellType.cellFromNib - typealias IssueResponsesHandler = (DataResponse<[Item]>) -> Void var api: ((Int, @escaping IssueResponsesHandler) -> Void)? - func loadMore(indexPath: IndexPath) { guard indexPath.item == datasource.count - 1 && !isLoading && canLoadMore else { return } load() } - func setup() { collectionView.refreshControl = refreshControl collectionView.alwaysBounceVertical = true refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) load() loadMoreCell?.load() - } - func load() { guard isLoading == false else {return } isLoading = true @@ -68,30 +62,23 @@ class ListViewController: UIViewCo case .success(let items): self.dataLoaded(items: items) self.isLoading = false - case .failure(_): + case .failure: self.isLoading = false break } - }) - } - func dataLoaded(items: [Item]) { refreshDataSourceIfNeeded() - - page = page + 1 - if items.count == 0 { + page += 1 + if items.isEmpty { canLoadMore = false loadMoreCell?.loadDone() - } refreshControl.endRefreshing() datasource.append(contentsOf: items) collectionView.reloadData() - } - @objc func refresh() { page = 1 canLoadMore = true @@ -99,11 +86,9 @@ class ListViewController: UIViewCo setNeedRefreshDatasource() load() } - func cellIdentifier() -> String { return "" } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { /* 사이즈 @@ -116,52 +101,40 @@ class ListViewController: UIViewCo 그 사이즈를 리턴. */ - - var estimatedSize = estimatedSizes[indexPath] ?? CGSize.zero if estimatedSize != .zero { return estimatedSize } let data = datasource[indexPath.item] - estimateCell.update(data: data) - let targetSize = CGSize(width: collectionView.frame.size.width, height: 50) - estimatedSize = estimateCell.contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriorityRequired, verticalFittingPriority: UILayoutPriorityDefaultLow) estimatedSizes[indexPath] = estimatedSize return estimatedSize - } - func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return datasource.count } - func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { loadMore(indexPath: indexPath) } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier(), for: indexPath) as! CellType + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier(), for: indexPath) as? CellType else { + return UICollectionViewCell() + } let issue = datasource[indexPath.item] cell.update(data: issue) return cell } - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { } - func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { return UICollectionReusableView() } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - return CGSize.zero } } diff --git a/GithubIssues/LoadMoreCell.swift b/GithubIssues/LoadMoreCell.swift index ab42466..95defcf 100644 --- a/GithubIssues/LoadMoreCell.swift +++ b/GithubIssues/LoadMoreCell.swift @@ -16,7 +16,8 @@ class LoadMoreCell: UICollectionReusableView { public func loadNib() -> UIView { let bundle = Bundle(for: type(of: self)) let nib = UINib(nibName: "LoadMoreCell", bundle: bundle) - return nib.instantiate(withOwner: self, options: nil)[0] as! UIView + guard let view = nib.instantiate(withOwner: self, options: nil)[0] as? UIView else { return UIView() } + return view } override public init(frame: CGRect) { @@ -32,20 +33,12 @@ class LoadMoreCell: UICollectionReusableView { fileprivate func setupNib() { let view = self.loadNib() - self.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false let bindings = ["view": view] self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:[], metrics:nil, views: bindings)) self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:[], metrics:nil, views: bindings)) - -// activityIndicatorView.addObserver(self, forKeyPath: "hidden", options: [ .new], context: nil) } - -// override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { -// print("keypath: \(keyPath), \(change)") -// } - } extension LoadMoreCell { @@ -59,12 +52,3 @@ extension LoadMoreCell { doneView.isHidden = true } } - -//extension LoadMoreCell { -// static var view: LoadMoreCell { -// get { -// return Bundle.main.loadNibNamed("LoadMoreCell", owner: nil, options: nil)?.first as! LoadMoreCell -// } -// } -//} - diff --git a/GithubIssues/LoginViewController.swift b/GithubIssues/LoginViewController.swift index 2983b4c..e0f5b5c 100644 --- a/GithubIssues/LoginViewController.swift +++ b/GithubIssues/LoginViewController.swift @@ -25,7 +25,7 @@ class LoginViewController: UIViewController { } static var viewController: LoginViewController { - let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "LoginViewController") as! LoginViewController + guard let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "LoginViewController") as? LoginViewController else { return LoginViewController() } return viewController } @IBAction func githubLoginButtonTapped(_ sender: Any) { diff --git a/GithubIssues/RepoViewController.swift b/GithubIssues/RepoViewController.swift index 702bb9d..5547451 100644 --- a/GithubIssues/RepoViewController.swift +++ b/GithubIssues/RepoViewController.swift @@ -27,9 +27,7 @@ class RepoViewController: UIViewController { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } - - // MARK: - Navigation override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { @@ -50,7 +48,6 @@ class RepoViewController: UIViewController { } } - @IBAction func unwindFromRepos(_ segue: UIStoryboardSegue) { if let reposViewController = segue.source as? ReposViewController, let (owner, repo) = reposViewController.selectedRepo { ownerTextField.text = owner diff --git a/GithubIssues/ReposViewController.swift b/GithubIssues/ReposViewController.swift index 2b85473..e5c2a90 100644 --- a/GithubIssues/ReposViewController.swift +++ b/GithubIssues/ReposViewController.swift @@ -23,8 +23,6 @@ class ReposViewController: UIViewController { override func prepare(for segue: UIStoryboardSegue, sender: Any?) { } - - } extension ReposViewController { diff --git a/Podfile b/Podfile index 03f4ad6..3eb271a 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,7 @@ target 'GithubIssues' do pod 'AlamofireImage' pod 'SwiftyJSON' pod 'OAuthSwift', '~> 1.1.2' - + pod 'SwiftLint' target 'GithubIssuesTests' do inherit! :search_paths # Pods for testing