diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a8aee27 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +### πŸ“Œ κ°œμš” +- + +### πŸ’» μž‘μ—… λ‚΄μš© +- + +### πŸ–ΌοΈ μŠ€ν¬λ¦°μƒ· +||| +|---|---| +||| diff --git a/.github/Release-note.yml b/.github/Release-note.yml new file mode 100644 index 0000000..e9c7fa8 --- /dev/null +++ b/.github/Release-note.yml @@ -0,0 +1,51 @@ +# Author by chanhihi +# Date 2023.08.09 +# name-template: "v$NEXT_MINOR_VERSION 🦊" +# tag-template: "v$NEXT_MINOR_VERSION" + +name-template: "v$RESOLVED_VERSION 🦊" +tag-template: "v$RESOLVED_VERSION" +version-resolver: + major: + labels: + - "⚠️ Breaking changes" + minor: + labels: + - "✨ Enhancement" + patch: + labels: + - "βš’ Refactor" + - "🐞 Bug" + default: patch + +categories: + - title: "⚠️ Breaking changes" + labels: + - "⚠️ Breaking Change" + - title: "πŸš€ Features" + labels: + - "✨ Enhancement" + - "βš’ Refactor" + - "πŸ› Structure" + - title: "πŸ› Bug Fixes" + labels: + - "🐞 Bug" + - title: "πŸ“š Documentation" + labels: + - "πŸ“„ Documentation" + - title: "🎨 Style" + labels: + - "πŸ’„ UI/UX" + - title: "πŸ— Infrastructure" + labels: + - "🌐 DevOps" +exclude-labels: + - "πŸ’– Question" + - "β˜‚οΈ Umbrella" + +change-template: "- $TITLE (#$NUMBER)" +change-title-escapes: '\<*_&' + +template: | + ## Changes + $CHANGES diff --git a/.github/workflows/Deployment.yml b/.github/workflows/Deployment.yml new file mode 100644 index 0000000..49762e0 --- /dev/null +++ b/.github/workflows/Deployment.yml @@ -0,0 +1,98 @@ +# Author by chanhihi +# Date 2024.04.26 + +name: Deployment + +on: + pull_request: + branches: + - main + types: + - closed + +jobs: + build: + name: Deploy on macOS latest - Release for iOS + runs-on: macos-latest + env: + XCODE_VERSION: "15.2.0" + SWIFT_VERSION: "5.9.2" + RUBY_VERSION: "2.6.10" + TUIST_VERSION: "3.36.2" + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Ruby 2.6 + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + + - name: Set Xcode version + run: sudo xcode-select -s '/Applications/Xcode_15.2.0.app/Contents/Developer' + + - name: Setup Swift + uses: swift-actions/setup-swift@v1 + with: + swift-version: ${{ env.SWIFT_VERSION }} + + - name: .env + run: touch .env && + echo "APP_STORE_CONNECT_API_KEY_KEY_ID=${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}" >> .env && + echo "APP_STORE_CONNECT_API_KEY_ISSUER_ID=${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}" >> .env && + echo "APP_STORE_CONNECT_API_KEY_KEY=${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}" >> .env + + - name: Setting Master Key + run: | + echo "$MASTER_KEY" > Tuist/master.key + env: + MASTER_KEY: ${{secrets.MASTER_KEY}} + + - name: Mise + uses: jdx/mise-action@v2 + + - name: Install Tuist + run: mise install tuist@${{ env.TUIST_VERSION }} + + - name: Tuist version + run: mise use -g tuist@${{ env.TUIST_VERSION }} + + - name: Install Fastlane + run: brew install fastlane + + - name: Tuist clean + run: tuist clean + + - name: Tuist fetch + run: tuist fetch + + - name: Tuist Signing Decrypt + run: tuist signing decrypt + + - name: Set Keychain + run: fastlane set_keychain + env: + KEYCHAIN_NAME: ${{ secrets.KEYCHAIN_NAME }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: Generate Xcode project with Tuist + run: tuist generate + + - name: Fastlane run + run: fastlane tf + + - name: Tagging + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.CHANHIHI }} + + - name: Draft Release + id: draft_release + uses: release-drafter/release-drafter@v5 + with: + config-name: Release-note.yml + env: + GITHUB_TOKEN: ${{ secrets.CHANHIHI }} diff --git a/.gitignore b/.gitignore index 551a3f5..0d5c680 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ .DS_Store .AppleDouble .LSOverride +.env + +# Auth +*.key +*.p8 +*.p12 +*.cer # Icon must end with two Icon @@ -68,3 +75,5 @@ Derived/ ### Tuist managed dependencies ### Tuist/Dependencies + +.mise.toml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..46c791e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "iBox/Resources/Version"] + path = iBox/Resources/Version + url = https://github.com/42Box/versioning diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f0e202f --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "dotenv" +gem "fastlane" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8ffb27 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +gen: + tuist fetch + tuist generate + +clean: + tuist clean + +fclean: clean + find . -name "*.xcodeproj" -exec rm -rf {} \; + find . -name "*.xcworkspace" -exec rm -rf {} \; + +re: fclean gen + +tf: + tuist signing decrypt + tuist fetch + tuist generate + + fastlane tf \ No newline at end of file diff --git a/Project.swift b/Project.swift index d32396e..7e20388 100644 --- a/Project.swift +++ b/Project.swift @@ -14,15 +14,24 @@ protocol ProjectFactory { class iBoxFactory: ProjectFactory { let projectName: String = "iBox" let bundleId: String = "com.box42.iBox" + let iosVersion: String = "15.0" let dependencies: [TargetDependency] = [ + .external(name: "SnapKit"), + .external(name: "SwiftSoup"), + .external(name: "SkeletonView"), + .target(name: "iBoxShareExtension") + ] + + let iBoxShareExtensionDependencies: [TargetDependency] = [ .external(name: "SnapKit") ] - let infoPlist: [String: Plist.Value] = [ + private let appInfoPlist: [String: Plist.Value] = [ "ITSAppUsesNonExemptEncryption": false, + "CFBundleDisplayName": "iBox", "CFBundleName": "iBox", - "CFBundleShortVersionString": "1.2.1", + "CFBundleShortVersionString": "1.0.0", "CFBundleVersion": "1", "UILaunchStoryboardName": "LaunchScreen", "UIApplicationSceneManifest": [ @@ -36,23 +45,72 @@ class iBoxFactory: ProjectFactory { ] ] ], - "UIUserInterfaceStyle": "Light" + "CFBundleURLTypes": [ + [ + "CFBundleURLName": "com.url.iBox", + "CFBundleURLSchemes": ["iBox"], + "CFBundleTypeRole": "Editor" + ] + ], + "NSAppTransportSecurity": [ + "NSAllowsArbitraryLoadsInWebContent": true + ] ] - func generateTarget() -> [ProjectDescription.Target] {[ - Target( + private let shareExtensionInfoPlist: [String: Plist.Value] = [ + "CFBundleDisplayName": "iBox.Share", + "CFBundleShortVersionString": "1.0.0", + "CFBundleVersion": "1", + "NSExtension": [ + "NSExtensionAttributes": [ + "NSExtensionActivationRule": [ + "NSExtensionActivationSupportsWebPageWithMaxCount": 1, + "NSExtensionActivationSupportsWebURLWithMaxCount": 1, + "SUBQUERY": [ + "extensionItems": [ + "SUBQUERY": [ + "attachments": [ + "ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO 'public.data'": "TRUE" + ], + "@count": 1 + ] + ], + "@count": 1 + ] + ] + ], + "NSExtensionPointIdentifier": "com.apple.share-services", + "NSExtensionPrincipalClass": "$(PRODUCT_MODULE_NAME).CustomShareViewController" + ] + ] + + func generateTarget() -> [ProjectDescription.Target] { + let appTarget = Target( name: projectName, destinations: .iOS, product: .app, bundleId: bundleId, - deploymentTargets: .iOS("15.0"), - infoPlist: .extendingDefault(with: infoPlist), + deploymentTargets: .iOS(iosVersion), + infoPlist: .extendingDefault(with: appInfoPlist), sources: ["\(projectName)/Sources/**"], resources: "\(projectName)/Resources/**", dependencies: dependencies ) - ]} - + + let shareExtensionTarget = Target( + name: "\(projectName)ShareExtension", + destinations: .iOS, + product: .appExtension, + bundleId: "\(bundleId).ShareExtension", + deploymentTargets: .iOS(iosVersion), + infoPlist: .extendingDefault(with: shareExtensionInfoPlist), + sources: ["ShareExtension/Sources/**"], + resources: ["ShareExtension/Resources/**"], + dependencies: iBoxShareExtensionDependencies + ) + + return [appTarget, shareExtensionTarget] + } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..4628625 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +
+ Fox +
+ + +[appstore](https://apps.apple.com) + +
+ +# πŸ’β€β™€οΈπŸ’β€β™‚οΈ Introduction +μ•ˆλ…•ν•˜μ„Έμš”! 42Box iOS νŒ€μž…λ‹ˆλ‹€. 🦊 
+42BoxλŠ” μ—¬λŸ¬ μ›Ή 링크λ₯Ό μ‰½κ²Œ μ €μž₯ν•˜κ³  ν΄λ”λ³„λ‘œ 정리할 수 μžˆλŠ” μ•±μž…λ‹ˆλ‹€.Β 
+λ„μ„œκ΄€ μ„œλΉ„μŠ€, μΆœμž… 관리 λ“± 42μ„œμšΈ μƒν™œμ— ν•„μˆ˜μ μΈ μ„œλΉ„μŠ€λ“€μ„ κΈ°λ³Έ 폴더에 λͺ¨μ•„ μ œκ³΅ν•˜κ³  μžˆμ–΄μš”.Β 
+πŸ“’Β μ„œλΉ„μŠ€λ₯Ό μ›ν™œν•˜κ²Œ μ΄μš©ν•˜μ‹€ 수 μžˆλ„λ‘ 핡심 κΈ°λŠ₯을 μ†Œκ°œν•©λ‹ˆλ‹€. πŸ‘€Β 
+ + +### ⭐️ Key Function +1. κΈ°λ³Έ 42폴더 제곡 + + > 42μ„œμšΈ μ„œλΉ„μŠ€μ™€ κ΄€λ ¨λœ 링크λ₯Ό 사전 μ €μž₯ν•œ μ „μš© 폴더λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. + +2. 뢁마크 관리 + + > μ›Ή 링크λ₯Ό μ†μ‰½κ²Œ μ €μž₯ν•˜κ³  λ‚˜λ§Œμ˜ ν΄λ”λ‘œ 정리할 수 μžˆμŠ΅λ‹ˆλ‹€. + +3. 즐겨찾기 + + > κ°€μž₯ 자주 λ°©λ¬Έν•˜λŠ” μ›Ή 링크에 λΉ λ₯΄κ²Œ μ ‘κ·Όν•˜κΈ° μœ„ν•΄ 즐겨찾기 νƒ­μœΌλ‘œ μΆ”κ°€ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +4. 곡유 + + > μ™ΈλΆ€ μ•±μ—μ„œ μ›Ή 링크λ₯Ό κ³΅μœ ν•΄ 뢁마크λ₯Ό μ‰½κ²Œ μΆ”κ°€ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +5. 링크 미리보기 + + > 링크λ₯Ό μ™„μ „νžˆ μ—΄μ§€ μ•Šκ³ λ„ 뢁마크λ₯Ό 길게 눌러 미리 λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€. + +6. 제슀처둜 뢁마크 μΆ”κ°€ + + > λΈŒλΌμš°μ§•ν•˜λŠ” λ™μ•ˆ κ°„λ‹¨ν•œ 제슀처둜 ν˜„μž¬ μ›Ή 링크λ₯Ό 뢁마크둜 μ‰½κ²Œ μ €μž₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +7. ν…Œλ§ˆ 및 μ„€μ • + + > ν…Œλ§ˆμ™€ μ‹œμž‘ ν™”λ©΄ μ„€μ • λ“± 개인 λ§žμΆ€ν™” 섀정을 ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +### πŸ“· ScreenShot + +| 폴더 관리 | μ™ΈλΆ€ μ•±μ—μ„œ 곡유 | 제슀처둜 뢁마크 μΆ”κ°€ | +|:---:|:---:|:---:| +|image|image|image| + + +| 즐겨찾기 μ„€μ • | ν…Œλ§ˆ 및 μ„€μ • | +|:---:|:---:| +|image|image| + +
+
+ +# βš™οΈ Development Environment +![iOS badge](https://img.shields.io/badge/iOS-15.0+-silver?style=flat-square) +![iOS badge](https://img.shields.io/badge/Xcode-15.0+-blue?style=flat-square) + +### πŸ›  Skills & Tech Stack + +* UIKit +* Tuist +* Combine +* MVVM +* Webkit +* ShareExtension + + +### πŸ“š Library +| Name |Version | +| ----------------- | ------ | +| SnapKit | `5.0.1`| +| SwiftSoup | `2.7.1`| +| SkeletonView | `1.0.0`| + +
+
+ +# πŸ‘©πŸ»β€πŸ’»πŸ§‘πŸ»β€πŸ’» Contributor + + + + + + + + + + + + + + + +
κ΅¬μ§€μ—°κΉ€μ°¬ν¬μ΄μ§€ν˜„μ΅œμ’…μ›

jikoo


chanheki


jihyeole


jonchoi

diff --git a/ShareExtension/Resources/Media.xcassets/128.imageset/128 1.png b/ShareExtension/Resources/Media.xcassets/128.imageset/128 1.png new file mode 100644 index 0000000..077d76c Binary files /dev/null and b/ShareExtension/Resources/Media.xcassets/128.imageset/128 1.png differ diff --git a/ShareExtension/Resources/Media.xcassets/128.imageset/128.png b/ShareExtension/Resources/Media.xcassets/128.imageset/128.png new file mode 100644 index 0000000..077d76c Binary files /dev/null and b/ShareExtension/Resources/Media.xcassets/128.imageset/128.png differ diff --git a/ShareExtension/Resources/Media.xcassets/128.imageset/Contents.json b/ShareExtension/Resources/Media.xcassets/128.imageset/Contents.json new file mode 100644 index 0000000..8b99860 --- /dev/null +++ b/ShareExtension/Resources/Media.xcassets/128.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "128.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "128 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ShareExtension/Resources/Media.xcassets/Contents.json b/ShareExtension/Resources/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ShareExtension/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ShareExtension/Sources/Extension/ShareExtension+UIColor+Extension.swift b/ShareExtension/Sources/Extension/ShareExtension+UIColor+Extension.swift new file mode 100644 index 0000000..817e21e --- /dev/null +++ b/ShareExtension/Sources/Extension/ShareExtension+UIColor+Extension.swift @@ -0,0 +1,22 @@ +// +// ShareExtension+UIColor+Extension.swift +// iBoxShareExtension +// +// Created by Chan on 4/14/24. +// + +import UIKit + +extension UIColor { + + convenience init(hex: UInt, alpha: CGFloat = 1.0) { + self.init( + red: CGFloat((hex & 0xFF0000) >> 16) / 255.0, + green: CGFloat((hex & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(hex & 0x0000FF) / 255.0, + alpha: CGFloat(alpha) + ) + } + + static let box2 = UIColor(hex: 0xFF9548) +} diff --git a/ShareExtension/Sources/Extension/ShareExtension+UIbutton+Extension.swift b/ShareExtension/Sources/Extension/ShareExtension+UIbutton+Extension.swift new file mode 100644 index 0000000..466fe44 --- /dev/null +++ b/ShareExtension/Sources/Extension/ShareExtension+UIbutton+Extension.swift @@ -0,0 +1,22 @@ +// +// ShareExtension+UIbutton+Extension.swift +// iBoxShareExtension +// +// Created by Chan on 4/14/24. +// + +import UIKit + +extension UIButton { + func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + UIGraphicsBeginImageContext(CGSize(width: 1.0, height: 1.0)) + guard let context = UIGraphicsGetCurrentContext() else { return } + context.setFillColor(color.cgColor) + context.fill(CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)) + + let backgroundImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + setBackgroundImage(backgroundImage, for: state) + } +} diff --git a/ShareExtension/Sources/ShareViewController.swift b/ShareExtension/Sources/ShareViewController.swift new file mode 100644 index 0000000..e68ff61 --- /dev/null +++ b/ShareExtension/Sources/ShareViewController.swift @@ -0,0 +1,187 @@ +// +// ShareViewController.swift +// iBoxWebShareExtension +// +// Created by Chan on 2/8/24. +// + +import UIKit +import Social +import UniformTypeIdentifiers + +import SnapKit + +@objc(CustomShareViewController) +class CustomShareViewController: UIViewController { + + var dataURL: String? + var panelView = ShareExtensionPanelView() + var modalView: UIView = { + let modalview = UIView() + modalview.backgroundColor = .clear + return modalview + }() + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupProperty() + setupHierarchy() + setupLayout() + setupModal() + extractSharedURL() + } + + // MARK: - Setup Methods + + private func setupProperty() { + panelView.delegate = self + } + + private func setupHierarchy() { + view.addSubview(modalView) + modalView.addSubview(panelView) + } + + private func setupLayout() { + modalView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + + panelView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(30) + make.centerY.equalToSuperview().inset(20) + make.height.equalTo(140) + } + } + + private func setupModal() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap(_:))) + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + modalView.addGestureRecognizer(tapGesture) + } + + func hideExtensionWithCompletionHandler(completion: @escaping (Bool) -> Void) { + UIView.animate(withDuration: 0.3, animations: { + self.navigationController?.view.transform = CGAffineTransform(translationX: 0, y:self.navigationController!.view.frame.size.height) + }, completion: completion) + } + + func extractSharedURL() { + guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else { + print("No extension items found.") + return + } + + if let item = extensionContext?.inputItems.first as? NSExtensionItem { + for attachment in item.attachments ?? [] { + if attachment.hasItemConformingToTypeIdentifier("public.plain-text") { + attachment.loadItem(forTypeIdentifier: "public.plain-text", options: nil) { (data, error) in + DispatchQueue.main.async { + if let text = data as? String { + self.extractURL(fromText: text) + } else { + print("Error loading text: \(String(describing: error))") + } + } + } + } + } + } + + for attachment in extensionItem.attachments ?? [] { + if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (data, error) in + DispatchQueue.main.async { + if let url = data as? URL, error == nil { + self?.dataURL = url.absoluteString + print("Shared URL: \(url.absoluteString)") + } else { + print("Failed to retrieve URL: \(String(describing: error))") + } + } + } + break + } else { + print("Attachment does not conform to URL type.") + } + } + } + + private func extractURL(fromText text: String) { + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf8.count)) + + if let firstMatch = matches?.first, let range = Range(firstMatch.range, in: text), let url = URL(string: String(text[range])) { + print("Extracted URL: \(url)") + self.dataURL = url.absoluteString + } else { + print("No URL found in text") + } + } + + // MARK: IBAction + + @IBAction func cancel() { + self.hideExtensionWithCompletionHandler(completion: { _ in + self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + }) + } + + @objc func openURL(_ url: URL) -> Bool { + self.hideExtensionWithCompletionHandler(completion: { _ in + self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + }) + + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + return application.perform(#selector(openURL(_:)), with: url) != nil + } + responder = responder?.next + } + return false + } + + @objc func handleBackgroundTap(_ sender: UITapGestureRecognizer) { + let location = sender.location(in: self.view) + if !panelView.frame.contains(location) { + cancel() + } + } +} + +extension CustomShareViewController: ShareExtensionPanelViewDelegate { + + func didTapCancel() { + cancel() + } + + func didTapOpenApp() { + guard let sharedURL = dataURL else { + print("Share extension error") + return + } + + let urlString = "iBox://url?data=\(sharedURL)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + + if let openUrl = URL(string: urlString) { + if self.openURL(openUrl) { + print("iBox 앱이 μ„±κ³΅μ μœΌλ‘œ μ—΄λ ΈμŠ΅λ‹ˆλ‹€.") + } else { + print("iBox 앱을 μ—΄ 수 μ—†μŠ΅λ‹ˆλ‹€.") + } + } else { + print("url error") + // ν•΄λ‹Ή url은 μ‚¬μš©ν•  수 μ—†μŒμ„ λ³΄μ—¬μ£ΌλŠ” λ·°λ₯Ό λ§Œλ“€μ–΄μ•Όν•¨. + } + } +} + +extension CustomShareViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} diff --git a/ShareExtension/Sources/View/ShareExtensionPanelView.swift b/ShareExtension/Sources/View/ShareExtensionPanelView.swift new file mode 100644 index 0000000..c84f702 --- /dev/null +++ b/ShareExtension/Sources/View/ShareExtensionPanelView.swift @@ -0,0 +1,145 @@ +// +// BackGroundView.swift +// iBox +// +// Created by Chan on 2/19/24. +// + +import UIKit + +import SnapKit + +protocol ShareExtensionPanelViewDelegate: AnyObject { + func didTapCancel() + func didTapOpenApp() +} + +class ShareExtensionPanelView: UIView { + + // MARK: - Properties + weak var delegate: ShareExtensionPanelViewDelegate? + + // MARK: - UI Components + lazy var stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.distribution = .fillProportionally + stack.spacing = 10 + return stack + }() + + lazy var logoImageView: UIImageView = { + let logoImageView = UIImageView() + logoImageView.image = UIImage(named: "128") + logoImageView.contentMode = .scaleAspectFit + logoImageView.setContentHuggingPriority(.required, for: .horizontal) + logoImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + return logoImageView + }() + + lazy var label: UILabel = { + let label = UILabel() + label.text = "이 링크λ₯Ό iBox μ•±μ—μ„œ μ—¬μ‹œκ² μŠ΅λ‹ˆκΉŒ?" + label.font = .systemFont(ofSize: 15) + label.textColor = .label + label.numberOfLines = 3 + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + return label + }() + + lazy var cancelButton: UIButton = { + let button = UIButton() + button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + button.tintColor = .systemGray3 + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + return button + }() + + lazy var divider: UIView = { + let view = UIView() + view.backgroundColor = .lightGray + view.layer.opacity = 0.2 + return view + }() + + lazy var openAppButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "arrow.up.forward.square"), for: .normal) + button.setTitle("μ•±μœΌλ‘œ λ‹΄μ•„κ°€κΈ°", for: .normal) + button.setTitleColor(.label, for: .normal) + button.setBackgroundColor(.clear, for: .normal) + + button.setTitle("앱이 μ‹€ν–‰λ©λ‹ˆλ‹€", for: .highlighted) + button.setTitleColor(.darkGray, for: .highlighted) + button.setBackgroundColor(.box2, for: .highlighted) + button.setImage(UIImage(systemName: "heart.fill"), for: .highlighted) + + button.imageView?.contentMode = .scaleAspectFit + button.tintColor = .box2 + + let spacing: CGFloat = 5 + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: spacing) + button.titleEdgeInsets = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: 0) + + return button + }() + + // MARK: - Initializer + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + private func setupProperty() { + backgroundColor = .systemBackground + clipsToBounds = true + layer.cornerRadius = 15 + + cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + openAppButton.addTarget(self, action: #selector(openAppButtonTapped), for: .touchUpInside) + } + + private func setupHierarchy() { + addSubview(stackView) + stackView.addArrangedSubview(logoImageView) + stackView.addArrangedSubview(label) + stackView.addArrangedSubview(cancelButton) + + addSubview(divider) + addSubview(openAppButton) + } + + private func setupLayout() { + stackView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(20) + } + + divider.snp.makeConstraints { make in + make.top.equalTo(stackView.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview() + make.height.equalTo(1) + } + + openAppButton.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom) + make.width.leading.trailing.bottom.equalToSuperview() + } + } + + // MARK: - Action Functions + @objc func cancelButtonTapped() { + delegate?.didTapCancel() + } + + @objc func openAppButtonTapped() { + delegate?.didTapOpenApp() + } +} diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index b258631..0e66bfc 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -8,8 +8,11 @@ import ProjectDescription let spm = SwiftPackageManagerDependencies([ - .remote(url: "https://github.com/SnapKit/SnapKit.git", requirement: .upToNextMinor(from: "5.0.1")) -]) + .remote(url: "https://github.com/SnapKit/SnapKit.git", requirement: .upToNextMinor(from: "5.0.1")), + .remote(url: "https://github.com/scinfu/SwiftSoup.git", requirement: .upToNextMajor(from: "2.7.1")), + .remote(url: "https://github.com/Juanpe/SkeletonView.git", requirement: .upToNextMajor(from: "1.0.0")) +], productTypes: ["SnapKit": .framework, "SwiftSoup": .framework, "SkeletonView": .framework] +) let dependencies = Dependencies( swiftPackageManager: spm, diff --git a/Tuist/Signing/debug.cer.encrypted b/Tuist/Signing/debug.cer.encrypted new file mode 100644 index 0000000..30ca264 --- /dev/null +++ b/Tuist/Signing/debug.cer.encrypted @@ -0,0 +1 @@ +IIQiI4/q9SfKX3HQz+Il4A==-Zm/M7463yYLz0r3uVmfIOViANQzqd+o0Npp9PpW/mNLB8t26kilJbNnJFsvURmuT5rmcZ0LbUxnfK9c2vPuRRwqcTsznLL9SEW8CZHt4CH5pF07LrSvoNQqkfWiBCHO/4dLgIa6NoiVN8rOIsIQ9ghgYTgwItg427WTKX8cB7tiewG1oOUEtxJvj/ju4Aa+qV+xh3Vp96x0dJvPXO6DToJTEWwU1o3CNWk8j+ibLmPz/7UzOq3u6s1rGNjsmNyyoQ6jjQ3HyMtlaH18LfqwK2JKiMOny1hA4sL3B9sAkJl2r7cyxM7MLFMI/qg4kfNIHtYs5bTQ359uke29QwRnV7F6YcV+hfZSxMcxMUSkOvGaEk7Q4jhlAkDSt1YmvOC+pqs6E4LI8RktFPYd+Z2Kvb1aG2TOoRjSHLojJNKrlobOOIzmnlKesLj3YzfnfXfz6T5rssyRa9kfWrGkkdjSE4CbW0Tc/tt4t8zGp964b8fYaBh7iTFyKUhUuCZR9Rl+0Gd3aSwCETG+cjw/6IeGTF6FKlJygckhT0h17eI0IgxsRQoKjJnKj35Sqd8Etlp1b6dTsODi8FSAq7UM1OHOFcwBqMPqcpgSYkxzBuQ8cyH0AGs1H3UToWZ3KQ5Zdfc8lR0njnRHpqYpNdBJBClEXJbra80jtGGYRtoF51msE81j61HpKivYuG6G5MX/vf6He3R0u+fEXX0fcouwfGPR+rK6GcX3YbMKdHw0tZ01uODlp5WctdRfuiGNJU/VV34OmqmNygFbeXINfSqFgAyb06gXcQ6flmW01/skfdgt7CqM9lviK7mWrpwzk3qqMqPbxw+1KJ7/T56ct9u/kjsxx5GNG2hI/a3hVvZX8eZCyg1PV3WVbXP+Fw1C3RZeUvll0SwxsZzihlb3gC+dU7CXVdPkImDpC7EfJdJIzWF5NeTltvugAtpy0EYsGXlhzbk020rDonAO1vDxqh+2lTNKG0/00ISItDKZZloY3phXAQqxLidJ68gwmVN2P1SqD1Ynim0EvKqvWPba9HZI9vrsd6qBZtBibx3Dna5gp/fyGi8recNatuzyCXe8QjVpuRB0N3y9at0j9Fz37jY7K99G1h81XEpaWaLXXFeFTFluQsb23Q3MUdJsgpXEzjvmKgLak5ZFx1cQ8Js3TLOPUk9PgJBc5ghgkUcXsgWT2q4PvoKAO3r4Safng5vhTG4RapuxpUBWlRumCExPKsUQtM4vpwD5F8gsKlWahiziVO6fB7TcS83Wfdx44zCxu31Xn6zffedlUOqFNHEtpDOjjrMHBOfG2uXJG7Ff+jYUtE6Vsx/bx+FwCoPlQHedETMgGZx5XURuXBZ8VXv+z4kX9o9Ohu6RO8g2KY9DgLQhkM9XRJIJkW7lqVuG5CzA/+p5KfL9f84l5YbnMIheL9UkT427E8YXyaGkJiI26wv9dO2Og4inP+DMcsDJva0+ClcHWf8/9VnHWDPku3LTDQFSxaPhOPaobEtc5/kjjzDmCDn7ZE7hFQoiYtyXRKpmQ80Ez8Ji4IR0OAQl2Z2bcN+9rpBrCv0kyZJYGKK/o3QcdqZj5HNkRGBE4EOZiKvTniIY2Z7kNGxM5moZYqSWhU9/8pUtA9RUIX5E3mW4KGCJ2+uXaEl2b9H8ia+cQVUzzidoNXwWhGD2wlZPrKewTb8K+zapxsyUbWLrkvYAXRCvQunmVHmD+v4DaR/95J+8xjCxc/02nMUWAxmPJrptWTjs2MBlcTIGpQDMnFo9LJnOu4S2wbfHc9/cOGVTnX0i+3UaghRWDwpyB+/i9Ioy2sidLW2/R0yVJTa4cyDn0ECFNe3W/l26hd/nXQOCi+BSiRiE1x0SZVJ4NIJjhKvIeJRc7iiFe6UTcN9StH2Ik940qgiZEO2yKFVVCWwkfsz9pFzuw+pxP7viAnELn5C3xU3NT7nSw0GEd1d6h5P9yk22PxxFGEXj3UL5Ygx6HGg== \ No newline at end of file diff --git a/Tuist/Signing/debug.p12.encrypted b/Tuist/Signing/debug.p12.encrypted new file mode 100644 index 0000000..df2043e --- /dev/null +++ b/Tuist/Signing/debug.p12.encrypted @@ -0,0 +1 @@ +5OXOPxe34sNNBC4hMy9pxQ==-Kr6wbaB5ueQaIHZlZBV/sRaI/5uPrRtYBP3nlBjbOi5Ve/ogG85bQeCRJoONVcckTasaL6urhkJW7/3jLzq4vzK+deYA+fldHcon7NY2LUs/OYrn6fLthFsiZWGZwMaDNszINCbPJQTwUCu870gSJM927rs/q9tRnU8VGJTXthfC9vmvI6hHYiHFP7SM2eMFhGuClnvEYx99AZRBrfeWL319G0hXb6tDRQ8nfh1PQ9YUbs/C47InZl5aPiH2aGYqWwch0OD5+iwkta9n7YtbgMYCBH+lPHz8uvwshgzyvdevOfU4Z8ep1ALRYVVtd2X0H0EM86U4flB19mXu1c/lY/0nUcIVr7g24ydagTJqakq1QIKD6WxKCk2C+sgge3lZYPngbmWvHwmnEUuaeq4VU74KUFQw2FcZZWJhG81tDbHC0K5z+HYndShghFUxN9LPFxvrCOFO1nbB4jzrSSd8q8pqBkglM1bhGPAmb7/BUoaDaZtqI91V5wDnhfFuFZEXK3hzJwDirLHezBjCqFfUkYdtAL/6X9dW8Jakyqq5gF5zCX9nN61YK5ZmrTvixz4rDuOqbETTHJbOCpd5NfIWi/4VHIUHcrCrTHKEA1vtNVZnrQZLvjIiAyYOL1N5dBOmV8rqD6dVsi4g5qCgL2uzNVgIp9J0yBQuzeeHy7aaJXQcE0wTjwySMU/LnUlFlzUsyGTML8hLcn7FriVf8Zwpr3A70HtKN4LfL8F7vmJFp0iHDZxRVspii1PYfNkSMHFBEJyZx0TkpdwVLCgUFes8YTuIj3Cawfc7NNtviBf7Xy0iXCXmDdqIkJO8+9ELehnkdSdOb09EzzcajjEE5/faVL9BemcyMg0tNQFywONlKLKA0gN5N+IlBeoEukJSWT0izvOPSPETEwsi5hgBvStEh93CbHr9dkzfTQ8raITAvA58F8mfz7mMypSGLV9McxPHvWP7Dk/oWNKCuUjJ6GwyhU27Lb8oaD/uEpSQIHmc+3NLwQ7LbRI+m2kK4ZCSPRAuxhDLpEZW2bfg1ianSPePXZSBkv810P8SkbZeGyzUtx3f7/mcbtCcACvA44sn+m5bL5MDJsMPCVLGopBwCwUPmVEYrCtG0QpYv3yHgazUU4SqJZa8eVA+RsWrH0ZXM6fgEM57xBjH3qGDUwzMYrW4CqtWNdn7v5EGoCgPkTRQ4h2msVMNXPby2e8WeLA9RNAzBFliioykX23u1EyomXbVYxQOccoAnelyvVtwy2TGbznqT4ucNak0NoXMtlNu9ZZ9yjGr3LyFOhk89XeR0NoiUV7Kj6ATx7Q5lODigdmV+ImW0wqREqACJMQUdPbAZWMiivRwNMnpQduet4USo5qASV788F3uhggWZBlL4Tgmh79CveSbpasIdA/IOhHW9zV76/kAvDSyGx5ZKaegBRhroaUMUTysdsphVreNSteEMXCGADdUOYab5I8U196Kh8dzheXLH0LlAwu2+ZHRcGkYCZSDRlmQutSge1zWYJX51K5Nn8CalcTPijERDfjzOd+mr26ELev6DH/xrProeu5kH0w4sXlB5wIFD5t7P2bb+8Rrmx53qjJ5/zXmwVnHFfnhyEtTTF+spbPM6QveO+DMd//4JbNgccy/+EfAlxz1lNaSik1NV2tRwMKthP3gjlZCEzZHIoUfJF9sTEzEd5dcHNhPL8bcpY7G0klYZyxrkHkSo45QrHAg/Fr4UxQaaClIRYaJvjzI73+9K/i0pIcxNHOJ5QR3GrR12bXNAQ2Ey4hEhNgq1I/hygES8UeCPL2nZdcJuvdcx7nMrUGTEkm5v8TadTnv1hg3x+MKD0c/3gpn1GwuKWqW4eBgXX1DpN8C072OkeaKtqYWl7JfmZfV0iRJZoVJ2l/pAQ4XluVkVdegHSYn1YjbM8NQNUwWa6we7pChyb05Upr5kMCwrChHx6t/0kNd2CQO1gKq5oH2/j1gp0VD0XePWLftIgFGWEZwi/mLNlHHoKQxcRIMKh/KdvlrxmCQuNwhdAFhBfapKl3STjWG0DBMy1N+qxV3gTCMdOy25UfBL7Zllvxy94xoy1S1GkMnfq2UZHeQMFE8xqZbQ6p5WtE08ClTLGU5+1hgj3pdPBJsUGtQJg3Qnfsta2iSyJB/ivfqN49yAUgozOMPWfiaxy27VOeJWsmuWns3HTn+zjsUeyYFbR3y4kIbsJTtqCuY+Mcfax05jgJt/QuYVftXIrdvk6wQxQc+Uzvf+Yj+CXAuBJzvBf4Tj6gQu3GpMlX/Fe7Ab9Rn+o8N6esp2zDpy9klJEnnCP4SyR3+VgjXHjbi2U1/pT5pKfQJ10v0sqNNivK1fqlvxDY2ymqDtYWl/H8ppAxCG0BcZ6si7gADF0o7rWb3Z8SMQmKtjyIeyoFwswn+41BTQD2hUfKmMc22f2px/PxKSmvcbCu7hJbG0B3tP5KJF9U1kPlSU8ExTTeX2XRdhK010/3m1Q6WfWwyiJ7t4FTs/loU6lR9wkKjrUpyT3GnNrcFulfomXC4AGVEFi5tjEYiOLemuBuFO4w0aRb+QrHZ4tYsKawYM4qpD27Q5A5rSz+lPg6fNNWhaIKaEoIasI3MD7zUxVS6DX4FNBqFat+ikvCHb5vkM9OEL4YCf5NiDXAdUxh1LzSjR0EMJIuN0j8VHSpEJMqmX4Y4rXYtPwuA4qn7xYYV9bPEMCasG9FKeVs0vl8ATqxvYGEgZtf2qoUdcwNKOAJ2RwJYzdSZuwNFLfPImL12WDxr7uuuv7jtmXIxinNgm1mh0C9lBM+gNaLEkswJJb5kj/e9engopLMXG4RNscdP1Vg7UluL3JmRQUCftEAsLWAtKNcti3HkfAc9VueGAybqFc0mW8orM5UQV8H4k++0Rb0dJeYVkbiPgsH81Aqg9u/81vduW1T6ub7/5IIF5bXKNzyC2eVD6U9NveoTL/7eVHuVG2AA5ojCK7QOEPSHIs6dMIHuQnXA+y3JU4YbueZwBBgjANsMVAYY3LW/8xtGNjVYQ9Et8xS+8ZfQet9Chg4ESp2s1g8hDrLGdAr7p5Vfk3umioNLKha7+TUHqMCgzk4eEElF/sMUpyq/jxdJqvSa+6bkR8vRBQGu0OK5xyxWEk/X/TZBjll+aHuViw4Rxkifk6oktOxjvGzJ1USR00A+wLEbn3sVjHEIdiUntXyh44f93QF1QUh+fJmzcUGyVexyectGdQZcbUVORoeaxI7J/m7LK4ZF2QqINoPi4GfRYLOwHAJlvo7fNyPsh/JdIoJdchDKlCRnIVTsC7lKjc3ljZQlvREZVapeDlN55iB9i0IwRW8I88Cpl69j86VOZNOdgOfryivkJV6Ux/9nTdYb+h2MI4B1qKggHnZH+A85La79vOgSvkMdultSYtLzMk47SW+Vu9M8tY+PBmBpa57JQxxDw1hgKWgIEuNymkulvlTb9M/aSQ/wvgdFQye4pgneSjb3zx9DrfghzggKp5+5IkrE+cnq552xblQJy8BHbWH6WsHvpEDwFuuM7W6//2D4+hj7d81m/2M1z9BYiIb4Au6FVBmJuw6DRxCHhqGhiJFMBEcHTK58RlE6ZUC8llnahbGVRDc/YpNyLU8l1o10R7is2xpxnNYd0rpnElJzOzo49ux7RhwMRviOVl7ViUVjARK1XddAB0ibkziZ0uEijR2MSXAZwN4YIj2YDE98Yz3izF5+/GTbDDMn22lscoGbQQs/pEsQ8S91bY2pOqZmEoeEkIxwHRpNOY65tmvnC8jhr11jGnj3HOjTk+jcrEBcjdQusAbWK/NXPuIFLHm6duthmQnfBYp7QLm38uiOB3TioNfLt6Q+rbTyCJ8GC7R7gvC5fwccjBeL/o6j+NggT56Am7FAK5pVivCS9xXrnVve5EjksmErFuEjD91GTrbqrLxBUOQ3rml3TTjSeTqPKxqWF7sRVTkIRCCePFl6OkBXbvN3OrQU+1iwGfbvdhIFKDbbBypMd5U+Nk6eTChuhtWGDmtvqfN5DrSSnMro0PNWC6scEpD6IwS3pdg7Tr5FGcDyVJQWJxmdHK0LgjNPYXAExvEq5TZyj0qi5bjUq56AAfmBCzkgJ//wHEhd48l+7/22QpmpXAXqPLfmIp+b4EDVQG9s+FNqcQt5azdWRwSB1SPt+LcuhcOPk/9TvUQ7jCwnzvhRRrrJh4xznhcPMxaALVP44YfSALyDtuHt3eo2TVC3MYbZ7n5MNHiN6yn82/9JtQz3I8FdO1OtHoqHwjEAEXfHA/43wxuSFY85vFD60+AH9wNNLymzqkpsiKGYO5PKbmUMsiehoYAtCPdghnHvm9vpID2LA60jcq6mSW7BE/eNLrz0fHnk5fZHlYTR+UXdXIO38eWkEMLp8XHdPNltn8nwBSpSmqgW \ No newline at end of file diff --git a/Tuist/Signing/iBox.Debug.mobileprovision b/Tuist/Signing/iBox.Debug.mobileprovision new file mode 100644 index 0000000..e40fd7b Binary files /dev/null and b/Tuist/Signing/iBox.Debug.mobileprovision differ diff --git a/Tuist/Signing/iBox.Release.mobileprovision b/Tuist/Signing/iBox.Release.mobileprovision new file mode 100644 index 0000000..3c12147 Binary files /dev/null and b/Tuist/Signing/iBox.Release.mobileprovision differ diff --git a/Tuist/Signing/iBoxShareExtension.Debug.mobileprovision b/Tuist/Signing/iBoxShareExtension.Debug.mobileprovision new file mode 100644 index 0000000..5e6c2c4 Binary files /dev/null and b/Tuist/Signing/iBoxShareExtension.Debug.mobileprovision differ diff --git a/Tuist/Signing/iBoxShareExtension.Release.mobileprovision b/Tuist/Signing/iBoxShareExtension.Release.mobileprovision new file mode 100644 index 0000000..b9d8815 Binary files /dev/null and b/Tuist/Signing/iBoxShareExtension.Release.mobileprovision differ diff --git a/Tuist/Signing/release.cer.encrypted b/Tuist/Signing/release.cer.encrypted new file mode 100644 index 0000000..e0fbb52 --- /dev/null +++ b/Tuist/Signing/release.cer.encrypted @@ -0,0 +1 @@ +/iI32Po39nIz5A4SRjThfg==-oKHwtQwnz0SLBJKPqimkZiYB6LocREJzbdXO3b9ibQhPPj0DvP1WWjG+DPDqVvX6AN43+ab6IoyNcHZ4uGIzzFYaOemQ2qmiaHjgKXpkGNNjoxQVN5Ka1wJ6NiAqUICUPIuLjiz1GUWOyMAPUnSSP2dj6wG8PpfsjalWFoTwyBUQhXgSryqL72zGGl2qtJgV42PVF9VNkGPhGP3zSdaSXbmWKJI16ijL4UH4xqDs3yPo1aULNoXySS+kYLBSqSFUUUDkqPeYh13ajCtVJJ9kc+SneR4pE0K54jHFBC/Hgl+77nmdfzCiFqMqvZI9bki7AMdFvqBXjG1ENel5V/V3My9i3+7oxURYKrLjAGuIo+99ba9Ot8xwgHAI0b+xfnQ1ztdYBZzmgNuWGT2PCK4RQl+5Sw0VUtEvauvQa9yBecvBEIHb5xwHtzyto4LEqhydeV7z9UPP1grflDqE6t4KG4K4QDbvGOOFITjXT+KzE++OrA1f66nC/0LgYz+Jvsnu9sy8smQjfOKzOrTNw7dsqZ2SOr0rGLPVYjbAqiVFelkv5EcS+XT9PH5BRzc7IkbR0An1hC+ydRqyVkmDQZ3MKL2r0wFABZ3Gmnxg/Rt/3v+QeUdVDl7a/her2SXKsR1McXdNdJ3PffvjdgtOltWnoCHDsx1YTrGTBz5trRJb8T50I5KtFfZNDZ2H7nuqFwzCs/xfmR8t7EK+XqL/SOT5vNYQaZCZMiNJPOijku2qG6YZn1Fvrq3pYNtYqaodgxD4ce5C0Y2m8OcWlh6ExSZYPBL+G1ubK70qhzdsg1X3KXU9bztGXDucUfPFwuJ5LwrBGlmeVdc62FNK3JPeKa0kWRGBEL3z9RX16S6blX5UI1g29iR96w8xlS4iPLWyWAg6SyWiZePaL7m0XhYzletj6k+h07/ZMYwrLtU+FQAJXjdfx6zAoz244eckkTzaMnr/IANtj8VvvP1NaPRnQZlSniWbx4a4vgssWdKw6EK8A00xc9uIFSjfmrqoI/MwwdsNeryrfEztqIHAZ+IQei5UiIuiatrGIHLvJe15PfbwOebVHMgK8Q1zpSsDrWZOIGMV3QipXzPSBzj7xBJ/Jyn1Ch13omwTm4hLD3g11BFIanVXeSAJc2GPBY1dV+yayrKRA5xBe83s/iLgE8wSCYxuRwzCA8kNLOzXdz19xpUqiL4jVL2sp1suiygpgcI/gv5s9HK6Mx+n6Ox7n8W9WXjwS+s0UueDJcLuAaXV2awUcm2ER7yXxU6/OVKO/2IUMr/wUV5gu46ua8r+RUYtDcS6eUak6NunabQTn/14Cp4MXC0BdZoNzHBtwffdE1XET2wEE5dnFrxv3bF097TaWMDElYOVBks0aSK2E26rb0I4BELA0qrA7vUloDhX0cSgRP0r/S+/CWFHh/0wNGIs3Bjb2PhFB3Z//wHXNac69E9VktgEDd4f0pZWaFFW/v+MdMTYezHbKciRebnmVvzJmoHCtoWQZwVdIOegtwipq+qAwKPbvBFnomesh210KnouW6ttKZNhUchEywRpISRmW6l5zmh/ediymvzdCRJFBg8a5nINNXNbVA6Q8TCjLbaimdtgJQ4ydpHqf1BGDC6SF54lMAoV5VzbLVTd/XvEAPwOzk+Vo4IAKWJUDLL8hUIZlNzDF6PK4xa9BbA3uNaV/QjctfJn6FnaHeQrbKv4NWYFGOTs8S5OnCbRcvWkmeCOehOkStytZE0vCu6M0CeNOh7dNSXJjWtYex7j+eCPCdH4q9aUH4IETpZu1fhVkdlmsJsMwZSSU9gWZlE53aLNgebPQe2C2IQwCL3s1ww75qtSIwfuQCQrjgfTTtogm1g9qxyNi2xDeqgKK9kfHLvTaKgVq7KWrTKsSElEbMINsveicHhTVsC2m+YpeSYS/XsNiJxVgNTb1ADhb6Mb+QGS05/nGR9gaLc4o9LtJc0Pq4SFnHZM5G3JG/GvNV8= \ No newline at end of file diff --git a/Tuist/Signing/release.p12.encrypted b/Tuist/Signing/release.p12.encrypted new file mode 100644 index 0000000..d875ef4 --- /dev/null +++ b/Tuist/Signing/release.p12.encrypted @@ -0,0 +1 @@ +mAAi3BCoeYUJm5Kl56ye4g==-9rSwPh46VlUcG3oarUGvTzrwCuDxv6qidrecyIJgIc1wtnm4zG5DD3QHgGfnxQpqnxniVt8TS8VNMk0mzG6I4rmWWwG3XPolvFjeb3FWSpWBWsyvkhO+S0vGMLNp07UYK1n1WJ0vaeDoVVKekvt924q1W/kfjRFaofUaQi1dj2zOyUproDNi6bUDeXkbsooh8y6xFh4FJ/wFDdUF0wT1dm71BbROFmGlJKiKiH/foxVGxTlj1Fvx0Atc/+OTpG37MF94irLa0+MmIkAsyCViTWe0dctLc04yfpMNTT93TI/vTtqiawRLChTruDTveOiEjM3mVDvbgVR5SPDEHk3JctGBbu4KN2vCLcESqQorBHaPl9y7vwO7WWFVKP6qmo0pKv//F0sQoDKj17x1DP1EyvGpFmyVqFpGJBsee/CaH0bugnidc2dxDckHOyqv1YlOxJyhiAbS3QZUVopy//g+E6CsKJg21ONC1UA1Sjp2yQ5J9bQ7jLvzkqguj6IFAUiAJ46wu+kF1BNV6reOETjE/gImiKUAfp4HMwXPiepCcoP3WftSm2KowVWje4oW+dgK5ppcT9Rxa7w5yA4uSV4smIdKJT51Wo1Viex3tAXWkhf2lth3ASYXYc/AhoHR3jXuZyiqs00vaWJpVmQsqwsV0Kp6CD4/sy3td0JJhmtXqH11Dleye1PEi4JrnfYSiVKm78VTnoXx26Gm9y4U7Y2uwsWT8RLAa57r/VAekuEuA+j/s7qqTpTyLAvGG9Xee68pgGkbBlqf1KUZdD9PkF0OI0C11Tdwwo3by/wy3vaW56IyLB+MvXegRkVVtdzjGq8srtq2seESDk1ZVncbZUDZBHV116aC/UvFZz6PTiWONM8LkVq+VqLgYz1NEuX2fJTy1e0lG55OhCIjYsAzavqLdC+x9xH83QK02YqFAf2xpIeguSO07Edcp85tsMSQKNd3oBnn04313UryF54nkYM5K4mXZKkrg4+dBaVtyu1HfPkYXB/thXD/dm4+uiHoSrFAV/6L/TOCQrpXoGJm8wfyNIv9Galh0LMjFKVhhKTeF4/FZ5+fD6e2BzfDrZJbPAJpiezwHlvx1WfSKB6cY5LI3we3q1qlRfZ74fA/8Xp31q6F6LADbW8UYcRMKaO+bPsVetxggvs1GEjiemGe3m/I99Z3qMpYtP1zl0R2pA3a5puoKwLF0f+sSZGjsx++e0EGEzB4AwvF4ZdBGE5OAc7E++obrwuGCKjSCY9wjHAxyXY+kxaN/E9hFprDWp/FiMbh01ECpy051JdX5JS0ay0/gPaJDtR/upH/1dzERzYbH9bCvcxXAAOAV2FRWX01JMVqLeaN+B4+mhuy+4MbcEhbr4H9sHcWLrUXOJMFOYJ7mZqCX7cuY1zQhiGKQc1XPhoXnfxYoeX/2CZrT2dNcL2OIR6eCpd0Eqsy81cIzDcvHkqYTYm9QE+LeAIJzab9EkLIk/gB1s8GjY80SZPofWnFNdY2M0UPbttVzZmkzTq7lOib2O8gCb6Hx6SyZ6APfmDdWcJzc760jzn4BV3zy/0HDxQyxX9+Y95AsELXMF36grk/2LM42hP0rmsxVO7Zanf2orcM3cLxzX8yfNLO/y3Wy/d5v6EecAaV/EM/tbq7uCHgmHOXaXsCog2AiuEFSE/twlLQH+472UACpTs7dOMt1CxSbf43RSAeg6e4M4Y/fM8x/ufRzZ+Abj4i4fvqsnHdO2LXSjW3BWJTYXXihjDH5VOXWblw+KbPBhpHf3Vq9+0VtU8L6IoX/mvI+jif5NPrs8KibP3rUM0dbfY005xdV7VHWbPD3cN6OxI68c+JZjMMTGWosOrwNp9a5ERJ3WlkrIQtY4XCvCUjZ3xVThiO5JdwB8tijwRsiUkdmWJwJOPHz75ON4rQkPrcRl1zlrmgXGDF2EAw98gN5mKXVNMUmuxhHH9aRT/GPVvqPgzPG6h2IC9WQaWvb4ta7YYRu0k3sQuZhSAfxRTsCMg0rUo272xaZhrGVTMgdt9yj8gBsvrkVh87XOv7KDgksusC4QJjYDDgYR5KwkaxD2CVu90PJDqIFOg9+BkzksDH7Ls0zq8tVLQfBvbENnXy7VYwSvobxLtSimscXQbH6TcaJVaTI6W7UzzSOdV6DiDYan3NncFE/DacUP8Z1GNpsbPKUOlyCfEPBQWBKLuY+IdArT+kxbjwR74NMEgPVUcIO+50OHId3VMwIVzQ2DjY29t4HrXMpvN2HQK3tstvKstkIv/piHg0MGCSr/i9Enj6V/KSoJ/NzGulPlVXJG8n1R4pDgF3pQmJE09P87p418oLehCCanuojoRwQSkWGpNBHvjlceUNmXCQbxW0aCPKxn3TLj9aJLYYUIoWxNT6m+viNbCdaIeGwm6S/PLTrfePESuPMGc/EDmsTXkuQ65edhtxlKFlqTdhyGpkrNfK70fvo7DUhYZimWvIR2cjC5CA28PqqpCRFj6R4bQ1+dJTId0kbvDh1J7LYTIfXeOblt7J8v0lv0izUmHGMDLtZtf8IBCci9Dd+b4i68Mhtoi7suO/lHm6JeaB1UkyhhbUja7jh8q1tpkms7O6D/HfMhr63NLwjL6hKu6GW+pFO7mQr3pPIR94jHaAYIssddfEQgZmu7jxYeuf9eC9hkk9wJIzBZ+rDAjed2qevRygaA2NwSIRtoOLKnjRUd2fb8JttTYZ4gNMr+tOtWc0EekKP6m5SJaBZ31gNXSFMYrq9Xp5/3uj4MD/pY0600M330SitU7G4qZTzCmV8w8AuAX2a4YRXyPKLq+IVppwBbGhf20fjnaYB4njnMlOgHq5ImkBAt6IuVUZ5CCeHxOxkHqbzZQCWSZRCXR4G9MVszrv/gWio13XVcqQxNeI0zitWAoq5/MZXgRXEbiCVDQZ+Aa4EqjKNP3IOq7hraK1jPxpDo2JTprAqS6Lg7BURfFWixrdcbmYG6i3ZjpmXd12+Ob3zfEh07fVOo1AZCmQsVLBkC+mSgSMIk83fBRW2YrAhBjA8t1BXkKb9cJPZ7vZnPmohERagm3YVFOaauPXKc/OB2wkHLiqYgcLA4o6UK43WCK3ds09nHvqn2TgtjUh/hv9Th1HRvHWB78nJmPyuyq2czC1uvqeOwHiGMvTVaFOOMR8Tb27vyeULG/sVdoKIMKltUT0/H2kx2w7syjw4CvYeda77OBvqgAipAvkP+zOjmtnIi8chBqH7528gs/73Oa/Er3nT9UWfYLyMMrgNZsmU1S6xiYFM3gnb4u4PMDiOjUrAJsXfbh9jalBshdeh8R7+7j5et7xt+xV5pUh+r0rubQ5e0Cz+JyDdRR7VsJMQjrZ+vmPXYUQz3gYO8NO4I3nUl2W8zv3jDGcjU53edoWCu2zLdHCi0buhGdoYsl5Oys4aWZcqzY3tk1QLJDVKGju8APFmTjK7d7rVVSJC9hLaNegmD/60h0m7Sl6ZW4NvmntCl78XkXNss0k1aw8zQLC0MwTgxTOYEhghk+53BNTla4zTfA9ejQfxHLR8LBxHG/U3+yIp30D9zHry9c8Lcp5tN6oLGknIgEP5/ojIQhvMnUnJSDg3pYG2ewqfbqLmQrf7EVFdsXMqNCHFnheftZgzJP+soddfV31vYHGHgmgx9siHZ9GtGgtaBwGHmOiVlC2za2faVR1ZCF47hhBD5yLSmH7MyXcZQ84/LHgeUNiUmtU/22nGnyubVaEw68RR7cIH9nr2XonuAAwhhfrdlgYQLcSHm3/AGauKwPq+3eJCQQ9qUmOqzOnObsccCirAOgJsVAAtVrYZAAFd8W/KwGYRYIqhiLMzNEWIFe3NldRHPcjDV+PUI2Mw+he6ovCaSgp9CRwFNGK/PixGekyYhMK5M6yqAEW9IPxz1Fv4PIIL43sGJLm3UKcaAVxk8RQC4U7vBqE4/++J20VjQBBpd40AWO1VSmMTi4TwURvp1rsaqMaPCJvF+QKtH3nAe+ZLOZylE5M5D068u8kOXvvVv2Uj9FdlJdgrT053c+cv6u5foHlHVayLIuYROkLiNHNzRm2088VtvhKvviVL5LiiHqSL4RaLT/lTvwPOHLry8o0DYldHM1nmQRgCG9ZVXQNmbJgcaA8fXJ5g0jk1viwJo2tkgNTinjvz0iusFq51MrgdSsj/hZptzdSm1qtozdvDCikLiuEn3jEO1M8WXnxZGKYVOYksIbMlHIxdLSKgF5Py1N81pPDyZyzMXv6dfczuhiEjOWyPQ0jKBNm4YJ0Xbq8p4SD8SqjyrhJLWIL+hCV8okwkfJg+2GaJ0XtsIQYZE3248PdgRwnn9rS/iGmtJpyW9NoeAeR2AFiLI9B22L4l7zws0DVocvdV3sh \ No newline at end of file diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..ead9487 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,8 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile + +app_identifier("com.box42.iBox") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..1c196f1 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,73 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +# Constants +APP_NAME = "iBox" +SCHEME = "iBox" +BUNDLE_ID = "com.box42.iBox" + +KEYCHAIN_NAME = ENV["KEYCHAIN_NAME"] +KEYCHAIN_PASSWORD = ENV["KEYCHAIN_PASSWORD"] + +default_platform(:ios) + +platform :ios do + # Keychain + desc "Save To Keychain" + lane :set_keychain do |options| + create_keychain( + name: "#{KEYCHAIN_NAME}", + password: "#{KEYCHAIN_PASSWORD}", + default_keychain: true, + unlock: true, + timeout: 3600, + lock_when_sleeps: true + ) + + import_certificate( + certificate_path: "Tuist/Signing/release.cer", + keychain_name: "#{KEYCHAIN_NAME}", + keychain_password: "#{KEYCHAIN_PASSWORD}" + ) + + import_certificate( + certificate_path: "Tuist/Signing/release.p12", + keychain_name: "#{KEYCHAIN_NAME}", + keychain_password: "#{KEYCHAIN_PASSWORD}" + ) + + install_provisioning_profile(path: "Tuist/Signing/#{APP_NAME}.Release.mobileprovision") + end + + # Upload TestFlight + desc "Push to TestFlight" + lane :tf do |options| + # AppStore Connect API key + app_store_connect_api_key(is_key_content_base64: true, in_house: false) + + # BuildNumber Up + increment_build_number({ build_number: latest_testflight_build_number() + 1 }) + + # Build App + build_app( + workspace: "#{APP_NAME}.xcworkspace", + scheme: "#{SCHEME}", + export_method: "app-store" + ) + + # Upload to TestFlight + upload_to_testflight(skip_waiting_for_build_processing: true) + end +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..5bde376 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,40 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios set_keychain + +```sh +[bundle exec] fastlane ios set_keychain +``` + +Save To Keychain + +### ios tf + +```sh +[bundle exec] fastlane ios tf +``` + +Push to TestFlight + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/report.xml b/fastlane/report.xml new file mode 100644 index 0000000..7abd9a5 --- /dev/null +++ b/fastlane/report.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/Contents.json new file mode 100644 index 0000000..de4782f --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox0.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/fox0.png b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/fox0.png new file mode 100644 index 0000000..0892874 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page0.imageset/fox0.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/Contents.json new file mode 100644 index 0000000..3efa561 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/fox1.png b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/fox1.png new file mode 100644 index 0000000..14ede17 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page1.imageset/fox1.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/Contents.json new file mode 100644 index 0000000..9e46b91 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/fox2.png b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/fox2.png new file mode 100644 index 0000000..2120ecd Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page2.imageset/fox2.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/Contents.json new file mode 100644 index 0000000..ea3a111 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox3.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/fox3.png b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/fox3.png new file mode 100644 index 0000000..e15e2be Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page3.imageset/fox3.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/Contents.json new file mode 100644 index 0000000..a772154 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox4.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/fox4.png b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/fox4.png new file mode 100644 index 0000000..352f185 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/fox/fox_page4.imageset/fox4.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/Contents.json new file mode 100644 index 0000000..9190e47 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sitting_fox0.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/sitting_fox0.png b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/sitting_fox0.png new file mode 100644 index 0000000..61410e0 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox0.imageset/sitting_fox0.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/Contents.json new file mode 100644 index 0000000..688a821 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sitting_fox1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/sitting_fox1.png b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/sitting_fox1.png new file mode 100644 index 0000000..4ea17f8 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox1.imageset/sitting_fox1.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/Contents.json new file mode 100644 index 0000000..9321c41 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sitting_fox2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/sitting_fox2.png b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/sitting_fox2.png new file mode 100644 index 0000000..050be9b Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox2.imageset/sitting_fox2.png differ diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/Contents.json b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/Contents.json new file mode 100644 index 0000000..5acd3eb --- /dev/null +++ b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sitting_fox3.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/sitting_fox3.png b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/sitting_fox3.png new file mode 100644 index 0000000..4ea17f8 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/42pack_icon/sitting_fox/sitting_fox3.imageset/sitting_fox3.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..7b88f33 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..a738dda Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..3051489 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..6534722 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..209db66 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..1d499a1 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..c937d4e Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..5c3c88b Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..24d309e Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..a1866dd Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..ff1d8bd Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..edd2f93 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..3056ce6 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..6ea7e65 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..be3cc60 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..aac3de7 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..4ae12b2 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..3e16efc Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..1bee41f Binary files /dev/null and b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..4fdf882 100644 --- a/iBox/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iBox/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,8 +1,153 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", "size" : "1024x1024" } ], diff --git a/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/Contents.json b/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/Contents.json new file mode 100644 index 0000000..ed1ff73 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "LaunchIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/LaunchIcon.png b/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/LaunchIcon.png new file mode 100644 index 0000000..d17824a Binary files /dev/null and b/iBox/Resources/Assets.xcassets/LaunchIcon.imageset/LaunchIcon.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/1024.png b/iBox/Resources/Assets.xcassets/Logo/1024.png new file mode 100644 index 0000000..83ffffa Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/1024.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/128.imageset/128 1.png b/iBox/Resources/Assets.xcassets/Logo/128.imageset/128 1.png new file mode 100644 index 0000000..077d76c Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/128.imageset/128 1.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/128.imageset/128.png b/iBox/Resources/Assets.xcassets/Logo/128.imageset/128.png new file mode 100644 index 0000000..077d76c Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/128.imageset/128.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/128.imageset/Contents.json b/iBox/Resources/Assets.xcassets/Logo/128.imageset/Contents.json new file mode 100644 index 0000000..8b99860 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/Logo/128.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "128.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "128 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/Logo/128.png b/iBox/Resources/Assets.xcassets/Logo/128.png new file mode 100644 index 0000000..077d76c Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/128.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/256.png b/iBox/Resources/Assets.xcassets/Logo/256.png new file mode 100644 index 0000000..fd64f79 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/256.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/32.png b/iBox/Resources/Assets.xcassets/Logo/32.png new file mode 100644 index 0000000..7a15387 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/32.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/512.png b/iBox/Resources/Assets.xcassets/Logo/512.png new file mode 100644 index 0000000..2d39060 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/512.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/64.png b/iBox/Resources/Assets.xcassets/Logo/64.png new file mode 100644 index 0000000..ad70cb5 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/Logo/64.png differ diff --git a/iBox/Resources/Assets.xcassets/Logo/Contents.json b/iBox/Resources/Assets.xcassets/Logo/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/Logo/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/appstore.imageset/Contents.json b/iBox/Resources/Assets.xcassets/appstore.imageset/Contents.json new file mode 100644 index 0000000..a0b3a22 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/appstore.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "appstore.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/appstore.imageset/appstore.png b/iBox/Resources/Assets.xcassets/appstore.imageset/appstore.png new file mode 100644 index 0000000..a738dda Binary files /dev/null and b/iBox/Resources/Assets.xcassets/appstore.imageset/appstore.png differ diff --git a/iBox/Resources/Assets.xcassets/playstore.imageset/Contents.json b/iBox/Resources/Assets.xcassets/playstore.imageset/Contents.json new file mode 100644 index 0000000..861f456 --- /dev/null +++ b/iBox/Resources/Assets.xcassets/playstore.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "playstore.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iBox/Resources/Assets.xcassets/playstore.imageset/playstore.png b/iBox/Resources/Assets.xcassets/playstore.imageset/playstore.png new file mode 100644 index 0000000..77b8432 Binary files /dev/null and b/iBox/Resources/Assets.xcassets/playstore.imageset/playstore.png differ diff --git a/iBox/Resources/Version b/iBox/Resources/Version new file mode 160000 index 0000000..57b2e79 --- /dev/null +++ b/iBox/Resources/Version @@ -0,0 +1 @@ +Subproject commit 57b2e79219b13e129aae1521374580fff722defd diff --git a/iBox/Resources/iBox.xcdatamodeld/iBox.xcdatamodel/contents b/iBox/Resources/iBox.xcdatamodeld/iBox.xcdatamodel/contents index 50d2514..16ddc4f 100644 --- a/iBox/Resources/iBox.xcdatamodeld/iBox.xcdatamodel/contents +++ b/iBox/Resources/iBox.xcdatamodeld/iBox.xcdatamodel/contents @@ -1,4 +1,16 @@ - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iBox/Sources/AddBookmark/AddBookmarkView.swift b/iBox/Sources/AddBookmark/AddBookmarkView.swift new file mode 100644 index 0000000..e8d7d99 --- /dev/null +++ b/iBox/Sources/AddBookmark/AddBookmarkView.swift @@ -0,0 +1,312 @@ +// +// AddBookmarkView.swift +// iBox +// +// Created by jiyeon on 1/5/24. +// + +import UIKit +import Combine + +import SkeletonView +import SnapKit + +class AddBookmarkView: UIView { + + var cancellables = Set() + + var onButtonTapped: (() -> Void)? + var onTextChange: ((Bool) -> Void)? + + var selectedFolderName: String? { + didSet { + selectedFolderLabel.text = selectedFolderName + } + } + + // MARK: - UI Components + + private let textFieldView: UIView = UIView().then { + $0.backgroundColor = UIColor.backgroundColor + $0.layer.cornerRadius = 20 + $0.clipsToBounds = true + } + + private let nameTextViewPlaceHolder = UILabel().then { + $0.text = "뢁마크 이름" + $0.font = .cellTitleFont + $0.textColor = .systemGray3 + $0.isSkeletonable = true + $0.isHiddenWhenSkeletonIsActive = true + } + + let nameTextView = UITextView().then { + $0.backgroundColor = .clear + $0.layer.borderWidth = 0 + $0.textContainerInset = UIEdgeInsets(top: 7, left: 0, bottom: 0, right: 0) + $0.font = .cellTitleFont + $0.textColor = .label + $0.isScrollEnabled = true + $0.keyboardType = .default + $0.autocorrectionType = .no + $0.isSkeletonable = true + $0.skeletonTextLineHeight = .fixed(20) + $0.skeletonPaddingInsets = .init(top: 5, left: 0, bottom: 5, right: 0) + } + + private let clearButton = UIButton().then { + $0.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + $0.tintColor = .systemGray3 + $0.isHidden = true + } + + private let separatorView = UIView().then { + $0.backgroundColor = .systemGray3 + } + + private let urlTextViewPlaceHolder = UILabel().then { + $0.text = "URL" + $0.font = .cellTitleFont + $0.textColor = .systemGray3 + $0.isSkeletonable = true + $0.isHiddenWhenSkeletonIsActive = true + } + + let urlTextView = UITextView().then { + $0.backgroundColor = .clear + $0.layer.borderWidth = 0 + $0.textContainerInset = UIEdgeInsets(top: 7, left: 0, bottom: 0, right: 0) + $0.font = .cellTitleFont + $0.textColor = .label + $0.isScrollEnabled = true + $0.keyboardType = .URL + $0.autocorrectionType = .no + $0.isSkeletonable = true + $0.skeletonTextLineHeight = .fixed(20) + $0.skeletonTextNumberOfLines = 2 + $0.skeletonPaddingInsets = .init(top: 5, left: 0, bottom: 5, right: 0) + } + + private let button = UIButton(type: .custom).then { + $0.backgroundColor = UIColor.backgroundColor + $0.layer.cornerRadius = 10 + $0.clipsToBounds = true + $0.titleLabel?.font = .cellTitleFont + $0.isEnabled = true + } + + private let buttonLabel = UILabel().then { + $0.text = "λͺ©λ‘" + $0.font = .cellTitleFont + $0.textColor = .label + } + + let selectedFolderLabel = UILabel().then { + $0.font = .descriptionFont + $0.textColor = .systemGray + $0.textAlignment = .right + } + + private let chevronImageView = UIImageView().then { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium, scale: .default) + let image = UIImage(systemName: "chevron.forward", withConfiguration: config)?.withRenderingMode(.alwaysTemplate) + $0.image = image + $0.tintColor = .systemGray3 + $0.contentMode = .scaleAspectFit + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + setupBindings() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + deinit { + AddBookmarkManager.shared.incomingTitle = nil + AddBookmarkManager.shared.incomingData = nil + AddBookmarkManager.shared.incomingFaviconUrl = nil + AddBookmarkManager.shared.incomingError = nil + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .systemGroupedBackground + clearButton.addTarget(self, action: #selector(clearTextView), for: .touchUpInside) + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + nameTextView.delegate = self + urlTextView.delegate = self + + isSkeletonable = true + } + + private func setupHierarchy() { + addSubview(textFieldView) + addSubview(nameTextView) + addSubview(clearButton) + addSubview(separatorView) + addSubview(urlTextView) + addSubview(nameTextViewPlaceHolder) + addSubview(urlTextViewPlaceHolder) + addSubview(button) + addSubview(buttonLabel) + addSubview(selectedFolderLabel) + addSubview(chevronImageView) + } + + private func setupLayout() { + + textFieldView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(70) + make.height.equalTo(150) + make.leading.equalToSuperview().offset(20) + make.trailing.equalToSuperview().offset(-20) + } + + nameTextView.snp.makeConstraints { make in + make.top.equalTo(textFieldView.snp.top).offset(10) + make.leading.equalTo(textFieldView.snp.leading).offset(15) + make.trailing.equalTo(clearButton.snp.leading) + make.height.equalTo(30) + } + + nameTextViewPlaceHolder.snp.makeConstraints { make in + make.top.equalTo(nameTextView.snp.top).offset(7) + make.leading.equalTo(nameTextView.snp.leading).offset(5) + } + + clearButton.snp.makeConstraints { make in + make.top.equalTo(nameTextView.snp.top).offset(7) + make.trailing.equalTo(textFieldView.snp.trailing).offset(-15) + make.width.height.equalTo(24) + } + + separatorView.snp.makeConstraints { make in + make.top.equalTo(nameTextView.snp.bottom).offset(10) + make.leading.equalTo(nameTextView.snp.leading).offset(5) + make.trailing.equalTo(textFieldView.snp.trailing).offset(-15) + make.height.equalTo(1) + } + + urlTextView.snp.makeConstraints { make in + make.top.equalTo(separatorView.snp.bottom).offset(10) + make.leading.trailing.equalTo(nameTextView) + make.bottom.equalTo(textFieldView.snp.bottom).offset(-10) + } + + urlTextViewPlaceHolder.snp.makeConstraints { make in + make.top.equalTo(urlTextView.snp.top).offset(7) + make.leading.equalTo(urlTextView.snp.leading).offset(5) + } + + button.snp.makeConstraints { make in + make.top.equalTo(textFieldView.snp.bottom).offset(20) + make.leading.equalToSuperview().offset(20) + make.trailing.equalToSuperview().offset(-20) + make.height.equalTo(50) + } + + buttonLabel.snp.makeConstraints { make in + make.leading.equalTo(button.snp.leading).offset(20) + make.centerY.equalTo(button.snp.centerY) + make.height.equalTo(40) + } + + selectedFolderLabel.snp.makeConstraints { make in + make.trailing.equalTo(chevronImageView.snp.leading).offset(-10) + make.centerY.equalTo(button.snp.centerY) + make.height.equalTo(40) + make.width.equalTo(200) + } + + chevronImageView.snp.makeConstraints { make in + make.trailing.equalTo(button.snp.trailing).offset(-20) + make.centerY.equalTo(button.snp.centerY) + make.width.height.equalTo(15) + } + + } + + private func setupBindings() { + AddBookmarkManager.shared.$incomingTitle + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + self?.nameTextView.text = title + self?.nameTextViewPlaceHolder.isHidden = !(title?.isEmpty ?? true) + self?.clearButton.isHidden = title?.isEmpty ?? true + self?.updateTextFieldsFilledState() + } + .store(in: &cancellables) + + AddBookmarkManager.shared.$incomingData + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.urlTextView.text = url + self?.urlTextViewPlaceHolder.isHidden = !(url?.isEmpty ?? true) + self?.updateTextFieldsFilledState() + } + .store(in: &cancellables) + } + + func updateTextFieldsFilledState() { + let isBothTextViewsFilled = !(nameTextView.text?.isEmpty ?? true) && !(urlTextView.text?.isEmpty ?? true) + onTextChange?(isBothTextViewsFilled) + } + + @objc func clearTextView() { + nameTextView.text = "" + textViewDidChange(nameTextView) + nameTextView.becomeFirstResponder() + } + + @objc private func buttonTapped() { + onButtonTapped?() + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + self.endEditing(true) + } +} + +extension AddBookmarkView: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + let textLength: Int = textView.text.count + textView.selectedRange = NSRange(location: textLength, length: 0) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if text == "\n" { + if textView == nameTextView { + urlTextView.becomeFirstResponder() + } else if textView == urlTextView { + textView.resignFirstResponder() + } + return false + } + return true + } + + func textViewDidChange(_ textView: UITextView) { + + let isBothTextViewsFilled = !nameTextView.text.isEmpty && !urlTextView.text.isEmpty + onTextChange?(isBothTextViewsFilled) + + if textView == nameTextView { + nameTextViewPlaceHolder.isHidden = !nameTextView.text.isEmpty + clearButton.isHidden = nameTextView.text.isEmpty + } + + if textView == urlTextView { + urlTextViewPlaceHolder.isHidden = !urlTextView.text.isEmpty + } + + } +} diff --git a/iBox/Sources/AddBookmark/AddBookmarkViewController.swift b/iBox/Sources/AddBookmark/AddBookmarkViewController.swift new file mode 100644 index 0000000..0388234 --- /dev/null +++ b/iBox/Sources/AddBookmark/AddBookmarkViewController.swift @@ -0,0 +1,226 @@ +// +// AddBookmarkViewController.swift +// iBox +// +// Created by jiyeon on 1/5/24. +// + +import Combine +import UIKit + +import SkeletonView + +protocol AddBookmarkViewControllerProtocol: AnyObject { + func addFolderDirect(_ folder: Folder) + func addBookmarkDirect(_ bookmark: Bookmark, at folderIndex: Int) +} + +final class AddBookmarkViewController: UIViewController { + weak var delegate: AddBookmarkViewControllerProtocol? + + var cancellables = Set() + + var haveValidInput = false + var selectedFolder: Folder? + var selectedFolderIndex: Int? + var folders = [Folder]() + + let addBookmarkView = AddBookmarkView() + + override func loadView() { + super.loadView() + setupAddBookmarkView() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateSelectedFolder() + addBookmarkView.updateTextFieldsFilledState() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + updateSelectedFolder() + addBookmarkView.nameTextView.becomeFirstResponder() + setupBindings() + + view.isSkeletonable = true + } + + private func setupNavigationBar() { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.titleTextAttributes = [.font: UIFont.subTitlefont] + + navigationController?.navigationBar.tintColor = .box + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.compactAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + + title = "μƒˆλ‘œμš΄ 뢁마크" + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "μ·¨μ†Œ", style: .plain, target: self, action: #selector(cancelButtonTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "μΆ”κ°€", style: .plain, target: self, action: #selector(addButtonTapped)) + navigationItem.rightBarButtonItem?.isEnabled = false + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.barItemFont + ] + navigationItem.leftBarButtonItem?.setTitleTextAttributes(attributes, for: .normal) + navigationItem.rightBarButtonItem?.setTitleTextAttributes(attributes, for: .normal) + navigationItem.rightBarButtonItem?.setTitleTextAttributes(attributes, for: .disabled) + } + + private func setupAddBookmarkView() { + addBookmarkView.onButtonTapped = { [weak self] in + self?.openFolderSelection() + } + addBookmarkView.onTextChange = { [weak self] isEnabled in + self?.haveValidInput = isEnabled + + if let haveValidInput = self?.haveValidInput, + haveValidInput, + let _ = self?.selectedFolder { + self?.navigationItem.rightBarButtonItem?.isEnabled = true + } else { + self?.navigationItem.rightBarButtonItem?.isEnabled = false + } + } + view = addBookmarkView + } + + private func updateSelectedFolder() { + folders = CoreDataManager.shared.getFolders() + let selectedFolderId = UserDefaultsManager.selectedFolderId + + for (index, folder) in folders.enumerated() { + if folder.id == selectedFolderId { + selectedFolder = folder + selectedFolderIndex = index + } + } + + if selectedFolder == nil && !folders.isEmpty { + selectedFolder = folders[0] + selectedFolderIndex = 0 + } + + if let selectedFolder { + addBookmarkView.selectedFolderName = selectedFolder.name + } else { + addBookmarkView.selectedFolderName = "μ„ νƒλœ 폴더가 μ—†μŠ΅λ‹ˆλ‹€." + } + } + + private func setupBindings() { + AddBookmarkManager.shared.$isFetching + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + if isFetching { + self?.view.hideSkeleton() + self?.view.showAnimatedGradientSkeleton() + } else { + self?.view.hideSkeleton() + } + } + .store(in: &cancellables) + + AddBookmarkManager.shared.$incomingError + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard error != nil else { return } + let alert = UIAlertController(title: "였λ₯˜", message: "ν•΄λ‹Ή URL을 κ°€μ Έμ˜¬ 수 μ—†μŠ΅λ‹ˆλ‹€", preferredStyle: .alert) + let okAction = UIAlertAction(title: "확인", style: .default) { _ in + AddBookmarkManager.shared.isFetching = false + } + alert.addAction(okAction) + self?.present(alert, animated: true) + } + .store(in: &cancellables) + } + + @objc private func cancelButtonTapped() { + + let isTextFieldsEmpty = addBookmarkView.nameTextView.text?.isEmpty ?? true && addBookmarkView.urlTextView.text?.isEmpty ?? true + + if isTextFieldsEmpty { + self.dismiss(animated: true, completion: nil) + } else { + let alertController = UIAlertController(title: nil, message: "변경사항 폐기", preferredStyle: .alert) + + + let discardAction = UIAlertAction(title: "예", style: .destructive) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + } + + let cancelAction = UIAlertAction(title: "μ•„λ‹ˆμ˜€", style: .cancel) + + alertController.addAction(discardAction) + alertController.addAction(cancelAction) + + present(alertController, animated: true, completion: nil) + } + } + + @objc private func addButtonTapped() { + guard let name = addBookmarkView.nameTextView.text, !name.isEmpty, + var urlString = addBookmarkView.urlTextView.text, !urlString.isEmpty else { + print("Invalid input") + return + } + + let lowercasedUrlString = urlString.lowercased() + if !lowercasedUrlString.hasPrefix("https://") && !lowercasedUrlString.hasPrefix("http://") { + urlString = "https://" + urlString + } + + var allowedCharacters = CharacterSet.urlQueryAllowed + allowedCharacters.insert("#") + + guard let encodedUrlString = urlString.addingPercentEncoding(withAllowedCharacters: allowedCharacters), + let url = URL(string: encodedUrlString) else { + print("Invalid URL format") + return + } + + let newBookmark = Bookmark(id: UUID(), name: name, url: url) + + if let selectedFolder = selectedFolder, + let selectedFolderIndex = selectedFolderIndex { + CoreDataManager.shared.addBookmark(newBookmark, folderId: selectedFolder.id) + delegate?.addBookmarkDirect(newBookmark, at: selectedFolderIndex) + } else { + print("μ„ νƒλœ 폴더가 μ—†μŠ΅λ‹ˆλ‹€.") + } + + self.dismiss(animated: true, completion: nil) + } + + private func openFolderSelection() { + let folderListViewController = FolderListViewController(folders: folders, selectedId: selectedFolder?.id) + folderListViewController.title = "λͺ©λ‘" + folderListViewController.delegate = self + + navigationController?.pushViewController(folderListViewController, animated: true) + } + +} + +extension AddBookmarkViewController: FolderListViewControllerDelegate { + func addFolder(_ folder: Folder) { + delegate?.addFolderDirect(folder) + } + + func selectFolder(_ folder: Folder, at index: Int) { + selectedFolder = folder + selectedFolderIndex = index + + if haveValidInput { + navigationItem.rightBarButtonItem?.isEnabled = true + } + + addBookmarkView.selectedFolderName = selectedFolder?.name + } + +} diff --git a/iBox/Sources/AddBookmark/FolderListCell.swift b/iBox/Sources/AddBookmark/FolderListCell.swift new file mode 100644 index 0000000..8e86e22 --- /dev/null +++ b/iBox/Sources/AddBookmark/FolderListCell.swift @@ -0,0 +1,88 @@ +// +// FolderListCell.swift +// iBox +// +// Created by μ΅œμ’…μ› on 3/7/24. +// + +import UIKit + +class FolderListCell: UITableViewCell { + + static let reuseIdentifier = "ListCell" + + // MARK: - UI Components + + private let folderImageView = UIImageView().then { + $0.image = UIImage(systemName: "folder.fill") + $0.contentMode = .scaleAspectFit + $0.tintColor = .gray + } + + let folderNameLabel = UILabel().then { + $0.textColor = .label + $0.font = .cellTitleFont + } + + private let checkImageView = UIImageView().then { + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold, scale: .default) + $0.image = UIImage(systemName: "checkmark", withConfiguration: config) + $0.contentMode = .scaleAspectFit + $0.tintColor = .box + $0.isHidden = true + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + self.backgroundColor = .clear + } + + private func setupHierarchy() { + self.contentView.addSubview(folderImageView) + self.contentView.addSubview(folderNameLabel) + self.contentView.addSubview(checkImageView) + } + + func setupLayout() { + folderImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(20) + make.width.height.equalTo(30) + make.top.greaterThanOrEqualToSuperview().offset(10) + make.bottom.lessThanOrEqualToSuperview().offset(-10) + } + + checkImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-20) + } + + folderNameLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(folderImageView.snp.trailing).offset(10) + make.trailing.equalTo(checkImageView.snp.leading).offset(-10) + make.top.greaterThanOrEqualToSuperview().offset(10) + make.bottom.lessThanOrEqualToSuperview().offset(-10) + } + } + + func configureWith(folder: Folder, isSelected: Bool) { + folderNameLabel.text = folder.name + checkImageView.isHidden = !isSelected + } + +} diff --git a/iBox/Sources/AddBookmark/FolderListView.swift b/iBox/Sources/AddBookmark/FolderListView.swift new file mode 100644 index 0000000..9ea7286 --- /dev/null +++ b/iBox/Sources/AddBookmark/FolderListView.swift @@ -0,0 +1,108 @@ +// +// FolderListView.swift +// iBox +// +// Created by μ΅œμ’…μ› on 3/7/24. +// + +import UIKit + +protocol FolderListViewDelegate: AnyObject { + func selectFolder(_ folder: Folder, at index: Int) +} + +class FolderListView: UIView { + weak var delegate: FolderListViewDelegate? + + let coreDataManager = CoreDataManager.shared + var folders: [Folder] = [] + var selectedFolderId: UUID? + + // MARK: - UI Components + + private let infoLabel = UILabel().then { + $0.text = "μƒˆλ‘œμš΄ 뢁마크λ₯Ό μΆ”κ°€ν•  폴더λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”." + $0.font = .barItemFont + $0.textColor = .label + $0.textAlignment = .center + } + + private let tableView = UITableView().then { + $0.backgroundColor = .clear + } + + private let stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 20 + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + func setupProperty() { + backgroundColor = .systemGroupedBackground + setupTableView() + } + + func setupHierarchy() { + addSubview(stackView) + } + + func setupLayout() { + stackView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(20) // Adjust as necessary + make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottom) + make.leading.equalTo(self.snp.leading) + make.trailing.equalTo(self.snp.trailing) + } + + stackView.addArrangedSubview(infoLabel) + stackView.addArrangedSubview(tableView) + } + + func setupTableView() { + self.tableView.dataSource = self + self.tableView.delegate = self + self.tableView.register(FolderListCell.self, forCellReuseIdentifier: FolderListCell.reuseIdentifier) + } + + func reloadFolderList() { + tableView.reloadData() + } +} + +extension FolderListView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return folders.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: FolderListCell.reuseIdentifier, for: indexPath) as! FolderListCell + let folder = folders[indexPath.row] + + let isSelectedFolder = selectedFolderId == folder.id + cell.configureWith(folder: folder, isSelected: isSelectedFolder) + + return cell + } +} + +extension FolderListView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let selectedFolder = folders[indexPath.row] + UserDefaultsManager.selectedFolderId = selectedFolder.id + delegate?.selectFolder(selectedFolder, at: indexPath.row) + } +} diff --git a/iBox/Sources/AddBookmark/FolderListViewController.swift b/iBox/Sources/AddBookmark/FolderListViewController.swift new file mode 100644 index 0000000..338b853 --- /dev/null +++ b/iBox/Sources/AddBookmark/FolderListViewController.swift @@ -0,0 +1,113 @@ +// +// FolderListViewController.swift +// iBox +// +// Created by μ΅œμ’…μ› on 3/7/24. +// + +import UIKit + +protocol FolderListViewControllerDelegate: AnyObject { + func selectFolder(_ folder: Folder, at index: Int) + func addFolder(_ folder: Folder) +} + +class FolderListViewController: UIViewController { + weak var delegate: FolderListViewControllerDelegate? + + let folderListView = FolderListView() + + init(folders: [Folder], selectedId: UUID?) { + super.init(nibName: nil, bundle: nil) + setupFolderListView(folders, selectedId: selectedId) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = folderListView + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + navigationController?.interactivePopGestureRecognizer?.delegate = self + } + + private func setupNavigationBar() { + navigationItem.hidesBackButton = true + let backButton = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(back)) + let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addFolder)) + navigationItem.leftBarButtonItem = backButton + navigationItem.rightBarButtonItem = addButton + } + + @objc private func back() { + self.navigationController?.popViewController(animated: true) + } + + @objc private func addFolder() { + let controller = UIAlertController(title: "μƒˆλ‘œμš΄ 폴더", message: "이 ν΄λ”μ˜ 이름을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€.", preferredStyle: .alert) + + controller.addTextField { textField in + textField.placeholder = "폴더 이름" + textField.autocorrectionType = .no + textField.spellCheckingType = .no + } + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .cancel, handler: nil) + let addAction = UIAlertAction(title: "μΆ”κ°€", style: .default) { [unowned controller, weak self] _ in + guard let textField = controller.textFields?.first, + let folderName = textField.text, !folderName.trimmingCharacters(in: .whitespaces).isEmpty else { return } + + let newFolder = Folder(id: UUID(), name: folderName, bookmarks: []) + CoreDataManager.shared.addFolder(newFolder) + self?.folderListView.folders.append(newFolder) + + self?.folderListView.selectedFolderId = newFolder.id + UserDefaultsManager.selectedFolderId = newFolder.id + + self?.folderListView.reloadFolderList() + self?.delegate?.addFolder(newFolder) + } + + controller.addAction(cancelAction) + controller.addAction(addAction) + + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: controller.textFields?.first, queue: .main) { notification in + if let textField = notification.object as? UITextField, + let text = textField.text, !text.trimmingCharacters(in: .whitespaces).isEmpty { + addAction.isEnabled = true + } else { + addAction.isEnabled = false + } + } + + addAction.isEnabled = false + + present(controller, animated: true) + } + + private func setupFolderListView(_ folders: [Folder], selectedId: UUID?) { + folderListView.delegate = self + folderListView.folders = folders + folderListView.selectedFolderId = selectedId + } +} + +extension FolderListViewController: FolderListViewDelegate { + func selectFolder(_ folder: Folder, at index: Int) { + delegate?.selectFolder(folder, at: index) + self.navigationController?.popViewController(animated: true) + } + +} + +extension FolderListViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} diff --git a/iBox/Sources/AppDelegate.swift b/iBox/Sources/AppDelegate.swift index 1d9e30e..f573b9f 100644 --- a/iBox/Sources/AppDelegate.swift +++ b/iBox/Sources/AppDelegate.swift @@ -5,18 +5,28 @@ // Created by 김찬희 on 2023/12/21. // -import UIKit import CoreData +import UIKit +import WebKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - + let versioningHandler: VersioningHandler = VersioningHandler() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + workaroundInitialWebViewDelay() + + versioningHandler.checkAppVersion { result in + AppStateManager.shared.versionCheckCompleted = result + } + return true } + + func workaroundInitialWebViewDelay() { + let webView = WKWebView() + webView.loadHTMLString("", baseURL: nil) + } // MARK: UISceneSession Lifecycle @@ -32,50 +42,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - // MARK: - Core Data stack - - lazy var persistentContainer: NSPersistentContainer = { - /* - The persistent container for the application. This implementation - creates and returns a container, having loaded the store for the - application to it. This property is optional since there are legitimate - error conditions that could cause the creation of the store to fail. - */ - let container = NSPersistentContainer(name: "iBox") - container.loadPersistentStores(completionHandler: { (storeDescription, error) in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - return container - }() - - // MARK: - Core Data Saving support - - func saveContext () { - let context = persistentContainer.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - } - } } -} - diff --git a/iBox/Sources/Base/BaseViewController.swift b/iBox/Sources/Base/BaseViewController.swift new file mode 100644 index 0000000..b0a872f --- /dev/null +++ b/iBox/Sources/Base/BaseViewController.swift @@ -0,0 +1,238 @@ +// +// BaseViewController.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +import SnapKit + +class NavigationBar: UIView { + var backButton = UIButton() + var titleLabel = UILabel() + var addButton = UIButton() + var moreButton = UIButton() + var doneButton = UIButton() +} + +protocol BaseViewControllerProtocol { + func setupNavigationBar() +} + +class BaseViewController: UIViewController, UIGestureRecognizerDelegate { + + let backgroundColor: UIColor = .backgroundColor + let tintColor: UIColor = .label + let titleFont: UIFont = .titleFont + + // MARK: - UI Components + + let statusBar = UIView() + + let navigationBar = NavigationBar().then { + $0.backButton.configuration = .plain() + $0.backButton.configuration?.image = UIImage(systemName: "chevron.left") + $0.backButton.configuration?.preferredSymbolConfigurationForImage = .init(weight: .semibold) + $0.addButton.configuration = .plain() + $0.addButton.configuration?.image = UIImage(systemName: "plus") + $0.addButton.configuration?.preferredSymbolConfigurationForImage = .init(weight: .semibold) + $0.moreButton.configuration = .plain() + $0.moreButton.configuration?.image = UIImage(systemName: "ellipsis.circle") + $0.moreButton.configuration?.preferredSymbolConfigurationForImage = .init(weight: .semibold) + $0.doneButton.configuration = .plain() + $0.doneButton.configuration?.baseForegroundColor = .label + $0.doneButton.configuration?.attributedTitle = AttributedString("μ™„λ£Œ", attributes: AttributeContainer([NSAttributedString.Key.font: UIFont.semiboldLabelFont])) + } + + let contentView: UIView = View() + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupLayout() + setupProperty() + } + + // MARK: - Setup Methods + + private func setupProperty() { + view.backgroundColor = .backgroundColor + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.interactivePopGestureRecognizer?.delegate = self + + setNavigationBarTintColor(tintColor) + setNavigationBarTitleLabelFont(titleFont) + setNavigationBarBackButtonHidden(true) + setNavigationBarMenuButtonHidden(true) + setNavigationBarDoneButtonHidden(true) + + navigationBar.backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + } + + private func setupHierarchy() { + view.addSubview(statusBar) + view.addSubview(navigationBar) + navigationBar.addSubview(navigationBar.backButton) + navigationBar.addSubview(navigationBar.titleLabel) + navigationBar.addSubview(navigationBar.addButton) + navigationBar.addSubview(navigationBar.moreButton) + navigationBar.addSubview(navigationBar.doneButton) + view.addSubview(contentView) + } + + private func setupLayout() { + statusBar.snp.makeConstraints { make in + make.leading.top.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top) + } + + navigationBar.snp.makeConstraints { make in + make.top.equalTo(statusBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(60) + } + + navigationBar.backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + + navigationBar.titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + navigationBar.moreButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + + navigationBar.addButton.snp.makeConstraints { make in + make.trailing.equalTo(navigationBar.moreButton.snp.leading).offset(-20) + make.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + + navigationBar.doneButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + + contentView.snp.makeConstraints { make in + make.top.equalTo(statusBar.snp.bottom).offset(60) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().inset(tabBarController?.tabBar.frame.height ?? 0) + } + } + + // MARK: - BaseViewController + + func setNavigationBarBackgroundColor(_ color: UIColor?) { + statusBar.backgroundColor = color + navigationBar.backgroundColor = color + } + + func setNavigationBarTintColor(_ color: UIColor) { + navigationBar.backButton.tintColor = color + navigationBar.addButton.tintColor = color + navigationBar.moreButton.tintColor = color + } + + func setNavigationBarHidden(_ hidden: Bool) { + navigationBar.isHidden = hidden + + if hidden { + contentView.snp.remakeConstraints { make in + make.top.equalTo(statusBar.snp.bottom) + make.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + } else { + contentView.snp.remakeConstraints { make in + make.top.equalTo(statusBar.snp.bottom).offset(60) + make.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + } + } + + func setNavigationBarBackButtonHidden(_ hidden: Bool) { + navigationBar.backButton.isHidden = hidden + + if hidden { + navigationBar.titleLabel.snp.remakeConstraints { make in + make.leading.equalToSuperview().inset(30) + make.centerY.equalToSuperview() + } + } else { + navigationBar.titleLabel.snp.remakeConstraints { make in + make.center.equalToSuperview() + } + } + } + + func setNavigationBarMenuButtonHidden(_ hidden: Bool) { + navigationBar.addButton.isHidden = hidden + navigationBar.moreButton.isHidden = hidden + } + + func setNavigationBarAddButtonHidden(_ hidden: Bool) { + navigationBar.addButton.isHidden = hidden + + if !hidden { + navigationBar.addButton.snp.remakeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + } + } + + func setNavigationBarDoneButtonHidden(_ hidden: Bool) { + navigationBar.doneButton.isHidden = hidden + } + + func setNavigationBarAddButtonAction(_ selector: Selector) { + navigationBar.addButton.addTarget(self, action: selector, for: .touchUpInside) + } + + func setNavigationBarMoreButtonAction(_ selector: Selector) { + navigationBar.moreButton.addTarget(self, action: selector, for: .touchUpInside) + } + + func setNavigationBarDoneButtonAction(_ selector: Selector) { + navigationBar.doneButton.addTarget(self, action: selector, for: .touchUpInside) + } + + func setNavigationBarTitleLabelText(_ text: String?) { + navigationBar.titleLabel.text = text + } + + func setNavigationBarTitleLabelFont(_ font: UIFont?) { + navigationBar.titleLabel.font = font + } + + func setNavigationBarTitleLabelTextColor(_ color: UIColor?) { + navigationBar.titleLabel.textColor = color + } + + // MARK: - Action Functions + + @objc func backButtonTapped() { + navigationController?.popViewController(animated: true) + } + + // MARK: - UIGestureRecognizerDelegate + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let navigationController = navigationController else { return false } + // Navigation Stack에 μŒ“μΈ λ·°κ°€ 1개λ₯Ό μ΄ˆκ³Όν•  λ•Œ μŠ€μ™€μ΄ν”„ 제슀처 ν—ˆμš© + return navigationController.viewControllers.count > 1 + } + +} diff --git a/iBox/Sources/Base/BottomSheetViewController.swift b/iBox/Sources/Base/BottomSheetViewController.swift new file mode 100644 index 0000000..97be2e3 --- /dev/null +++ b/iBox/Sources/Base/BottomSheetViewController.swift @@ -0,0 +1,143 @@ +// +// BottomSheetViewController.swift +// iBox +// +// Created by jiyeon on 1/5/24. +// + +import UIKit + +import SnapKit + +class BottomSheetViewController: UIViewController { + + var bottomSheetHeight: CGFloat + + // MARK: - UI Components + + let dimmedView = UIView().then { + $0.backgroundColor = .clear + $0.isUserInteractionEnabled = true + } + + let sheetView = View().then { + $0.clipsToBounds = true + $0.layer.cornerRadius = 30 + $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // μ™Όμͺ½ μœ„, 였λ₯Έμͺ½ μœ„ λ‘₯κΈ€κ²Œ + } + + let indicator = UIView().then { + $0.clipsToBounds = true + $0.layer.cornerRadius = 2 + $0.backgroundColor = .darkGray + } + + // MARK: - Initializer + + init(bottomSheetHeight: CGFloat) { + self.bottomSheetHeight = bottomSheetHeight + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupProperty() + setupHierarchy() + setupLayout() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showBottomSheets() + } + + // MARK: - Setup Methods + + private func setupProperty() { + // TapGesture + let dimmedTap = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) + dimmedView.addGestureRecognizer(dimmedTap) + + // SwipeGesture + let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(panGesture)) + swipeGesture.direction = .down + view.addGestureRecognizer(swipeGesture) + } + + private func setupHierarchy() { + view.addSubview(dimmedView) + view.addSubview(sheetView) + sheetView.addSubview(indicator) + } + + private func setupLayout() { + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + sheetView.snp.makeConstraints { make in + make.top.equalTo(view.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(bottomSheetHeight) + } + + indicator.snp.makeConstraints { make in + make.width.equalTo(40) + make.height.equalTo(4) + make.top.equalToSuperview().inset(10) + make.centerX.equalToSuperview() + } + } + + // MARK: - Action Functions + + @objc private func dimmedViewTapped(_ tapRecognizer: UITapGestureRecognizer) { + hideBottomSheets() + } + + @objc private func panGesture(_ recognizer: UISwipeGestureRecognizer) { + if recognizer.state == .ended { + switch recognizer.direction { + case .down: hideBottomSheets() + default: break + } + } + } + + // MARK: - Bottom Sheet Action + + private func showBottomSheets() { + sheetView.snp.remakeConstraints { make in + make.leading.bottom.trailing.equalToSuperview() + make.height.equalTo(bottomSheetHeight) + } + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: { + self.dimmedView.backgroundColor = .dimmedViewColor + self.view.layoutIfNeeded() + }) + } + + private func hideBottomSheets() { + sheetView.snp.remakeConstraints { make in + make.top.equalTo(view.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(bottomSheetHeight) + } + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: { + self.dimmedView.backgroundColor = .clear + self.view.layoutIfNeeded() + }) { _ in + if self.presentingViewController != nil { + self.dismiss(animated: false) + } + } + } + +} diff --git a/iBox/Sources/BaseViewController.swift b/iBox/Sources/BaseViewController.swift deleted file mode 100644 index fd38cb5..0000000 --- a/iBox/Sources/BaseViewController.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// BaseViewController.swift -// iBox -// -// Created by jiyeon on 12/26/23. -// - -import UIKit - -class BaseViewController: UIViewController { - - let baseView = View(frame: UIScreen.main.bounds) - - override func viewDidLoad() { - super.viewDidLoad() - view.addSubview(baseView) - } -} diff --git a/iBox/Sources/BoxList/BoxListCell.swift b/iBox/Sources/BoxList/BoxListCell.swift new file mode 100644 index 0000000..b9929b4 --- /dev/null +++ b/iBox/Sources/BoxList/BoxListCell.swift @@ -0,0 +1,129 @@ +// +// BoxListCell.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/30/24. +// + +import UIKit + +import SnapKit + +class BoxListCell: UITableViewCell { + var viewModel: BoxListCellViewModel? + static let reuseIdentifier = "boxListCell" + + var onDelete: (() -> Void)? + var onEdit: (() -> Void)? + + // MARK: - UI Components + + private let cellImageView = UIImageView().then { + $0.image = UIImage(systemName: "ellipsis.rectangle.fill") + $0.tintColor = .label + $0.contentMode = .scaleAspectFit + } + + private let label = UILabel().then { + $0.font = .cellTitleFont + } + + private let editButton = UIButton().then{ + $0.configuration = .plain() + $0.configuration?.image = UIImage(systemName: "ellipsis.circle")?.withTintColor(.box, renderingMode: .alwaysOriginal) + + $0.showsMenuAsPrimaryAction = true + $0.isHidden = true + } + + private lazy var editAction = UIAction(title: "뢁마크 νŽΈμ§‘", image: UIImage(systemName: "pencil")) {[weak self] _ in + self?.onEdit?() + } + + private lazy var deleteAction = UIAction(title: "μ‚­μ œ", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.onDelete?() + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + configureEditMenu() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + viewModel = nil + onDelete = nil + onEdit = nil + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .tableViewBackgroundColor + selectionStyle = .gray + } + + private func setupHierarchy() { + contentView.addSubview(cellImageView) + contentView.addSubview(label) + contentView.addSubview(editButton) + } + + private func setupLayout() { + cellImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(25) + make.top.bottom.equalToSuperview().inset(10) + make.width.equalTo(23) + } + + label.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.trailing.equalToSuperview().inset(25) + make.leading.equalTo(cellImageView.snp.trailing).offset(8) + } + + editButton.snp.makeConstraints { make in + make.width.equalTo(25) + make.top.bottom.equalToSuperview() + make.trailing.equalToSuperview().offset(-10) + } + } + + private func configureEditMenu() { + editButton.menu = UIMenu(options: .displayInline, children: [editAction, deleteAction]) + } + + // MARK: - Bind ViewModel + + func bindViewModel(_ viewModel: BoxListCellViewModel) { + self.viewModel = viewModel + label.text = viewModel.name + } + + func setEditButtonHidden(_ isHidden: Bool) { + editButton.isHidden = isHidden + + if isHidden { + label.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview() + make.trailing.equalToSuperview().inset(25) + make.leading.equalTo(cellImageView.snp.trailing).offset(10) + } + } else { + label.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview() + make.trailing.equalTo(editButton.snp.leading).offset(-5) + make.leading.equalTo(cellImageView.snp.trailing).offset(10) + } + } + } + +} diff --git a/iBox/Sources/BoxList/BoxListCellViewModel.swift b/iBox/Sources/BoxList/BoxListCellViewModel.swift new file mode 100644 index 0000000..2c6cefc --- /dev/null +++ b/iBox/Sources/BoxList/BoxListCellViewModel.swift @@ -0,0 +1,29 @@ +// +// BoxListCellViewModel.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/30/24. +// + +import Foundation + +class BoxListCellViewModel: Identifiable { + var bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + var id: UUID { + bookmark.id + } + + var name: String { + bookmark.name + } + + var url: URL { + bookmark.url + } + +} diff --git a/iBox/Sources/BoxList/BoxListSectionViewModel.swift b/iBox/Sources/BoxList/BoxListSectionViewModel.swift new file mode 100644 index 0000000..bb2d0e9 --- /dev/null +++ b/iBox/Sources/BoxList/BoxListSectionViewModel.swift @@ -0,0 +1,74 @@ +// +// BoxListSectionViewModel.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/30/24. +// + +import Foundation + +class BoxListSectionViewModel: Identifiable { + var folder: Folder + + var boxListCellViewModels: [BoxListCellViewModel] { + didSet { + folder.bookmarks = boxListCellViewModels.map { + Bookmark(id: $0.id, name: $0.name, url: $0.url) + } + } + } + + init(folder: Folder) { + self.folder = folder + boxListCellViewModels = folder.bookmarks.map { BoxListCellViewModel(bookmark: $0) } + } + + var id: UUID { + folder.id + } + + var name: String { + get { + folder.name + } + set { + folder.name = newValue + } + } + + var isOpened: Bool = false + + + var boxListCellViewModelsWithStatus: [BoxListCellViewModel] { + return isOpened ? boxListCellViewModels : [] + } + + func viewModel(at index: Int) -> BoxListCellViewModel { + return boxListCellViewModels[index] + } + + @discardableResult + func deleteCell(at index: Int) -> BoxListCellViewModel { + let cell = boxListCellViewModels[index] + boxListCellViewModels.remove(at: index) + return cell + } + + func updateCell(at index: Int, bookmark: Bookmark) { + boxListCellViewModels[index].bookmark = bookmark + } + + func insertCell(_ cell: BoxListCellViewModel, at index: Int) { + boxListCellViewModels.insert(cell, at: index) + } + + @discardableResult + func openSectionIfNeeded() -> Bool { + if !isOpened { + isOpened = true + return true + } + return false + } +} + diff --git a/iBox/Sources/BoxList/BoxListView.swift b/iBox/Sources/BoxList/BoxListView.swift new file mode 100644 index 0000000..abbaba0 --- /dev/null +++ b/iBox/Sources/BoxList/BoxListView.swift @@ -0,0 +1,525 @@ +// +// BoxListView.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/3/24. +// + +import Combine +import UIKit + +import SnapKit + +protocol BoxListViewDelegate: AnyObject { + func didSelectWeb(id: UUID, at url: URL, withName name: String) + func pushViewController(type: EditType) + func pushViewController(url: URL?) + func presentEditBookmarkController(at indexPath: IndexPath) + func deleteFolderinBoxList(at section: Int) + func editFolderNameinBoxList(at section: Int, currentName: String) +} + +class BoxListView: UIView { + + var viewModel: BoxListViewModel? + private var boxListDataSource: BoxListDataSource! + weak var delegate: BoxListViewDelegate? + private var cancellables = Set() + + // MARK: - UI Components + + private let backgroundView = UIView().then { + $0.clipsToBounds = true + $0.layer.cornerRadius = 20 + $0.backgroundColor = .tableViewBackgroundColor + } + + private let tableView = UITableView(frame: .zero, style: .grouped).then { + $0.register(BoxListCell.self, forCellReuseIdentifier: BoxListCell.reuseIdentifier) + + $0.sectionHeaderTopPadding = 0 + $0.clipsToBounds = true + $0.layer.cornerRadius = 20 + $0.backgroundColor = .clear + $0.separatorColor = .clear + $0.rowHeight = 50 + $0.estimatedSectionHeaderHeight = 0 + $0.estimatedSectionFooterHeight = 0 + $0.estimatedRowHeight = 0 + } + + private let emptyStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 10 + $0.isHidden = true + } + + private let emptyLabel = UILabel().then { + $0.text = "폴더가 μ—†μŠ΅λ‹ˆλ‹€" + $0.font = .emptyLabelFont + $0.textColor = .secondaryLabel + $0.textAlignment = .center + } + + private lazy var lightEmptyImages = [ + UIImage(named: "sitting_fox0")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .light))) ?? UIImage(), + UIImage(named: "sitting_fox1")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .light))) ?? UIImage(), + UIImage(named: "sitting_fox2")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .light))) ?? UIImage(), + UIImage(named: "sitting_fox3")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .light))) ?? UIImage() + ] + + private lazy var darkEmptyImages = [ + UIImage(named: "sitting_fox0")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .dark))) ?? UIImage(), + UIImage(named: "sitting_fox1")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .dark))) ?? UIImage(), + UIImage(named: "sitting_fox2")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .dark))) ?? UIImage(), + UIImage(named: "sitting_fox3")?.imageWithColor(.secondaryLabel.resolvedColor(with: .init(userInterfaceStyle: .dark))) ?? UIImage() + ] + + private let emptyImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.tintColor = .secondaryLabel + $0.animationDuration = 1.5 + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + configureDataSource() + bindViewModel() + subscribeToNotifications() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if #available(iOS 13.0, *), traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + if previousTraitCollection?.userInterfaceStyle == .light { + emptyImageView.animationImages = darkEmptyImages + } else { + emptyImageView.animationImages = lightEmptyImages + } + if !emptyStackView.isHidden { + emptyImageView.startAnimating() + } + } + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .backgroundColor + viewModel = BoxListViewModel() + tableView.delegate = self + + if UITraitCollection.current.userInterfaceStyle == .light { + emptyImageView.animationImages = lightEmptyImages + } else { + emptyImageView.animationImages = darkEmptyImages + } + } + + private func setupHierarchy() { + addSubview(backgroundView) + backgroundView.addSubview(tableView) + backgroundView.addSubview(emptyStackView) + } + + private func setupLayout() { + backgroundView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide).inset(10) + make.leading.trailing.bottom.equalTo(safeAreaLayoutGuide).inset(20) + } + + tableView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().offset(10) + make.leading.trailing.equalToSuperview() + } + + emptyStackView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + emptyImageView.snp.makeConstraints { make in + make.width.height.equalTo(30) + } + + emptyStackView.addArrangedSubview(emptyLabel) + emptyStackView.addArrangedSubview(emptyImageView) + + } + + private func configureDataSource() { + boxListDataSource = BoxListDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemIdentifier in + guard let self, let viewModel = self.viewModel else { fatalError() } + guard let cell = tableView.dequeueReusableCell(withIdentifier: BoxListCell.reuseIdentifier, for: indexPath) as? BoxListCell else { fatalError() } + cell.setEditButtonHidden(!viewModel.isEditing) + cell.bindViewModel(viewModel.viewModel(at: indexPath)) + cell.onDelete = { [weak self, weak cell] in + guard let self = self, let cell = cell else { return } + if let currentIndexPath = self.tableView.indexPath(for: cell) { + self.viewModel?.deleteBookmark(at: currentIndexPath) + } + } + cell.onEdit = { [weak self, weak cell] in + guard let self = self, let cell = cell else { return } + if let currentIndexPath = self.tableView.indexPath(for: cell) { + self.delegate?.presentEditBookmarkController(at: currentIndexPath) + } + } + + return cell + } + boxListDataSource.defaultRowAnimation = .top + boxListDataSource.delegate = self + } + + private func applySnapshot(with sections: [BoxListSectionViewModel]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(sections.map{ $0.id }) + for section in sections { + snapshot.appendItems(section.boxListCellViewModelsWithStatus.map { $0.id }, toSection: section.id) + } + boxListDataSource.apply(snapshot, animatingDifferences: true) + } + + private func bindViewModel() { + guard let viewModel else { return } + let output = viewModel.transform(input: viewModel.input.eraseToAnyPublisher()) + + output.receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .sendBoxList(boxList: let boxList): + self?.applySnapshot(with: boxList) + self?.emptyStackView.isHidden = !boxList.isEmpty + if self?.emptyStackView.isHidden == false { + self?.emptyImageView.startAnimating() + } else { + self?.emptyImageView.stopAnimating() + } + case .editStatus(isEditing: let isEditing): + self?.tableView.setEditing(isEditing, animated: false) + guard let snapshot = self?.boxListDataSource.snapshot() else { return } + self?.boxListDataSource.applySnapshotUsingReloadData(snapshot) + case .reloadSections(idArray: let idArray): + guard var snapshot = self?.boxListDataSource.snapshot() else { return } + snapshot.reloadSections(idArray) + self?.boxListDataSource.apply(snapshot, animatingDifferences: false) + case .reloadRows(idArray: let idArray): + guard var snapshot = self?.boxListDataSource.snapshot() else { return } + snapshot.reloadItems(idArray) + self?.boxListDataSource.apply(snapshot) + case .openCloseFolder(boxList: let boxList, section: let section, isEmpty: let isEmpty): + self?.applySnapshot(with: boxList) + self?.tableView.layoutIfNeeded() + if !isEmpty { + let indexPath = IndexPath(row: NSNotFound, section: section) + self?.tableView.scrollToRow(at: indexPath, at: .top, animated: true) + } + } + }.store(in: &cancellables) + } + + private func subscribeToNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(dataDidReset), name: .didResetData, object: nil) + } + + @objc private func dataDidReset(notification: NSNotification) { + viewModel?.input.send(.viewDidLoad) + } + +} + +extension BoxListView: UITableViewDelegate { + + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let view = UIView() + let line = UIView() + view.addSubview(line) + line.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(15) + } + view.backgroundColor = .tableViewBackgroundColor + line.backgroundColor = .quaternaryLabel + return view + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0.7 + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 50 + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 48 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let viewModel else { return nil } + let button = FolderButton(isOpen: viewModel.boxList[section].isOpened) + button.setFolderName(viewModel.boxList[section].name) + button.tag = section + + // ν„°μΉ˜ν–ˆμ„ λ•Œ + button.addTarget(self, action: #selector(handleOpenClose), for: .touchUpInside) + // 길게 λˆŒλ €μ„ λ•Œ + button.addTarget(self, action: #selector(handleMenu), for: .menuActionTriggered) + + let edit = UIAction(title: "폴더 νŽΈμ§‘", image: UIImage(systemName: "pencil")) { [weak self] _ in + guard let folderName = self?.viewModel?.boxList[section].name else { return } + self?.delegate?.editFolderNameinBoxList(at: section, currentName: folderName) + } + let delete = UIAction(title: "폴더 μ‚­μ œ", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.delegate?.deleteFolderinBoxList(at: section) + } + + button.menu = UIMenu(options: .displayInline, children: [edit, delete]) + + return button + } + + @objc private func handleOpenClose(button: FolderButton) { + guard let viewModel else { return } + viewModel.input.send(.folderTapped(section: button.tag)) + button.toggleStatus() + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + } + } + + @objc private func handleMenu(button: FolderButton) { + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.prepare() + generator.impactOccurred() + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let cellViewModel = viewModel?.boxList[indexPath.section].boxListCellViewModelsWithStatus[indexPath.row] else { return } + delegate?.didSelectWeb(id: cellViewModel.id, at: cellViewModel.url, withName: cellViewModel.name) + + tableView.deselectRow(at: indexPath, animated: true) + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + // μ•‘μ…˜ μ •μ˜ + let favoriteAction = UIContextualAction(style: .normal, title: "favorite", handler: { [weak self] (action, view, completionHandler) in + self?.viewModel?.input.send(.toggleFavorite(indexPath: indexPath)) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + completionHandler(true) + }) + favoriteAction.backgroundColor = .box2 + if viewModel?.isFavoriteBookmark(at: indexPath) == true { + favoriteAction.image = UIImage(systemName: "heart.fill") + } else { + favoriteAction.image = UIImage(systemName: "heart") + } + + let shareAction = UIContextualAction(style: .normal, title: "share", handler: {(action, view, completionHandler) in + let cellViewModel = self.viewModel?.viewModel(at: indexPath) + self.delegate?.pushViewController(url: cellViewModel?.url) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + completionHandler(true) + }) + shareAction.backgroundColor = .box3 + shareAction.image = UIImage(systemName: "square.and.arrow.up") + + let deleteAction = UIContextualAction(style: .normal, title: "delete", handler: {(action, view, completionHandler) in + self.viewModel?.input.send(.deleteBookmark(indexPath: indexPath)) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + completionHandler(true) + }) + deleteAction.backgroundColor = .systemGray + deleteAction.image = UIImage(systemName: "trash.fill") + + // μŠ€μ™€μ΄ν”„ μ•‘μ…˜ ꡬ성 + let configuration = UISwipeActionsConfiguration(actions: [deleteAction, shareAction, favoriteAction]) + configuration.performsFirstActionWithFullSwipe = false // μ™„μ „νžˆ μŠ€μ™€μ΄ν”„ν–ˆμ„ λ•Œ 첫 번째 μ•‘μ…˜μ΄ μžλ™μœΌλ‘œ μ‹€ν–‰λ˜λŠ” 것을 λ§‰μŒ + + return configuration + } + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + return .none + } + + func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + return false + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + let configuration = UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { [weak self] () -> UIViewController? in + guard let self = self else { return nil } + return self.createOrRetrievePreviewController(for: indexPath) + }, actionProvider: { suggestedActions in + return self.makeContextMenu(for: indexPath) + }) + return configuration + } + + private func createOrRetrievePreviewController(for indexPath: IndexPath) -> UIViewController? { + guard let cellViewModel = self.viewModel?.boxList[indexPath.section].boxListCellViewModelsWithStatus[indexPath.row] else { return nil } + let id = cellViewModel.id + let cachedViewController = WebCacheManager.shared.viewControllerForKey(id) + + if let cachedViewController = cachedViewController, cachedViewController.errorViewController?.isHandlingError == nil { + return cachedViewController + } + + if cachedViewController?.errorViewController?.isHandlingError ?? false { + WebCacheManager.shared.removeViewControllerForKey(id) + } + + let newViewController = createWebViewController(with: cellViewModel) + WebCacheManager.shared.cacheData(forKey: id, viewController: newViewController) + return newViewController + } + + private func createWebViewController(with viewModel: BoxListCellViewModel) -> WebViewController { + let viewController = WebViewController() + viewController.selectedWebsite = viewModel.url + viewController.title = viewModel.name + viewController.id = viewModel.id + return viewController + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let indexPath = configuration.identifier as? IndexPath, let cell = tableView.cellForRow(at: indexPath) else { + return nil + } + + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + + return UITargetedPreview(view: cell, parameters: parameters) + } + + private func makeContextMenu(for indexPath: IndexPath) -> UIMenu { + let isFavorite = self.viewModel?.isFavoriteBookmark(at: indexPath) ?? false + let favoriteActionTitle = isFavorite ? "즐겨찾기 ν•΄μ œ" : "즐겨찾기둜 등둝" + let favoriteActionImage = UIImage(systemName: isFavorite ? "heart.slash.fill" : "heart.fill") + + let favoriteAction = UIAction(title: favoriteActionTitle, image: favoriteActionImage) { [weak self] action in + self?.viewModel?.input.send(.toggleFavorite(indexPath: indexPath)) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } + + favoriteAction.image?.withTintColor(.box2) + + let shareAction = UIAction(title: "κ³΅μœ ν•˜κΈ°", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] action in + guard let self = self, let url = self.viewModel?.boxList[indexPath.section].boxListCellViewModelsWithStatus[indexPath.row].url else { return } + + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + if let viewController = self.delegate as? UIViewController { + viewController.present(activityViewController, animated: true, completion: nil) + } + } + + let editAction = UIAction(title: "뢁마크 νŽΈμ§‘", image: UIImage(systemName: "pencil")) { [weak self] action in + guard let self = self else { return } + + self.delegate?.presentEditBookmarkController(at: indexPath) + + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } + + let deleteAction = UIAction(title: "μ‚­μ œ", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] action in + self?.viewModel?.input.send(.deleteBookmark(indexPath: indexPath)) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } + + + + return UIMenu(title: "", children: [favoriteAction, shareAction, editAction, deleteAction]) + } +} + +extension BoxListView: BoxListDataSourceDelegate { + func openFolderIfNeeded(_ folderIndex: Int) { + viewModel?.input.send(.openFolderIfNeeded(folderIndex: folderIndex)) + } + + func moveCell(at sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + viewModel?.input.send(.moveBookmark(from: sourceIndexPath, to: destinationIndexPath)) + } + +} + +protocol BoxListDataSourceDelegate: AnyObject { + func moveCell(at sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) + func openFolderIfNeeded(_ folderIndex: Int) +} + +class BoxListDataSource: UITableViewDiffableDataSource { + weak var delegate: BoxListDataSourceDelegate? + + override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + delegate?.moveCell(at: sourceIndexPath, to: destinationIndexPath) + + guard let src = itemIdentifier(for: sourceIndexPath), + sourceIndexPath != destinationIndexPath else { return } + + var snap = snapshot() + if let dest = itemIdentifier(for: destinationIndexPath) { + if sourceIndexPath.section == destinationIndexPath.section && sourceIndexPath.row < destinationIndexPath.row { + snap.moveItem(src, afterItem: dest) + } else { + snap.moveItem(src, beforeItem:dest) + } + } else { + snap.deleteItems([src]) + snap.appendItems([src], toSection: snap.sectionIdentifiers[destinationIndexPath.section]) + } + + self.apply(snap, animatingDifferences: false) + + delegate?.openFolderIfNeeded(destinationIndexPath.section) + } +} diff --git a/iBox/Sources/BoxList/BoxListViewController.swift b/iBox/Sources/BoxList/BoxListViewController.swift new file mode 100644 index 0000000..5c95f7b --- /dev/null +++ b/iBox/Sources/BoxList/BoxListViewController.swift @@ -0,0 +1,303 @@ +// +// BoxListViewController.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 12/27/23. +// + +import UIKit + +import SkeletonView + +class BoxListViewController: BaseViewController, BaseViewControllerProtocol { + + var shouldPresentModalAutomatically: Bool = false { + didSet { + if shouldPresentModalAutomatically { + if let vc = findAddBookmarkViewController() { + if vc.presentedViewController is UIAlertController { + vc.dismiss(animated: false) + } + } else { + dismiss(animated: false) + self.addButtonTapped() + } + shouldPresentModalAutomatically = false + } + } + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.input.send(.viewDidLoad) + contentView.delegate = self + } + + override func viewWillAppear(_ animated: Bool) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.input.send(.viewWillAppear) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: nil) { _ in + self.dismissPreviewIfNeeded() + } + } + + func dismissPreviewIfNeeded() { + if let previewVC = self.presentedViewController as? WebViewController { + previewVC.dismiss(animated: true, completion: nil) + } + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("42Box") + setNavigationBarMenuButtonHidden(false) + setNavigationBarAddButtonAction(#selector(addButtonTapped)) + setNavigationBarMoreButtonAction(#selector(moreButtonTapped)) + setNavigationBarDoneButtonAction(#selector(doneButtonTapped)) + } + + // MARK: - Action Functions + + @objc private func addButtonTapped() { + let addBookmarkViewController = AddBookmarkViewController() + addBookmarkViewController.delegate = self + + let navigationController = UINavigationController(rootViewController: addBookmarkViewController) + + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true, completion: nil) + } + + @objc private func moreButtonTapped() { + let editViewController = EditViewController(bottomSheetHeight: 200) + editViewController.delegate = self + present(editViewController, animated: false) + } + + @objc private func doneButtonTapped() { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.input.send(.toggleEditStatus) + setNavigationBarMenuButtonHidden(false) + setNavigationBarDoneButtonHidden(true) + } + +} + +extension BoxListViewController: AddBookmarkViewControllerProtocol { + func addFolderDirect(_ folder: Folder) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.addFolderDirect(folder) + } + + func addBookmarkDirect(_ bookmark: Bookmark, at folderIndex: Int) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.addBookmarkDirect(bookmark, at: folderIndex) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } + +} + +extension BoxListViewController: BoxListViewDelegate { + func deleteFolderinBoxList(at section: Int) { + recheckDeleteFolder(at: section) + } + + private func recheckDeleteFolder(at section: Int) { + let actionSheetController = UIAlertController(title: nil, message: "λͺ¨λ“  λΆλ§ˆν¬κ°€ μ‚­μ œλ©λ‹ˆλ‹€.", preferredStyle: .alert) + let firstAction = UIAlertAction(title: "폴더 μ‚­μ œ", style: .destructive) {[weak self] _ in + guard let contentView = self?.contentView as? BoxListView else { return } + contentView.viewModel?.deleteFolderDirect(section) + } + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .cancel) + actionSheetController.addAction(firstAction) + actionSheetController.addAction(cancelAction) + present(actionSheetController, animated: true) + } + + func editFolderNameinBoxList(at section: Int, currentName: String) { + let controller = UIAlertController(title: "폴더 이름 λ³€κ²½", message: nil, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .default) { _ in return } + let okAction = UIAlertAction(title: "확인", style: .default) { [weak self] action in + guard let newName = controller.textFields?.first?.text else { return } + guard let contentView = self?.contentView as? BoxListView else { return } + contentView.viewModel?.editFolderDirect(section, name: newName) + } + controller.addAction(cancelAction) + controller.addAction(okAction) + okAction.isEnabled = true + + controller.addTextField() { textField in + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let textCount = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0 + let textIsNotEmpty = textCount > 0 + + okAction.isEnabled = textIsNotEmpty + + }) + } + controller.textFields?.first?.text = currentName + controller.textFields?.first?.autocorrectionType = .no + controller.textFields?.first?.spellCheckingType = .no + + self.present(controller, animated: true) + } + + func presentEditBookmarkController(at indexPath: IndexPath) { + guard let contentView = contentView as? BoxListView else { return } + + let controller = UIAlertController(title: "뢁마크 νŽΈμ§‘", message: nil, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .default) { _ in return } + let okAction = UIAlertAction(title: "확인", style: .default) { [weak self] action in + guard let newName = controller.textFields?.first?.text else { return } + guard let newUrlString = controller.textFields?.last?.text, + let newUrl = URL(string: newUrlString) else { return } + guard let contentView = self?.contentView as? BoxListView else { return } + guard let bookmark = contentView.viewModel?.bookmark(at: indexPath) else { return } + + contentView.viewModel?.editBookmark(at: indexPath, name: newName, url: newUrl) + + WebCacheManager.shared.removeViewControllerForKey(bookmark.id) + } + + controller.addAction(cancelAction) + controller.addAction(okAction) + okAction.isEnabled = true + + controller.addTextField() { textField in + textField.clearButtonMode = .whileEditing + + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let textCount = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0 + let textIsNotEmpty = textCount > 0 + + okAction.isEnabled = textIsNotEmpty + + }) + } + controller.addTextField() { textField in + textField.clearButtonMode = .whileEditing + + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let textCount = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0 + let textIsNotEmpty = textCount > 0 + + okAction.isEnabled = textIsNotEmpty + + }) + } + + guard let bookmark = contentView.viewModel?.bookmark(at: indexPath) else { return } + + controller.textFields?.first?.text = bookmark.name + controller.textFields?.first?.autocorrectionType = .no + controller.textFields?.first?.spellCheckingType = .no + + controller.textFields?.last?.text = bookmark.url.absoluteString + controller.textFields?.last?.autocorrectionType = .no + controller.textFields?.last?.spellCheckingType = .no + + self.present(controller, animated: true) + } + + func didSelectWeb(id: UUID, at url: URL, withName name: String) { + let viewController = getOrCreateWebViewController(id: id, url: url, name: name) + navigationController?.pushViewController(viewController, animated: true) + } + + private func getOrCreateWebViewController(id: UUID, url: URL, name: String) -> WebViewController { + let cachedViewController = WebCacheManager.shared.viewControllerForKey(id) + + if let cachedViewController = cachedViewController, cachedViewController.errorViewController?.isHandlingError == nil { + return cachedViewController + } + + if cachedViewController?.errorViewController?.isHandlingError ?? false { + WebCacheManager.shared.removeViewControllerForKey(id) + } + + return createAndCacheWebViewController(id: id, url: url, name: name) + } + + private func createAndCacheWebViewController(id: UUID, url: URL, name: String) -> WebViewController { + let viewController = WebViewController() + viewController.delegate = self + viewController.selectedWebsite = url + viewController.title = name + viewController.id = id + WebCacheManager.shared.cacheData(forKey: id, viewController: viewController) + return viewController + } + + func pushViewController(type: EditType) { + guard let contentView = contentView as? BoxListView else { return } + switch type { + case .folder: + + let editFolderViewController = EditFolderViewController(folders: contentView.viewModel?.folders ?? []) + editFolderViewController.delegate = self + navigationController?.pushViewController(editFolderViewController, animated: true) + case .bookmark: + contentView.viewModel?.input.send(.toggleEditStatus) + setNavigationBarMenuButtonHidden(true) + setNavigationBarDoneButtonHidden(false) + } + } + + func pushViewController(url: URL?) { + guard let url = url else { return } + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + self.present(activityViewController, animated: true) + } + +} + +extension BoxListViewController: EditFolderViewControllerDelegate { + func moveFolder(from: Int, to: Int) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.moveFolder(from: from, to: to) + } + + func editFolderName(at row: Int, name: String) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.editFolderName(at: row, name: name) + } + + func deleteFolder(at row: Int) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.deleteFolder(at: row) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } + + func addFolder(_ folder: Folder) { + guard let contentView = contentView as? BoxListView else { return } + contentView.viewModel?.addFolder(folder) + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + } +} diff --git a/iBox/Sources/BoxList/BoxListViewModel.swift b/iBox/Sources/BoxList/BoxListViewModel.swift new file mode 100644 index 0000000..adc72f7 --- /dev/null +++ b/iBox/Sources/BoxList/BoxListViewModel.swift @@ -0,0 +1,206 @@ +// +// BoxListViewModel.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/30/24. +// + +import Combine +import Foundation + +class BoxListViewModel { + + var boxList = [BoxListSectionViewModel]() + + var folders: [Folder] { + boxList.map{ $0.folder } + } + + var sectionsToReload = Set() + var rowsToReload = Set() + var isEditing = false + var favoriteId: UUID? = nil + + + enum Input { + case toggleEditStatus + case viewDidLoad + case viewWillAppear + case folderTapped(section: Int) + case deleteBookmark(indexPath: IndexPath) + case toggleFavorite(indexPath: IndexPath) + case moveBookmark(from: IndexPath, to: IndexPath) + case openFolderIfNeeded(folderIndex: Int) + } + + enum Output { + case sendBoxList(boxList: [BoxListSectionViewModel]) + case reloadSections(idArray: [BoxListSectionViewModel.ID]) + case reloadRows(idArray: [BoxListCellViewModel.ID]) + case editStatus(isEditing: Bool) + case openCloseFolder(boxList: [BoxListSectionViewModel], section: Int, isEmpty: Bool) + } + + let input = PassthroughSubject() + private let output = PassthroughSubject() + private var cancellables = Set() + + func transform(input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] event in + guard let self else { return } + switch event { + case .toggleEditStatus: + isEditing.toggle() + output.send(.editStatus(isEditing: isEditing)) + case .viewDidLoad: + let folders = CoreDataManager.shared.getFolders() + boxList = folders.map{ BoxListSectionViewModel(folder: $0) } + favoriteId = UserDefaultsManager.favoriteId + output.send(.sendBoxList(boxList: boxList)) + case .viewWillAppear: + output.send(.sendBoxList(boxList: boxList)) + if !sectionsToReload.isEmpty { + output.send(.reloadSections(idArray: Array(sectionsToReload))) + sectionsToReload.removeAll() + } + case let .folderTapped(section): + boxList[section].isOpened.toggle() + output.send(.openCloseFolder(boxList: boxList, section: section, isEmpty: boxList[section].boxListCellViewModelsWithStatus.isEmpty)) + case let .deleteBookmark(indexPath): + deleteBookmark(at: indexPath) + case let .toggleFavorite(indexPath): + toggleFavorite(at: indexPath) + case .moveBookmark(from: let from, to: let to): + reorderBookmark(srcIndexPath: from, destIndexPath: to) + case .openFolderIfNeeded(folderIndex: let folderIndex): + openFolder(folderIndex) + } + }.store(in: &cancellables) + return output.eraseToAnyPublisher() + } + + func viewModel(at indexPath: IndexPath) -> BoxListCellViewModel { + return boxList[indexPath.section].boxListCellViewModels[indexPath.row] + } + + func isFavoriteBookmark(at indexPath: IndexPath) -> Bool { + if let favoriteId { + if favoriteId == bookmark(at: indexPath).id { + return true + } else { return false } + } else { + return false + } + } + + private func toggleFavorite(at indexPath: IndexPath) { + let bookmark = boxList[indexPath.section].viewModel(at: indexPath.row) + if let prevId = favoriteId { + if prevId == bookmark.id { // μ§€κΈˆ λ“€μ–΄μ˜¨κ²Œ 즐겨찾기면 μ§€μ›Œμ•Ό + WebViewPreloader.shared.setFavoriteUrl(url: nil) + favoriteId = nil + UserDefaultsManager.favoriteId = nil + } else { + WebViewPreloader.shared.setFavoriteUrl(url: bookmark.url) + favoriteId = bookmark.id + } + } else { + WebViewPreloader.shared.setFavoriteUrl(url: bookmark.url) + favoriteId = bookmark.id + } + UserDefaultsManager.favoriteId = favoriteId + } + + func bookmark(at indexPath: IndexPath) -> Bookmark { + return boxList[indexPath.section].viewModel(at: indexPath.row).bookmark + } + + func deleteBookmark(at indexPath: IndexPath) { + let bookmarkId = boxList[indexPath.section].viewModel(at: indexPath.row).id + CoreDataManager.shared.deleteBookmark(id: bookmarkId) + boxList[indexPath.section].deleteCell(at: indexPath.row) + output.send(.sendBoxList(boxList: boxList)) + } + + func editBookmark(at indexPath: IndexPath, name: String, url: URL) { + let bookmarkId = boxList[indexPath.section].viewModel(at: indexPath.row).id + CoreDataManager.shared.updateBookmark(id: bookmarkId, name: name, url: url) + boxList[indexPath.section].updateCell(at: indexPath.row, bookmark: Bookmark(id: bookmarkId, name: name, url: url)) + output.send(.reloadRows(idArray: [bookmarkId])) + } + + func reorderBookmark(srcIndexPath: IndexPath, destIndexPath: IndexPath) { + let mover = boxList[srcIndexPath.section].deleteCell(at: srcIndexPath.row) + boxList[destIndexPath.section].insertCell(mover, at: destIndexPath.row) + + let destFolderId = boxList[destIndexPath.section].id + CoreDataManager.shared.moveBookmark(from: srcIndexPath, to: destIndexPath, srcId: mover.id, destFolderId: destFolderId) + } + + func openFolder(_ folderIndex: Int) { + let destFolderId = boxList[folderIndex].id + if boxList[folderIndex].openSectionIfNeeded() { + output.send(.reloadSections(idArray: [destFolderId])) + output.send(.sendBoxList(boxList: boxList)) + } + } + + func addFolder(_ folder: Folder) { + let boxListSectionViewModel = BoxListSectionViewModel(folder: folder) + boxList.append(boxListSectionViewModel) + } + + func deleteFolder(at row: Int) { + sectionsToReload.remove(boxList[row].id) + boxList.remove(at: row) + for box in boxList { + sectionsToReload.update(with: box.id) + } + } + + func editFolderName(at row: Int, name: String) { + boxList[row].name = name + sectionsToReload.update(with: boxList[row].id) + } + + func moveFolder(from: Int, to: Int) { + let mover = boxList.remove(at: from) + boxList.insert(mover, at: to) + for box in boxList { + sectionsToReload.update(with: box.id) + } + } + + func addFolderDirect(_ folder: Folder) { + let boxListSectionViewModel = BoxListSectionViewModel(folder: folder) + boxList.append(boxListSectionViewModel) + output.send(.sendBoxList(boxList: boxList)) + } + + func addBookmarkDirect(_ bookmark: Bookmark, at index: Int) { + boxList[index].boxListCellViewModels.append(BoxListCellViewModel(bookmark: bookmark)) + output.send(.sendBoxList(boxList: boxList)) + } + + func deleteFolderDirect(_ section: Int) { + let folderId = boxList[section].id + CoreDataManager.shared.deleteFolder(id: folderId) + boxList.remove(at: section) + for box in boxList { + sectionsToReload.update(with: box.id) + } + output.send(.sendBoxList(boxList: boxList)) + output.send(.reloadSections(idArray: Array(sectionsToReload))) + sectionsToReload.removeAll() + } + + func editFolderDirect(_ section: Int, name: String) { + let folderId = boxList[section].id + CoreDataManager.shared.updateFolder(id: folderId, name: name) + boxList[section].name = name + sectionsToReload.update(with: boxList[section].id) + output.send(.reloadSections(idArray: Array(sectionsToReload))) + sectionsToReload.removeAll() + } + +} diff --git a/iBox/Sources/BoxList/Edit/EditCell.swift b/iBox/Sources/BoxList/Edit/EditCell.swift new file mode 100644 index 0000000..9bb7e56 --- /dev/null +++ b/iBox/Sources/BoxList/Edit/EditCell.swift @@ -0,0 +1,72 @@ +// +// EditCell.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import UIKit + +import SnapKit + +class EditCell: UITableViewCell { + + static let reuseIdentifier = "EditCell" + + // MARK: - UI Components + + private let iconView = UIImageView().then { + $0.tintColor = .label + } + + private let titleLabel = UILabel().then { + $0.font = .cellTitleFont + $0.textColor = .label + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + selectionStyle = .none + backgroundColor = .clear + } + + private func setupHierarchy() { + addSubview(iconView) + addSubview(titleLabel) + } + + private func setupLayout() { + iconView.snp.makeConstraints { make in + make.width.height.equalTo(20) + make.leading.equalToSuperview().inset(30) + make.centerY.equalToSuperview() + } + + titleLabel.snp.makeConstraints{ make in + make.leading.equalTo(iconView.snp.trailing).offset(20) + make.centerY.equalToSuperview() + } + } + + // MARK: - Bind + + func bind(_ editItem: EditItem) { + iconView.image = UIImage(systemName: editItem.imageString) + titleLabel.text = editItem.title + } + +} diff --git a/iBox/Sources/BoxList/Edit/EditView.swift b/iBox/Sources/BoxList/Edit/EditView.swift new file mode 100644 index 0000000..5141e4e --- /dev/null +++ b/iBox/Sources/BoxList/Edit/EditView.swift @@ -0,0 +1,87 @@ +// +// EditView.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import UIKit + +import SnapKit + +class EditView: UIView { + + var delegate: EditViewDelegate? + + private let editItems = [ + EditItem(type: .folder, imageString: "folder.fill", title: "폴더 관리"), + EditItem(type: .bookmark, imageString: "bookmark.fill", title: "뢁마크 관리") + ] + + // MARK: - UI Components + + let tableView = UITableView().then { + $0.backgroundColor = .clear + $0.register(EditCell.self, forCellReuseIdentifier: EditCell.reuseIdentifier) + $0.separatorStyle = .none + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .backgroundColor + tableView.delegate = self + tableView.dataSource = self + } + + private func setupHierarchy() { + addSubview(tableView) + } + + private func setupLayout() { + tableView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(40) + make.leading.bottom.trailing.equalToSuperview() + } + } + +} + +extension EditView: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 55 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + delegate?.pushViewController(type: editItems[indexPath.row].type) + } + +} + +extension EditView: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return editItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: EditCell.reuseIdentifier) as? EditCell else { return UITableViewCell() } + cell.bind(editItems[indexPath.row]) + return cell + } + +} diff --git a/iBox/Sources/BoxList/Edit/EditViewController.swift b/iBox/Sources/BoxList/Edit/EditViewController.swift new file mode 100644 index 0000000..e3c8e7b --- /dev/null +++ b/iBox/Sources/BoxList/Edit/EditViewController.swift @@ -0,0 +1,34 @@ +// +// EditViewController.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import UIKit + +protocol EditViewDelegate { + func pushViewController(type: EditType) +} + +class EditViewController: BottomSheetViewController { + + var delegate: BoxListViewDelegate? + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + sheetView.delegate = self + } + +} + +extension EditViewController: EditViewDelegate { + + func pushViewController(type: EditType) { + delegate?.pushViewController(type: type) + dismiss(animated: false) + } + +} diff --git a/iBox/Sources/BoxList/EditFolder/EditFolderCell.swift b/iBox/Sources/BoxList/EditFolder/EditFolderCell.swift new file mode 100644 index 0000000..1d5a169 --- /dev/null +++ b/iBox/Sources/BoxList/EditFolder/EditFolderCell.swift @@ -0,0 +1,88 @@ +// +// FolderCell.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 3/11/24. +// + +import UIKit + +class EditFolderCell: UITableViewCell { + static let reuserIdentifier = "folderCell" + + var onDelete: (() -> Void)? + var onEdit: (() -> Void)? + + private let containerView = UIView() + + private let folderView = FolderView() + + private let editButton = UIButton().then{ + $0.configuration = .plain() + $0.configuration?.image = UIImage(systemName: "ellipsis.circle")?.withTintColor(.box, renderingMode: .alwaysOriginal) + + $0.showsMenuAsPrimaryAction = true + } + + private lazy var nameEditAction = UIAction(title: "이름 λ³€κ²½", image: UIImage(systemName: "pencil")) { [weak self] _ in + self?.onEdit?() + } + + private lazy var deleteAction = UIAction(title: "μ‚­μ œ", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.onDelete?() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + configureEditMenu() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + onEdit = nil + onDelete = nil + } + + private func setupProperty() { + backgroundColor = .tableViewBackgroundColor + selectionStyle = .none + } + + private func setupHierarchy() { + contentView.addSubview(containerView) + containerView.addSubview(folderView) + containerView.addSubview(editButton) + } + + private func setupLayout() { + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + editButton.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.trailing.equalToSuperview().offset(-10) + make.width.equalTo(30) + } + + folderView.snp.makeConstraints { make in + make.top.bottom.leading.equalToSuperview() + make.trailing.equalTo(editButton.snp.leading) + } + } + + private func configureEditMenu() { + editButton.menu = UIMenu(options: .displayInline, children: [nameEditAction, deleteAction]) + } + + func configure(_ name: String) { + folderView.setFolderName(name) + } + +} diff --git a/iBox/Sources/BoxList/EditFolder/EditFolderView.swift b/iBox/Sources/BoxList/EditFolder/EditFolderView.swift new file mode 100644 index 0000000..554baa6 --- /dev/null +++ b/iBox/Sources/BoxList/EditFolder/EditFolderView.swift @@ -0,0 +1,152 @@ +// +// EditFolderView.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import UIKit + +protocol EditFolderViewDelegate: AnyObject { + func deleteFolder(at: IndexPath) + func editFolderName(at: IndexPath, name: String) + func moveFolder(from: Int, to: Int) +} + +class EditFolderView: UIView { + weak var delegate: EditFolderViewDelegate? + + var viewModel: EditFolderViewModel? { + didSet { + viewModel?.delegate = self + tableView.reloadData() + } + } + + private let backgroundView = UIView().then { + $0.clipsToBounds = true + $0.layer.cornerRadius = 20 + $0.backgroundColor = .tableViewBackgroundColor + } + + private let tableView = UITableView().then { + $0.register(EditFolderCell.self, forCellReuseIdentifier: EditFolderCell.reuserIdentifier) + + $0.setEditing(true, animated: true) + $0.backgroundColor = .clear + $0.rowHeight = 50 + $0.separatorInset = .init(top: 0, left: 15, bottom: 0, right: 15) + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + configureTableView() + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func configureTableView() { + tableView.dataSource = self + tableView.delegate = self + } + + private func setupProperty() { + backgroundColor = .backgroundColor + } + + private func setupHierarchy() { + addSubview(backgroundView) + backgroundView.addSubview(tableView) + } + + private func setupLayout() { + backgroundView.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide).inset(10) + make.leading.trailing.bottom.equalTo(safeAreaLayoutGuide).inset(20) + } + + tableView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().offset(10) + make.leading.trailing.equalToSuperview() + } + } + +} + +extension EditFolderView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel?.folderCount ?? 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let viewModel else { fatalError() } + guard let cell = tableView.dequeueReusableCell(withIdentifier: EditFolderCell.reuserIdentifier, for: indexPath) as? EditFolderCell else { fatalError() } + cell.onDelete = { [weak self, weak cell] in + guard let self = self, let cell = cell else { return } + if let currentIndexPath = self.tableView.indexPath(for: cell) { + self.delegate?.deleteFolder(at: currentIndexPath) + } + } + cell.onEdit = { [weak self, weak cell] in + guard let self = self, let cell = cell else { return } + if let currentIndexPath = self.tableView.indexPath(for: cell) { + self.editFolderName(at: currentIndexPath) + } + } + cell.configure(viewModel.folderName(at: indexPath)) + return cell + } + + private func editFolderName(at indexPath: IndexPath) { + guard let viewModel else { return } + delegate?.editFolderName(at: indexPath, name: viewModel.folderName(at: indexPath)) + } + +} + +extension EditFolderView: UITableViewDelegate { + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + return .none + } + + func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + return false + } + + func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard let viewModel else { return } + viewModel.reorderFolder(srcIndexPath: sourceIndexPath, destIndexPath: destinationIndexPath) + delegate?.moveFolder(from: sourceIndexPath.row, to: destinationIndexPath.row) + } +} + +extension EditFolderView: EditFolderViewModelDelegate { + func reloadRow(_ indexPath: IndexPath) { + tableView.reloadRows(at: [indexPath], with: .automatic) + } + + func deleteRow(_ indexPath: IndexPath) { + tableView.deleteRows(at: [indexPath], with: .automatic) + } + + func addRow() { + guard let viewModel else { return } + let indexPath = IndexPath(row: viewModel.folderCount - 1, section: 0) + tableView.insertRows(at: [indexPath], with: .automatic) + } + + func reloadTableView() { + tableView.reloadData() + } +} + diff --git a/iBox/Sources/BoxList/EditFolder/EditFolderViewController.swift b/iBox/Sources/BoxList/EditFolder/EditFolderViewController.swift new file mode 100644 index 0000000..a708a8f --- /dev/null +++ b/iBox/Sources/BoxList/EditFolder/EditFolderViewController.swift @@ -0,0 +1,139 @@ +// +// EditFolderViewController.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import UIKit + +protocol EditFolderViewControllerDelegate: AnyObject { + func addFolder(_ folder: Folder) + func deleteFolder(at row: Int) + func editFolderName(at row: Int, name: String) + func moveFolder(from: Int, to: Int) +} + +class EditFolderViewController: BaseViewController, BaseViewControllerProtocol { + weak var delegate: EditFolderViewControllerDelegate? + + init(folders: [Folder]) { + super.init(nibName: nil, bundle: nil) + setupEditFolderView(folders) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + } + + // MARK: - BaseNavigationBarViewControllerProtocol + + func setupEditFolderView(_ folders: [Folder]) { + guard let contentView = contentView as? EditFolderView else { return } + let viewModel = EditFolderViewModel(folderList: folders) + contentView.viewModel = viewModel + contentView.delegate = self + } + + func setupNavigationBar() { + setNavigationBarTitleLabelText("폴더 관리") + setNavigationBarTitleLabelFont(.semiboldLabelFont) + setNavigationBarAddButtonHidden(false) + setNavigationBarBackButtonHidden(false) + setNavigationBarAddButtonAction(#selector(addButtonTapped)) + } + + @objc private func addButtonTapped() { + let controller = UIAlertController(title: "μƒˆλ‘œμš΄ 폴더", message: "이 ν΄λ”μ˜ 이름을 μž…λ ₯ν•˜μ‹­μ‹œμ˜€.", preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .default) { _ in return } + let okAction = UIAlertAction(title: "확인", style: .default) { [weak self] action in + guard let name = controller.textFields?.first?.text else { return } + let folder = Folder(id: UUID(), name: name, bookmarks: []) + guard let contentView = self?.contentView as? EditFolderView else { return } + contentView.viewModel?.addFolder(folder) + self?.delegate?.addFolder(folder) + } + controller.addAction(cancelAction) + controller.addAction(okAction) + okAction.isEnabled = false + + controller.addTextField() { textField in + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let textCount = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0 + let textIsNotEmpty = textCount > 0 + + okAction.isEnabled = textIsNotEmpty + + }) + } + controller.textFields?.first?.placeholder = "이름" + controller.textFields?.first?.autocorrectionType = .no + controller.textFields?.first?.spellCheckingType = .no + self.present(controller, animated: true) + } + +} + +extension EditFolderViewController: EditFolderViewDelegate { + func moveFolder(from: Int, to: Int) { + delegate?.moveFolder(from: from, to: to) + } + + func deleteFolder(at indexPath: IndexPath) { + recheckDeleteFolder(at: indexPath) + } + + private func recheckDeleteFolder(at indexPath: IndexPath) { + let actionSheetController = UIAlertController(title: nil, message: "λͺ¨λ“  λΆλ§ˆν¬κ°€ μ‚­μ œλ©λ‹ˆλ‹€.", preferredStyle: .alert) + let firstAction = UIAlertAction(title: "폴더 μ‚­μ œ", style: .destructive) {[weak self] _ in + guard let contentView = self?.contentView as? EditFolderView else { return } + contentView.viewModel?.deleteFolder(at: indexPath) + self?.delegate?.deleteFolder(at: indexPath.row) + } + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .cancel) + actionSheetController.addAction(firstAction) + actionSheetController.addAction(cancelAction) + present(actionSheetController, animated: true) + } + + func editFolderName(at indexPath: IndexPath, name: String) { + let controller = UIAlertController(title: "폴더 이름 λ³€κ²½", message: nil, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .default) { _ in return } + let okAction = UIAlertAction(title: "확인", style: .default) { [weak self] action in + guard let newName = controller.textFields?.first?.text else { return } + guard let contentView = self?.contentView as? EditFolderView else { return } + contentView.viewModel?.editFolderName(at: indexPath, name: newName) + self?.delegate?.editFolderName(at: indexPath.row, name: newName) + } + controller.addAction(cancelAction) + controller.addAction(okAction) + okAction.isEnabled = true + + controller.addTextField() { textField in + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let textCount = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0 + let textIsNotEmpty = textCount > 0 + + okAction.isEnabled = textIsNotEmpty + + }) + } + controller.textFields?.first?.text = name + controller.textFields?.first?.autocorrectionType = .no + controller.textFields?.first?.spellCheckingType = .no + + self.present(controller, animated: true) + } + +} diff --git a/iBox/Sources/BoxList/EditFolder/EditFolderViewModel.swift b/iBox/Sources/BoxList/EditFolder/EditFolderViewModel.swift new file mode 100644 index 0000000..5ec7149 --- /dev/null +++ b/iBox/Sources/BoxList/EditFolder/EditFolderViewModel.swift @@ -0,0 +1,59 @@ +// +// EditFolderViewModel.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 3/11/24. +// + +import Combine +import Foundation + +protocol EditFolderViewModelDelegate: AnyObject { + func reloadRow(_ indexPath: IndexPath) + func deleteRow(_ indexPath: IndexPath) + func reloadTableView() + func addRow() +} + +class EditFolderViewModel { + weak var delegate: EditFolderViewModelDelegate? + var folderList: [Folder] + + private let output = PassthroughSubject() + + init(folderList: [Folder]) { + self.folderList = folderList + } + + var folderCount: Int { + folderList.count + } + + func addFolder(_ folder: Folder) { + CoreDataManager.shared.addFolder(folder) + folderList.append(folder) + delegate?.addRow() + } + + func deleteFolder(at indexPath: IndexPath) { + CoreDataManager.shared.deleteFolder(id: folderList[indexPath.row].id) + folderList.remove(at: indexPath.row) + delegate?.deleteRow(indexPath) + } + + func editFolderName(at indexPath: IndexPath, name: String) { + CoreDataManager.shared.updateFolder(id: folderList[indexPath.row].id, name: name) + folderList[indexPath.row].name = name + delegate?.reloadRow(indexPath) + } + + func folderName(at indexPath: IndexPath) -> String { + return folderList[indexPath.row].name + } + + func reorderFolder(srcIndexPath: IndexPath, destIndexPath: IndexPath) { + let mover = folderList.remove(at: srcIndexPath.row) + folderList.insert(mover, at: destIndexPath.row) + CoreDataManager.shared.moveFolder(from: srcIndexPath.row, to: destIndexPath.row) + } +} diff --git a/iBox/Sources/BoxList/FolderButton.swift b/iBox/Sources/BoxList/FolderButton.swift new file mode 100644 index 0000000..81f548e --- /dev/null +++ b/iBox/Sources/BoxList/FolderButton.swift @@ -0,0 +1,87 @@ +// +// FolderButton.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/4/24. +// + +import UIKit + +import SnapKit + +class FolderButton: UIButton { + + private var isOpen: Bool = true + + // MARK: - UI Components + + private let folderView = FolderView().then { + $0.isUserInteractionEnabled = false + } + + private let openCloseImageView = UIImageView().then { + $0.tintColor = .tertiaryLabel + $0.contentMode = .scaleAspectFit + } + + // MARK: - Initializer + + init(isOpen: Bool) { + self.isOpen = isOpen + super.init(frame: .zero) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .tableViewBackgroundColor + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium, scale: .default) + openCloseImageView.image = UIImage(systemName: "chevron.right", withConfiguration: config) + if isOpen { + openCloseImageView.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 2) + } + } + + private func setupHierarchy() { + addSubview(folderView) + addSubview(openCloseImageView) + } + + private func setupLayout() { + openCloseImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.width.height.equalTo(15) + make.trailing.equalToSuperview().offset(-20) + } + + folderView.snp.makeConstraints { make in + make.top.bottom.leading.equalToSuperview() + make.trailing.equalTo(openCloseImageView.snp.leading) + } + } + + func setFolderName(_ name: String) { + folderView.setFolderName(name) + } + + func toggleStatus() { + isOpen = !isOpen + if isOpen { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.openCloseImageView.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 2) + } + } else { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.openCloseImageView.transform = CGAffineTransform(rotationAngle: 0) + } + } + + } +} diff --git a/iBox/Sources/BoxList/FolderView.swift b/iBox/Sources/BoxList/FolderView.swift new file mode 100644 index 0000000..37af8c4 --- /dev/null +++ b/iBox/Sources/BoxList/FolderView.swift @@ -0,0 +1,63 @@ +// +// FolderView.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 3/11/24. +// + +import UIKit + +class FolderView: UIView { + private let folderImageView = UIImageView().then { + $0.image = UIImage(systemName: "folder.fill") + $0.contentMode = .scaleAspectFit + $0.tintColor = .gray + } + + private let folderNameLabel = UILabel().then { + $0.textColor = .label + $0.font = .boldLabelFont + } + + init() { + super.init(frame: .zero) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .tableViewBackgroundColor + } + + private func setupHierarchy() { + addSubview(folderImageView) + addSubview(folderNameLabel) + } + + private func setupLayout() { + folderImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.width.height.equalTo(25) + make.leading.equalToSuperview().offset(20) + } + + folderNameLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(folderImageView.snp.trailing).offset(10) + make.trailing.equalToSuperview().inset(10) + } + } + + func setFolderName(_ name: String) { + folderNameLabel.text = name + } +} + + diff --git a/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenView.swift b/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenView.swift new file mode 100644 index 0000000..d24fcf1 --- /dev/null +++ b/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenView.swift @@ -0,0 +1,79 @@ +// +// CustomLaunchScreenView.swift +// iBox +// +// Created by Chan on 3/2/24. +// + +import UIKit + +import SnapKit + +class CustomLaunchScreenView: UIView { + private let logoImageView = UIImageView() + private var imageViews: [UIImageView] = [] + private let images = ["fox_page0", "fox_page1", "fox_page2", "fox_page3", "fox_page4"] + private var timer: Timer? + + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureUI() + } + + private func configureUI() { + backgroundColor = .backgroundColor + logoImageView.image = UIImage(named: "LaunchIcon") + logoImageView.contentMode = .scaleAspectFit + addSubview(logoImageView) + + for imageName in images { + let imageView = UIImageView(image: UIImage(named: imageName)) + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + imageView.tintColor = .box2 + addSubview(imageView) + imageViews.append(imageView) + } + + logoImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(200) + } + + imageViews.forEach { imageView in + imageView.snp.makeConstraints { make in + make.bottom.equalToSuperview().inset(20) + make.left.equalToSuperview().inset(20) + make.width.height.equalTo(32) + } + } + + changeImages() + } + + private func changeImages() { + var currentIndex = 0 + + timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in + guard let self = self else { return } + + let state = AppStateManager.shared.versionCheckCompleted + if state == .success || state == .later || state == .maxRetryReached { + timer.invalidate() + self.timer = nil + return + } + + self.imageViews.forEach { $0.isHidden = true } + self.imageViews[currentIndex].isHidden = false + + currentIndex = (currentIndex + 1) % self.imageViews.count + } + } +} diff --git a/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenViewController.swift b/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenViewController.swift new file mode 100644 index 0000000..1c63d5b --- /dev/null +++ b/iBox/Sources/CustomLaunchScreen/CustomLaunchScreenViewController.swift @@ -0,0 +1,93 @@ +// +// CustomLaunchScreenViewController.swift +// iBox +// +// Created by Chan on 3/2/24. +// + +import UIKit +import Combine + +class CustomLaunchScreenViewController: UIViewController { + private var customLaunchScreenView: CustomLaunchScreenView! + private var cancellables: Set = [] + private var urlContext: UIOpenURLContext? + + init(urlContext: UIOpenURLContext?) { + self.urlContext = urlContext + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureLaunchScreenView() + observeVersionCheckCompletion() + } + + private func configureLaunchScreenView() { + customLaunchScreenView = CustomLaunchScreenView(frame: self.view.bounds) + self.view.addSubview(customLaunchScreenView) + + customLaunchScreenView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + + // MARK: - Custom Update Checker View (μ˜ˆμ •) + private func observeVersionCheckCompletion() { + AppStateManager.shared.$versionCheckCompleted + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success, .maxRetryReached, .later: + self?.transitionToNextScreen() + print("App 정상 μ‹€ν–‰") + case .urlError: + print("URL μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.") + case .networkError: + print("λ„€νŠΈμ›Œν¬ μš”μ²­μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.") + case .decodingError: + print("응닡 디코딩에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.") + case .versionOutdated(let mandatoryUpdate, let updateUrl): + if mandatoryUpdate { + print("ν•„μˆ˜ μ—…λ°μ΄νŠΈκ°€ ν•„μš”ν•©λ‹ˆλ‹€. μ—…λ°μ΄νŠΈ URL: \(updateUrl)") + } else { + print("μ—…λ°μ΄νŠΈκ°€ μžˆμŠ΅λ‹ˆλ‹€. μ—…λ°μ΄νŠΈ URL: \(updateUrl)") + } + case .serverError: + print("μ„œλ²„ μ—λŸ¬ λ˜λŠ” 기타 μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.") + case .update: + print("μ—…λ°μ΄νŠΈ 클릭") + case .internalSceneError: + print("scene error μˆ˜μ§‘") + case .internalInfoError: + print("info error μˆ˜μ§‘") + case .initial: + self?.startupFlow() + print("init") + } + } + .store(in: &cancellables) + } + + private func startupFlow() { + DefaultData.insertDefaultDataIfNeeded() + } + + private func transitionToNextScreen() { + guard let window = self.view.window else { return } + + let mainViewController = MainTabBarController() + window.rootViewController = mainViewController + + if let urlContext = self.urlContext, + let tabBarController = window.rootViewController as? UITabBarController { + AddBookmarkManager.shared.navigateToAddBookmarkView(from: urlContext.url, in: tabBarController) + } + + UIView.transition(with: window, duration: 0.5, options: .transitionCrossDissolve, animations: {}, completion: nil) + } +} diff --git a/iBox/Sources/Error/ErrorCode.swift b/iBox/Sources/Error/ErrorCode.swift new file mode 100644 index 0000000..65a90fe --- /dev/null +++ b/iBox/Sources/Error/ErrorCode.swift @@ -0,0 +1,18 @@ +// +// ErrorCode.swift +// iBox +// +// Created by Chan on 4/23/24. +// + +import Foundation + +enum ViewErrorCode: Equatable { + case normal + case unknown + case webContentProcessTerminated + case webViewInvalidated + case javaScriptExceptionOccurred + case javaScriptResultTypeIsUnsupported + case networkError(URLError) +} diff --git a/iBox/Sources/Error/ErrorPageView.swift b/iBox/Sources/Error/ErrorPageView.swift new file mode 100644 index 0000000..8c3836b --- /dev/null +++ b/iBox/Sources/Error/ErrorPageView.swift @@ -0,0 +1,187 @@ +// +// ErrorPageView.swift +// iBox +// +// Created by Chan on 4/18/24. +// + +import UIKit + +import SnapKit + +class ErrorPageView: UIView { + private var imageViews: [UIImageView] = [] + private let images = ["fox_page0", "fox_page1", "fox_page2", "fox_page3", "fox_page4"] + var timer: Timer? + + private let backPannelView = UIView().then { + $0.backgroundColor = .backgroundColor + $0.clipsToBounds = true + $0.layer.cornerRadius = 15 + } + + private let problemUrlLabel = UILabel().then { + $0.font = .cellTitleFont + $0.textAlignment = .center + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + } + + private let messageLabel = UILabel().then { + $0.textAlignment = .center + $0.numberOfLines = 0 + $0.text = "ν•΄λ‹Ή μ£Όμ†Œμ—μ„œ λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + $0.font = .boldLabelFont + } + + let closeButton = UIButton().then { + $0.configuration = .plain() + $0.configuration?.attributedTitle = .init( + "λ‹«κΈ°", + attributes: .init([.font: UIFont.boldLabelFont, .foregroundColor: UIColor.white]) + ) + $0.setTitleColor(.white, for: .normal) + $0.backgroundColor = .systemGray + $0.layer.cornerRadius = 10 + $0.addAnimationForStateChange(from: .box, to: .systemGray) + } + + let retryButton = UIButton().then { + $0.configuration = .plain() + $0.configuration?.attributedTitle = .init( + "μƒˆλ‘œκ³ μΉ¨", + attributes: .init([.font: UIFont.boldLabelFont, .foregroundColor: UIColor.white]) + ) + $0.setTitleColor(.white, for: .normal) + $0.backgroundColor = .systemGray + $0.layer.cornerRadius = 10 + $0.addAnimationForStateChange(from: .box, to: .systemGray) + } + + let backButton = UIButton().then { + $0.configuration = .plain() + $0.configuration?.attributedTitle = .init( + "λ‚˜κ°€κΈ°", + attributes: .init([.font: UIFont.boldLabelFont, .foregroundColor: UIColor.white]) + ) + $0.backgroundColor = .box2 + $0.layer.cornerRadius = 10 + $0.addAnimationForStateChange(from: .box, to: .box2) + } + + let stackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 20 + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupAnimation() + setupLayout() + changeImages() + } + + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + timer?.invalidate() + timer = nil + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupProperty() { + changeImages() + } + + private func setupHierarchy() { + addSubview(backPannelView) + backPannelView.addSubview(messageLabel) + backPannelView.addSubview(problemUrlLabel) + backPannelView.addSubview(stackView) + stackView.addArrangedSubview(backButton) + stackView.addArrangedSubview(retryButton) + stackView.addArrangedSubview(closeButton) + } + + private func setupAnimation() { + for imageName in images { + let imageView = UIImageView(image: UIImage(named: imageName)) + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + imageView.tintColor = .box2 + addSubview(imageView) + imageViews.append(imageView) + } + } + + private func setupLayout() { + imageViews.forEach { imageView in + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(backPannelView.snp.top).offset(5) + make.width.height.equalTo(38) + } + } + + backPannelView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(100) + make.trailing.leading.equalToSuperview().inset(20) + make.height.equalToSuperview().multipliedBy(0.4) + } + + problemUrlLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(20) + make.bottom.equalTo(messageLabel.snp.top).offset(-20) + make.leading.trailing.equalToSuperview().inset(20) + } + + messageLabel.snp.makeConstraints { make in + make.bottom.equalTo(stackView.snp.top).offset(-20) + make.leading.trailing.equalToSuperview().inset(20) + } + + stackView.snp.makeConstraints { make in + make.leading.bottom.trailing.equalToSuperview().inset(20) + } + + [backButton, retryButton, closeButton].forEach { button in + button.snp.makeConstraints { make in + make.height.equalTo(40) + } + } + } + + func configure(with error: Error, url: String) { + problemUrlLabel.text = "\(url)" + print(error.localizedDescription) + } + + private func changeImages() { + var currentIndex = 0 + + timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + let state = AppStateManager.shared.currentViewErrorState + + if state == .normal { + return + } + + self.imageViews.forEach { $0.isHidden = true } + self.imageViews[currentIndex].isHidden = false + + currentIndex = (currentIndex + 1) % self.imageViews.count + } + } +} diff --git a/iBox/Sources/Error/ErrorPageViewController.swift b/iBox/Sources/Error/ErrorPageViewController.swift new file mode 100644 index 0000000..0cc570e --- /dev/null +++ b/iBox/Sources/Error/ErrorPageViewController.swift @@ -0,0 +1,154 @@ +// +// ErrorPageViewController.swift +// iBox +// +// Created by Chan on 4/18/24. +// + +import UIKit +import WebKit + +protocol ErrorPageControllerDelegate: AnyObject { + func presentErrorPage(_ errorPage: ErrorPageViewController) + func backButton() +} + +protocol WebViewErrorDelegate { + func webView(_ webView: WebView, didFailWithError error: Error, url: URL?) +} + +class ErrorPageViewController: UIViewController { + weak var delegate: ErrorPageControllerDelegate? + var webView: WebView? + var isHandlingError = false + + let slideInPresentationManager = SlideInPresentationManager() + + init(webView: WebView) { + super.init(nibName: nil, bundle: nil) + self.webView = webView + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = ErrorPageView() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupProperty() + setupPresentation() + } + + override func viewWillDisappear(_ animated: Bool) { + resetErrorHandling() + } + + private func setupProperty() { + if let errorPageView = view as? ErrorPageView { + errorPageView.retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + errorPageView.closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + errorPageView.backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + } + } + + func setupPresentation() { + self.modalPresentationStyle = .custom + self.transitioningDelegate = slideInPresentationManager + } + + func configureWithError(_ error: Error, url: String) { + if let errorPageView = view as? ErrorPageView { + errorPageView.configure(with: error, url: url) + } + } + + @objc private func retryButtonTapped() { + webView?.retryLoading() + } + + @objc private func closeButtonTapped() { + dismiss(animated: true) + } + + @objc func backButtonTapped() { + self.dismiss(animated: true) { + self.delegate?.backButton() + } + } + + func handleError(_ error: Error, _ url: URL?) { + guard !isHandlingError else { return } + isHandlingError = true + + if presentedViewController != nil { + dismiss(animated: true) { + self.configureWithError(error, url: url?.absoluteString ?? "") + self.delegate?.presentErrorPage(self) + } + } else { + configureWithError(error, url: url?.absoluteString ?? "") + delegate?.presentErrorPage(self) + } + } + + func resetErrorHandling() { + isHandlingError = false + } + + private func convertErrorToViewErrorCode(_ error: Error) -> ViewErrorCode { + if let wkError = error as? WKError { + switch wkError.code { + case .webContentProcessTerminated: + return .webContentProcessTerminated + case .webViewInvalidated: + return .webViewInvalidated + case .javaScriptExceptionOccurred: + return .javaScriptExceptionOccurred + case .javaScriptResultTypeIsUnsupported: + return .javaScriptResultTypeIsUnsupported + default: + return .unknown + } + } else if let urlError = error as? URLError { + return .networkError(urlError) + } + + return .unknown + } + + private func handleViewError(_ error: ViewErrorCode) { + switch error { + case .normal: + print("OK.") + case .unknown: + print("Unknown error occurred in the view.") + case .webContentProcessTerminated: + print("Web content process has been terminated unexpectedly.") + case .webViewInvalidated: + print("The web view has been invalidated.") + case .javaScriptExceptionOccurred: + print("A JavaScript exception occurred.") + case .javaScriptResultTypeIsUnsupported: + print("JavaScript returned a result type that is not supported.") + case .networkError(let urlError): + print("Network error occurred: \(urlError.localizedDescription)") + } + + AppStateManager.shared.updateViewError(error) + } +} + +extension ErrorPageViewController: WebViewErrorDelegate { + + func webView(_ webView: WebView, didFailWithError error: Error, url: URL?) { + handleError(error, url) + + let viewErrorCode = convertErrorToViewErrorCode(error) + handleViewError(viewErrorCode) + } + +} diff --git a/iBox/Sources/Extension/Notification+Extension.swift b/iBox/Sources/Extension/Notification+Extension.swift new file mode 100644 index 0000000..fbe5b65 --- /dev/null +++ b/iBox/Sources/Extension/Notification+Extension.swift @@ -0,0 +1,15 @@ +// +// Notification+Extension.swift +// iBox +// +// Created by jiyeon on 4/11/24. +// + +import Foundation + +extension Notification.Name { + + static let didResetData = Notification.Name("didResetData") + static let didAddBookmark = Notification.Name("didAddBookmark") + +} diff --git a/iBox/Sources/Extension/UIButton+Extension.swift b/iBox/Sources/Extension/UIButton+Extension.swift new file mode 100644 index 0000000..16dee87 --- /dev/null +++ b/iBox/Sources/Extension/UIButton+Extension.swift @@ -0,0 +1,55 @@ +// +// UIButton+Extension.swift +// iBox +// +// Created by Chan on 4/25/24. +// + +import UIKit +import ObjectiveC + +private var touchDownColorKey: UInt8 = 0 +private var touchUpColorKey: UInt8 = 0 + +extension UIButton { + var touchDownColor: UIColor? { + get { + return objc_getAssociatedObject(self, &touchDownColorKey) as? UIColor + } + set { + objc_setAssociatedObject(self, &touchDownColorKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var touchUpColor: UIColor? { + get { + return objc_getAssociatedObject(self, &touchUpColorKey) as? UIColor + } + set { + objc_setAssociatedObject(self, &touchUpColorKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func addAnimationForStateChange(from: UIColor, to: UIColor) { + self.touchDownColor = from + self.touchUpColor = to + addTarget(self, action: #selector(animateOnTouchDown), for: .touchDown) + addTarget(self, action: #selector(animateOnTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + } + + @objc private func animateOnTouchDown() { + if let color = touchDownColor { + UIView.animate(withDuration: 0.3) { + self.backgroundColor = color + } + } + } + + @objc private func animateOnTouchUp() { + if let color = touchUpColor { + UIView.animate(withDuration: 0.3) { + self.backgroundColor = color + } + } + } +} diff --git a/iBox/Sources/Extension/UIColor+Extension.swift b/iBox/Sources/Extension/UIColor+Extension.swift new file mode 100644 index 0000000..2a5fc3f --- /dev/null +++ b/iBox/Sources/Extension/UIColor+Extension.swift @@ -0,0 +1,43 @@ +// +// UIColor+Extension.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +extension UIColor { + + convenience init(hex: UInt, alpha: CGFloat = 1.0) { + self.init( + red: CGFloat((hex & 0xFF0000) >> 16) / 255.0, + green: CGFloat((hex & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(hex & 0x0000FF) / 255.0, + alpha: CGFloat(alpha) + ) + } + + private static func color(light: UIColor, dark: UIColor) -> UIColor { + return UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return dark + default: + return light + } + } + } + + static let box = UIColor(hex: 0xFF7F29) + static let box2 = UIColor(hex: 0xFF9548) + static let box3 = UIColor(hex: 0xFFDC6E) + static let boxWithOpacity = UIColor(hex: 0xFF7F29, alpha: 0.7) + static let box2WithOpacity = UIColor(hex: 0xFF9548, alpha: 0.7) + static let tableViewBackgroundColor = color(light: .systemGroupedBackground, dark: .systemGray4) + static let folderGray = color(light: .systemGray3, dark: .systemGray2) + static let webIconColor = color(light: .black, dark: .systemGray) + static let dimmedViewColor = UIColor.black.withAlphaComponent(0.75) + static let backgroundColor = color(light: .white, dark: UIColor(hex: 0x242424)) + static let invertBackgroundColor = color(light: UIColor(hex: 0x242424), dark: .white) +} diff --git a/iBox/Sources/Extension/UIFont+Extension.swift b/iBox/Sources/Extension/UIFont+Extension.swift new file mode 100644 index 0000000..cdca19a --- /dev/null +++ b/iBox/Sources/Extension/UIFont+Extension.swift @@ -0,0 +1,23 @@ +// +// UIFont+Extension.swift +// iBox +// +// Created by jiyeon on 3/26/24. +// + +import UIKit + +extension UIFont { + + static let titleFont = UIFont.systemFont(ofSize: 20.0, weight: .bold) + static let subTitlefont = UIFont.systemFont(ofSize: 16.0, weight: .semibold) + static let barItemFont = UIFont.systemFont(ofSize: 14.5, weight: .semibold) + static let labelFont = UIFont.systemFont(ofSize: 16.0) + static let semiboldLabelFont = UIFont.systemFont(ofSize: 16.0, weight: .semibold) + static let boldLabelFont = UIFont.systemFont(ofSize: 14.5, weight: .bold) + static let cellTitleFont = UIFont.systemFont(ofSize: 14.5) + static let cellDescriptionFont = UIFont.systemFont(ofSize: 12.0, weight: .regular) + static let descriptionFont = UIFont.systemFont(ofSize: 12.5) + static let refreshControlFont = UIFont.boldSystemFont(ofSize: 11.0) + static let emptyLabelFont = UIFont.systemFont(ofSize: 15.0, weight: .regular) +} diff --git a/iBox/Sources/Extension/UIImage+Extension.swift b/iBox/Sources/Extension/UIImage+Extension.swift new file mode 100644 index 0000000..1b7e640 --- /dev/null +++ b/iBox/Sources/Extension/UIImage+Extension.swift @@ -0,0 +1,27 @@ +// +// UIImage+Extension.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 4/11/24. +// + +import UIKit + +extension UIImage { + func imageWithColor(_ color: UIColor) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale); + guard let context = UIGraphicsGetCurrentContext(), let cgImage = self.cgImage else { return nil } + + context.translateBy(x: 0, y: self.size.height) + context.scaleBy(x: 1.0, y: -1.0); + context.setBlendMode(.normal) + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + context.clip(to: rect, mask: cgImage) + color.setFill() + context.fill(rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext(); + + return newImage + } +} diff --git a/iBox/Sources/Extension/UIView+Extension.swift b/iBox/Sources/Extension/UIView+Extension.swift new file mode 100644 index 0000000..e7f584c --- /dev/null +++ b/iBox/Sources/Extension/UIView+Extension.swift @@ -0,0 +1,54 @@ +// +// UIView+Extension.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +protocol Then {} + +extension Then where Self: AnyObject { + + func then(block: (Self) -> Void) -> Self { + block(self) + return self + } + +} + +extension UIView: Then {} + +extension UIView { + + func toUserInterfaceStyle(_ theme: Theme) -> UIUserInterfaceStyle { + switch theme { + case .light: return UIUserInterfaceStyle.light + case .dark: return UIUserInterfaceStyle.dark + case .system: return UIUserInterfaceStyle.unspecified + } + } + + func roundCorners(_ corners: UIRectCorner, radius: CGFloat) { + DispatchQueue.main.async { // ν™•μ‹€νžˆ 메인 μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰λ˜λ„λ‘ κ°•μ œ + let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + let mask = CAShapeLayer() + mask.path = path.cgPath + mask.frame = self.bounds + self.layer.mask = mask + } + } + + // MARK: - λ·° 계측 ꡬ쑰 log + func printViewHierarchy(level: Int = 0) { + let padding = String(repeating: " ", count: level * 2) + let viewInfo = "\(padding)\(type(of: self)) - Frame: \(self.frame)" + print(viewInfo) + + for subview in self.subviews { + subview.printViewHierarchy(level: level + 1) + } + } + +} diff --git a/iBox/Sources/Extension/UIViewController+Extension.swift b/iBox/Sources/Extension/UIViewController+Extension.swift new file mode 100644 index 0000000..71a9797 --- /dev/null +++ b/iBox/Sources/Extension/UIViewController+Extension.swift @@ -0,0 +1,30 @@ +// +// UIViewController+Extension.swift +// iBox +// +// Created by Chan on 4/16/24. +// + +import UIKit + +extension UIViewController { + func findMainTabBarController() -> UITabBarController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UITabBarController { + return viewController + } + responder = nextResponder + } + return nil + } + + func findAddBookmarkViewController() -> AddBookmarkViewController? { + if let navigationController = presentedViewController as? UINavigationController, + let vc = navigationController.topViewController as? AddBookmarkViewController { + return vc + } + return nil + } + +} diff --git a/iBox/Sources/Favorite/FavoriteView.swift b/iBox/Sources/Favorite/FavoriteView.swift new file mode 100644 index 0000000..fd475f3 --- /dev/null +++ b/iBox/Sources/Favorite/FavoriteView.swift @@ -0,0 +1,68 @@ +// +// FavoriteView.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/18/24. +// + +import UIKit +import WebKit + +import SnapKit + +class FavoriteView: UIView { + + var webView: WebView? + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + webView?.setupRefreshControl() + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .backgroundColor + + loadFavoriteWeb() + webView = WebViewPreloader.shared.getFavoriteView() + } + + private func setupHierarchy() { + guard let webView else { return } + addSubview(webView) + } + + private func setupLayout() { + guard let webView else { return } + webView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func loadFavoriteWeb() { + let favoriteId = UserDefaultsManager.favoriteId + var favoriteUrl: URL? = nil + if let favoriteId { + favoriteUrl = CoreDataManager.shared.getBookmarkUrl(favoriteId) + if favoriteUrl == nil { + UserDefaultsManager.favoriteId = nil + } + } + WebViewPreloader.shared.preloadFavoriteView(url: favoriteUrl) + } + +} diff --git a/iBox/Sources/Favorite/FavoriteViewController.swift b/iBox/Sources/Favorite/FavoriteViewController.swift new file mode 100644 index 0000000..237813c --- /dev/null +++ b/iBox/Sources/Favorite/FavoriteViewController.swift @@ -0,0 +1,44 @@ +// +// FavoriteViewController.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 12/27/23. +// + +import UIKit + +class FavoriteViewController: BaseViewController, BaseViewControllerProtocol { + + var delegate: AddBookmarkViewControllerProtocol? + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? FavoriteView else { return } + contentView.webView?.delegate = self + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarHidden(true) + } + +} + +extension FavoriteViewController: WebViewDelegate { + + func pushAddBookMarkViewController(url: URL) { + let encodingURL = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + + if let iBoxUrl = URL(string: "iBox://url?data=" + encodingURL) { + if let tabBarController = findMainTabBarController() { + AddBookmarkManager.shared.navigateToAddBookmarkView(from: iBoxUrl, in: tabBarController) + } + } + } + +} diff --git a/iBox/Sources/Initializer/DefaultData.swift b/iBox/Sources/Initializer/DefaultData.swift new file mode 100644 index 0000000..4cc8522 --- /dev/null +++ b/iBox/Sources/Initializer/DefaultData.swift @@ -0,0 +1,50 @@ +// +// DefaultData.swift +// iBox +// +// Created by Chan on 4/17/24. +// + +import Foundation + + +class DefaultData { + + static func insertDefaultDataIfNeeded(_ isReset: Bool = false, completion: (() -> Void)? = nil) { + let isDefaultDataInserted = UserDefaultsManager.isDefaultDataInserted + if !isDefaultDataInserted || isReset { + fetchDefaultData { defaultFolders in + DispatchQueue.main.async { + CoreDataManager.shared.deleteAllFolders() + CoreDataManager.shared.addInitialFolders(defaultFolders) + UserDefaultsManager.isDefaultDataInserted = true + completion?() + } + } + } + } + + static func fetchDefaultData(completion: @escaping ([Folder]) -> Void) { + let localDic: [String : String] = ["Seoul" : "default-kr", "default" : "default"] + let cityName = "Seoul" // μΆ”ν›„ global μ˜ˆμ • + let local = localDic[cityName] ?? "default" + + let url = URL(string: "https://raw.githubusercontent.com/42Box/versioning/main/\(local).json")! + URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data, error == nil else { + print("Error fetching default data: \(String(describing: error))") + completion(DefaultDataLoader.defaultData) + return + } + + do { + let folderData = try JSONDecoder().decode(FolderData.self, from: data) + let folders = [Folder(id: UUID(), name: "42 \(cityName)", bookmarks: folderData.list.map { Bookmark(id: UUID(), name: $0.name, url: URL(string: $0.url)!) })] + completion(folders) + } catch { + print("Error decoding JSON: \(error)") + completion(DefaultDataLoader.defaultData) + } + }.resume() + } +} diff --git a/iBox/Sources/Initializer/DefaultDataModel.swift b/iBox/Sources/Initializer/DefaultDataModel.swift new file mode 100644 index 0000000..7a3bb89 --- /dev/null +++ b/iBox/Sources/Initializer/DefaultDataModel.swift @@ -0,0 +1,31 @@ +// +// DefaultDataModel.swift +// iBox +// +// Created by Chan on 4/17/24. +// + +import Foundation + + +struct FolderData: Codable { + var list: [BookmarkData] +} + +struct BookmarkData: Codable { + var name: String + var url: String +} + +struct DefaultDataLoader { + static let defaultData = [ + Folder(id: UUID(), name: "42 폴더", bookmarks: [ + Bookmark(id: UUID(), name: "42 Intra", url: URL(string: "https://profile.intra.42.fr/")!), + Bookmark(id: UUID(), name: "42Where", url: URL(string: "https://www.where42.kr/")! ), + Bookmark(id: UUID(), name: "42Stat", url: URL(string: "https://stat.42seoul.kr/")!), + Bookmark(id: UUID(), name: "μ§‘ν˜„μ „", url: URL(string: "https://42library.kr/")!), + Bookmark(id: UUID(), name: "42gg", url: URL(string: "https://gg.42seoul.kr/")!), + Bookmark(id: UUID(), name: "24HANE", url: URL(string: "https://24hoursarenotenough.42seoul.kr/")!) + ]) + ] +} diff --git a/iBox/Sources/Main/MainTabBarController.swift b/iBox/Sources/Main/MainTabBarController.swift new file mode 100644 index 0000000..f1b8329 --- /dev/null +++ b/iBox/Sources/Main/MainTabBarController.swift @@ -0,0 +1,66 @@ +// +// MainTabBarController.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 12/27/23. +// + +import UIKit + +class MainTabBarController: UITabBarController { + + var previousTabIndex = 0 + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + delegate = self + view.backgroundColor = .backgroundColor + + setupTabBar() + setupTabBarAppearance() + } + + // MARK: - Setup Methods + + private func setupTabBar() { + viewControllers = [ + setupViewController(viewController: BoxListViewController(), image: UIImage(systemName: "square.grid.2x2.fill")), + setupViewController(viewController: FavoriteViewController(), image: UIImage(systemName: "heart.fill")), + setupViewController(viewController: SettingsViewController(), image: UIImage(systemName: "gearshape.fill")) + ] + tabBar.tintColor = .box + tabBar.backgroundColor = .backgroundColor + selectedIndex = UserDefaultsManager.homeTabIndex + } + + private func setupViewController(viewController: UIViewController, image: UIImage?) -> UIViewController { + viewController.tabBarItem.title = "" + viewController.tabBarItem.image = image + return UINavigationController(rootViewController: viewController) + } + + private func setupTabBarAppearance() { + let appearance = UITabBarItem.appearance() + appearance.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.clear], for: .normal) + appearance.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.clear], for: .selected) + } + +} + +extension MainTabBarController: UITabBarControllerDelegate { + + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + } + if tabBarController.selectedIndex == 1 && previousTabIndex == 1 { + WebViewPreloader.shared.resetFavoriteView() + } + previousTabIndex = tabBarController.selectedIndex + } + +} diff --git a/iBox/Sources/MainView.swift b/iBox/Sources/MainView.swift deleted file mode 100644 index 9d37b15..0000000 --- a/iBox/Sources/MainView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// MainView.swift -// iBox -// -// Created by jiyeon on 12/26/23. -// - -import UIKit - -import SnapKit - -class MainView: UIView { - - // MARK: - UI - - var label: UILabel = { - let label = UILabel() - label.text = "μ˜ˆμ‹œμž…λ‹ˆλ‹Ή" - label.textColor = .black - return label - }() - - // MARK: - init - - override init(frame: CGRect) { - super.init(frame: frame) - configureUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - configure UI - - func configureUI() { - backgroundColor = .systemBackground - - addSubview(label) - - label.snp.makeConstraints { - $0.center.equalToSuperview() - } - } - -} diff --git a/iBox/Sources/MainViewController.swift b/iBox/Sources/MainViewController.swift deleted file mode 100644 index ee81407..0000000 --- a/iBox/Sources/MainViewController.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MainViewController.swift -// iBox -// -// Created by jiyeon on 12/26/23. -// - -import UIKit - -class MainViewController: BaseViewController { - - // MARK: - life cycle - - override func viewDidLoad() { - super.viewDidLoad() - } - -} diff --git a/iBox/Sources/Model/Bookmark.swift b/iBox/Sources/Model/Bookmark.swift new file mode 100644 index 0000000..260beb0 --- /dev/null +++ b/iBox/Sources/Model/Bookmark.swift @@ -0,0 +1,14 @@ +// +// Bookmark.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/30/24. +// + +import Foundation + +struct Bookmark: Codable { + let id: UUID + var name: String + var url: URL +} diff --git a/iBox/Sources/Model/BookmarkError.swift b/iBox/Sources/Model/BookmarkError.swift new file mode 100644 index 0000000..0128173 --- /dev/null +++ b/iBox/Sources/Model/BookmarkError.swift @@ -0,0 +1,14 @@ +// +// BookmarkError.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 4/21/24. +// + +import Foundation + +enum BookmarkError { + case htmlError + case decodeError + case parseError +} diff --git a/iBox/Sources/Model/EditItem.swift b/iBox/Sources/Model/EditItem.swift new file mode 100644 index 0000000..83f5689 --- /dev/null +++ b/iBox/Sources/Model/EditItem.swift @@ -0,0 +1,19 @@ +// +// EditItem.swift +// iBox +// +// Created by jiyeon on 2/29/24. +// + +import Foundation + +enum EditType { + case folder + case bookmark +} + +struct EditItem { + var type: EditType + var imageString: String + var title: String +} diff --git a/iBox/Sources/Model/Folder.swift b/iBox/Sources/Model/Folder.swift new file mode 100644 index 0000000..b13d1b8 --- /dev/null +++ b/iBox/Sources/Model/Folder.swift @@ -0,0 +1,15 @@ +// +// Folder.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/4/24. +// + +import Foundation + +struct Folder: Codable { + let id: UUID + var name: String + var bookmarks: [Bookmark] +} + diff --git a/iBox/Sources/Model/HomeTabType.swift b/iBox/Sources/Model/HomeTabType.swift new file mode 100644 index 0000000..2d7bb4c --- /dev/null +++ b/iBox/Sources/Model/HomeTabType.swift @@ -0,0 +1,20 @@ +// +// HomeTabType.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Foundation + +enum HomeTabType: CaseIterable { + case boxList + case favorite + + func toString() -> String { + switch self { + case .boxList: "뢁마크 λͺ©λ‘" + case .favorite: "즐겨찾기" + } + } +} diff --git a/iBox/Sources/Model/Metadata.swift b/iBox/Sources/Model/Metadata.swift new file mode 100644 index 0000000..8f95deb --- /dev/null +++ b/iBox/Sources/Model/Metadata.swift @@ -0,0 +1,12 @@ +// +// Metadata.swift +// iBoxShareExtension +// +// Created by 김찬희 on 2024/03/14. +// + +struct Metadata { + var title: String? + var faviconUrl: String? + var url: String? +} diff --git a/iBox/Sources/Model/SettingsItem.swift b/iBox/Sources/Model/SettingsItem.swift new file mode 100644 index 0000000..36f3981 --- /dev/null +++ b/iBox/Sources/Model/SettingsItem.swift @@ -0,0 +1,33 @@ +// +// SettingsItem.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import Foundation + +enum SettingsType { + case theme + case homeTab + case haptics + case reset + case guide + + func toString() -> String { + switch self { + case .theme: "ν…Œλ§ˆ" + case .homeTab: "μ‹œμž‘ ν™”λ©΄" + case .haptics: "진동" + case .reset: "데이터 μ΄ˆκΈ°ν™”" + case .guide: "μ•± μ†Œκ°œ" + } + } + +} + +struct SettingsItem { + var type: SettingsType + var description: String? + var flag: Bool? +} diff --git a/iBox/Sources/Model/Theme.swift b/iBox/Sources/Model/Theme.swift new file mode 100644 index 0000000..aceb433 --- /dev/null +++ b/iBox/Sources/Model/Theme.swift @@ -0,0 +1,30 @@ +// +// Theme.swift +// iBox +// +// Created by jiyeon on 1/4/24. +// + +import UIKit + +enum Theme: Codable, CaseIterable { + case light + case dark + case system + + func toString() -> String { + switch self { + case .light: "라이트 λͺ¨λ“œ" + case .dark: "닀크 λͺ¨λ“œ" + case .system: "μ‹œμŠ€ν…œ μ„€μ • λͺ¨λ“œ" + } + } + + func toImageString() -> String { + switch self { + case .light: "circle" + case .dark: "circle.fill" + case .system: "circle.righthalf.filled" + } + } +} diff --git a/iBox/Sources/Model/VersionInfo.swift b/iBox/Sources/Model/VersionInfo.swift new file mode 100644 index 0000000..1f83182 --- /dev/null +++ b/iBox/Sources/Model/VersionInfo.swift @@ -0,0 +1,23 @@ +// +// Versioning.swift +// iBox +// +// Created by Chan on 2/29/24. +// + +// MARK: - VersionInfo +struct VersionInfo: Codable { + let version: [Version] + let url: URLClass +} + +// MARK: - URLClass +struct URLClass: Codable { + let storeUrl: String +} + +// MARK: - Version +struct Version: Codable { + let id: Int + let latestVersion, minRequiredVersion: String +} diff --git a/iBox/Sources/SceneDelegate.swift b/iBox/Sources/SceneDelegate.swift index f820efc..6672167 100644 --- a/iBox/Sources/SceneDelegate.swift +++ b/iBox/Sources/SceneDelegate.swift @@ -10,19 +10,26 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } window = UIWindow(frame: windowScene.coordinateSpace.bounds) window?.windowScene = windowScene + + window?.overrideUserInterfaceStyle = window?.toUserInterfaceStyle(UserDefaultsManager.theme) ?? .unspecified - let mainViewController = MainViewController() + window?.rootViewController = CustomLaunchScreenViewController(urlContext: connectionOptions.urlContexts.first) + window?.makeKeyAndVisible() - let navigationController = UINavigationController(rootViewController: mainViewController) - - window?.rootViewController = navigationController // 루트 뷰컨트둀러 μ„€μ • - window?.makeKeyAndVisible() // μœˆλ„μš°λ₯Ό 화면에 λ³΄μ—¬μ€Œ + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let urlContext = URLContexts.first, + let tabBarController = window?.rootViewController as? UITabBarController { + AddBookmarkManager.shared.navigateToAddBookmarkView(from: urlContext.url, in: tabBarController) + } } func sceneDidDisconnect(_ scene: UIScene) { @@ -53,9 +60,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // to restore the scene back to its current state. // Save changes in the application's managed object context when the application transitions to the background. - (UIApplication.shared.delegate as? AppDelegate)?.saveContext() } } - diff --git a/iBox/Sources/Settings/Guide/GuideView.swift b/iBox/Sources/Settings/Guide/GuideView.swift new file mode 100644 index 0000000..7162049 --- /dev/null +++ b/iBox/Sources/Settings/Guide/GuideView.swift @@ -0,0 +1,68 @@ +// +// GuideView.swift +// iBox +// +// Created by jiyeon on 4/22/24. +// + +import UIKit +import WebKit + +import SnapKit + +class GuideView: UIView { + + var guideUrl: URL? { + didSet { + loadWebsite() + } + } + + // MARK: - UI Components + + private let webView: WKWebView + + // MARK: - Initializer + + override init(frame: CGRect) { + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: .zero, configuration: config) + super.init(frame: frame) + + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + webView.stopLoading() + webView.isOpaque = false + } + + // MARK: - Setup Methods + + private func setupHierarchy() { + addSubview(webView) + } + + private func setupLayout() { + webView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide.snp.topMargin) + make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottomMargin) + make.leading.equalTo(self.safeAreaLayoutGuide.snp.leadingMargin) + make.trailing.equalTo(self.safeAreaLayoutGuide.snp.trailingMargin) + } + } + + private func loadWebsite() { + guard let url = guideUrl else { return } + webView.load(URLRequest(url: url)) + webView.allowsBackForwardNavigationGestures = true + } + +} diff --git a/iBox/Sources/Settings/Guide/GuideViewController.swift b/iBox/Sources/Settings/Guide/GuideViewController.swift new file mode 100644 index 0000000..bc393b5 --- /dev/null +++ b/iBox/Sources/Settings/Guide/GuideViewController.swift @@ -0,0 +1,30 @@ +// +// GuideViewController.swift +// iBox +// +// Created by jiyeon on 4/22/24. +// + +import UIKit + +class GuideViewController: BaseViewController, BaseViewControllerProtocol { + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? GuideView else { return } + contentView.guideUrl = URL(string: "https://github.com/42Box/iOS/tree/develop?tab=readme-ov-file#%EF%B8%8F%EF%B8%8F-introduction") + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("μ•± μ†Œκ°œ") + setNavigationBarTitleLabelFont(.subTitlefont) + setNavigationBarBackButtonHidden(false) + } + +} diff --git a/iBox/Sources/Settings/HomeTab/HomeTabSelectorCell.swift b/iBox/Sources/Settings/HomeTab/HomeTabSelectorCell.swift new file mode 100644 index 0000000..f22df25 --- /dev/null +++ b/iBox/Sources/Settings/HomeTab/HomeTabSelectorCell.swift @@ -0,0 +1,74 @@ +// +// HomeTabSelectorCell.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import UIKit + +import SnapKit + +class HomeTabSelectorCell: UITableViewCell { + + static let reuseIdentifier = "MainTabCell" + + // MARK: - UI Components + + let titleLabel = UILabel().then { + $0.font = .cellTitleFont + } + + let selectButton = UIButton().then { + $0.configuration = .plain() + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .clear + selectionStyle = .none + } + + private func setupHierarchy() { + addSubview(titleLabel) + addSubview(selectButton) + } + + private func setupLayout() { + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + + selectButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(20) + } + } + + func setupSelectButton(_ selected: Bool) { + if selected { + selectButton.configuration?.image = UIImage(systemName: "circle.inset.filled") + selectButton.tintColor = .box2 + } else { + selectButton.configuration?.image = UIImage(systemName: "circle") + selectButton.tintColor = .gray + } + } + +} diff --git a/iBox/Sources/Settings/HomeTab/HomeTabSelectorView.swift b/iBox/Sources/Settings/HomeTab/HomeTabSelectorView.swift new file mode 100644 index 0000000..c8ba4df --- /dev/null +++ b/iBox/Sources/Settings/HomeTab/HomeTabSelectorView.swift @@ -0,0 +1,95 @@ +// +// HomeTabSelectorView.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Combine +import UIKit + +class HomeTabSelectorView: UIView { + + private var viewModel: HomeTabSelectorViewModel? + private var cancellables = Set() + + // MARK: - UI Components + + let tableView = UITableView().then { + $0.separatorStyle = .none + $0.register(HomeTabSelectorCell.self, forCellReuseIdentifier: HomeTabSelectorCell.reuseIdentifier) + $0.backgroundColor = .clear + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + tableView.delegate = self + tableView.dataSource = self + } + + private func setupHierarchy() { + addSubview(tableView) + } + + private func setupLayout() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Bind ViewModel + + func bindViewModel(_ viewModel: HomeTabSelectorViewModel) { + self.viewModel = viewModel + viewModel.$selectedIndex + .receive(on: RunLoop.main) + .sink { [weak self] selectedIndex in + UserDefaultsManager.homeTabIndex = selectedIndex + self?.tableView.reloadData() + }.store(in: &cancellables) + } + +} + +extension HomeTabSelectorView: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 55 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let viewModel = viewModel else { return } + viewModel.selectedIndex = indexPath.row + } + +} + +extension HomeTabSelectorView: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return HomeTabType.allCases.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let viewModel = viewModel, + let cell = tableView.dequeueReusableCell(withIdentifier: HomeTabSelectorCell.reuseIdentifier) as? HomeTabSelectorCell else { return UITableViewCell() } + cell.titleLabel.text = HomeTabType.allCases[indexPath.row].toString() + cell.setupSelectButton(viewModel.selectedIndex == indexPath.row) + return cell + } + +} diff --git a/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewController.swift b/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewController.swift new file mode 100644 index 0000000..b22096a --- /dev/null +++ b/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewController.swift @@ -0,0 +1,32 @@ +// +// HomeTabSelectorViewwController.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import UIKit + +class HomeTabSelectorViewController: BaseViewController, BaseViewControllerProtocol { + + private let viewModel = HomeTabSelectorViewModel() + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? HomeTabSelectorView else { return } + contentView.bindViewModel(viewModel) + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("μ‹œμž‘ ν™”λ©΄ μ„€μ •ν•˜κΈ°") + setNavigationBarTitleLabelFont(.subTitlefont) + setNavigationBarBackButtonHidden(false) + } + +} diff --git a/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewModel.swift b/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewModel.swift new file mode 100644 index 0000000..6a49529 --- /dev/null +++ b/iBox/Sources/Settings/HomeTab/HomeTabSelectorViewModel.swift @@ -0,0 +1,15 @@ +// +// HomeTabSelectorViewModel.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Combine +import Foundation + +class HomeTabSelectorViewModel { + + @Published var selectedIndex: Int = UserDefaultsManager.homeTabIndex + +} diff --git a/iBox/Sources/Settings/Reset/ResetSuccessView.swift b/iBox/Sources/Settings/Reset/ResetSuccessView.swift new file mode 100644 index 0000000..c72685d --- /dev/null +++ b/iBox/Sources/Settings/Reset/ResetSuccessView.swift @@ -0,0 +1,99 @@ +// +// ResetSuccessView.swift +// iBox +// +// Created by Chan on 4/17/24. +// + +import UIKit + +import SnapKit + +protocol ResetSuccessViewDelegate: AnyObject { + func didCompleteReset() +} + +class ResetSuccessView: UIView { + + weak var delegate: ResetSuccessViewDelegate? + + private let checkMarkImageView = UIImageView().then { + $0.image = UIImage(systemName: "checkmark") + $0.contentMode = .scaleAspectFit + $0.tintColor = .box2 + } + + private let label = UILabel().then { + $0.text = "μ΄ˆκΈ°ν™” 성곡" + $0.textColor = .invertBackgroundColor + $0.textAlignment = .center + $0.font = UIFont.systemFont(ofSize: 16) + } + + private let stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 10 + $0.alignment = .center + $0.distribution = .equalSpacing + } + + private let backPannelView = UIView().then { + $0.backgroundColor = .backgroundColor + $0.layer.cornerRadius = 20 + $0.clipsToBounds = true + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + animateView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.alpha = 0 + self.backgroundColor = UIColor.invertBackgroundColor.withAlphaComponent(0.35) + self.addSubview(backPannelView) + backPannelView.addSubview(stackView) + + stackView.addArrangedSubview(checkMarkImageView) + stackView.addArrangedSubview(label) + + backPannelView.snp.makeConstraints { make in + make.width.height.equalTo(200) + make.center.equalToSuperview() + } + + stackView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + checkMarkImageView.snp.makeConstraints { make in + make.width.height.equalTo(50) + } + } + + private func animateView() { + UIView.animate(withDuration: 0.5, animations: { + self.alpha = 1.0 + }) { _ in + UIView.animate(withDuration: 0.5) { + self.backPannelView.alpha = 1.0 + } + + UIView.animate(withDuration: 0.5, delay: 2, options: []) { + self.backPannelView.alpha = 0.0 + } completion: { _ in + UIView.animate(withDuration: 0.5, animations: { + self.alpha = 0.0 + }) { _ in + self.removeFromSuperview() + self.delegate?.didCompleteReset() + } + } + } + } +} diff --git a/iBox/Sources/Settings/Reset/ResetView.swift b/iBox/Sources/Settings/Reset/ResetView.swift new file mode 100644 index 0000000..303f22f --- /dev/null +++ b/iBox/Sources/Settings/Reset/ResetView.swift @@ -0,0 +1,73 @@ +// +// ResetView.swift +// iBox +// +// Created by jiyeon on 3/14/24. +// + +import UIKit + +import SnapKit + +class ResetView: UIView { + + var delegate: ResetViewDelegate? + + // MARK: - UI Components + + let label = UILabel().then { + $0.text = "κ²½κ³ : 이 μž‘μ—…μ„ μ§„ν–‰ν•˜λ©΄ μ €μž₯ν•˜μ‹  λͺ¨λ“  폴더 및 뢁마크 정보가 영ꡬ적으둜 μ‚­μ œλ˜κ³  κΈ°λ³Έκ°’μœΌλ‘œ μ΄ˆκΈ°ν™”λ©λ‹ˆλ‹€. μ§„ν–‰ν•˜κΈ° 전에 μ€‘μš”ν•œ 정보가 μ—†λŠ”μ§€ λ‹€μ‹œ ν•œλ²ˆ 확인해 μ£Όμ‹œκΈ° λ°”λžλ‹ˆλ‹€." + $0.numberOfLines = 0 + $0.font = .descriptionFont + } + + let resetButton = UIButton().then { + $0.configuration = .plain() + $0.configuration?.attributedTitle = .init("μ΄ˆκΈ°ν™”", attributes: .init([.font: UIFont.boldLabelFont])) + $0.tintColor = .white + $0.backgroundColor = .box + $0.clipsToBounds = true + $0.layer.cornerRadius = 5 + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + resetButton.addTarget(self, action: #selector(handleResetButtonTap), for: .touchUpInside) + } + + private func setupHierarchy() { + addSubview(label) + addSubview(resetButton) + } + + private func setupLayout() { + label.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(20) + } + + resetButton.snp.makeConstraints { make in + make.top.equalTo(label.snp.bottom).offset(20) + make.trailing.leading.equalToSuperview().inset(20) + make.height.equalTo(44) + } + } + + @objc private func handleResetButtonTap() { + delegate?.showAlert() + } + +} diff --git a/iBox/Sources/Settings/Reset/ResetViewController.swift b/iBox/Sources/Settings/Reset/ResetViewController.swift new file mode 100644 index 0000000..c5e10bc --- /dev/null +++ b/iBox/Sources/Settings/Reset/ResetViewController.swift @@ -0,0 +1,96 @@ +// +// ResetViewController.swift +// iBox +// +// Created by jiyeon on 3/14/24. +// + +import UIKit + +protocol ResetViewDelegate { + func showAlert() +} + +class ResetViewController: BaseViewController, BaseViewControllerProtocol { + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? ResetView else { return } + contentView.delegate = self + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("데이터 μ΄ˆκΈ°ν™”") + setNavigationBarTitleLabelFont(.subTitlefont) + setNavigationBarBackButtonHidden(false) + } + +} + +extension ResetViewController: ResetViewDelegate { + + func showAlert() { + let alertController = UIAlertController(title: "κ²½κ³ ", message: "이 μž‘μ—…μ€ 되돌릴 수 μ—†μŠ΅λ‹ˆλ‹€. κ³„μ†ν•˜λ €λ©΄ \"iBox\"라고 μž…λ ₯ν•΄ μ£Όμ„Έμš”.", preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: "μ·¨μ†Œ", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + let confirmAction = UIAlertAction(title: "확인", style: .default) { [weak self] _ in + guard let self = self else { return } + if let textField = alertController.textFields?.first, let text = textField.text, text == "iBox" { + self.resetData() + } else { + self.showAlert() + } + } + + confirmAction.isEnabled = false + confirmAction.setValue(UIColor.red, forKey: "titleTextColor") + + alertController.addAction(confirmAction) + + alertController.addTextField() { textField in + NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: textField, queue: OperationQueue.main, using: + {_ in + let isTextMatch = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) == "iBox" + + confirmAction.isEnabled = isTextMatch + }) + + } + + self.present(alertController, animated: true, completion: nil) + } + + private func resetData() { + DefaultData.insertDefaultDataIfNeeded(true) { + UserDefaultsManager.favoriteId = nil + WebViewPreloader.shared.setFavoriteUrl(url: nil) + NotificationCenter.default.post(name: .didResetData, object: nil) + DispatchQueue.main.async { + let successView = ResetSuccessView(frame: self.view.bounds) + successView.delegate = self + self.view.addSubview(successView) + successView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + } + } +} + +// MARK: - ResetSuccessView + +extension ResetViewController: ResetSuccessViewDelegate { + + func didCompleteReset() { + self.navigationController?.popViewController(animated: true) + } + +} diff --git a/iBox/Sources/Settings/SettingsCellViewModel.swift b/iBox/Sources/Settings/SettingsCellViewModel.swift new file mode 100644 index 0000000..bd39065 --- /dev/null +++ b/iBox/Sources/Settings/SettingsCellViewModel.swift @@ -0,0 +1,30 @@ +// +// SettingsCellViewModel.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Foundation + +class SettingsCellViewModel { + + let settingsItem: SettingsItem + + init(_ settingsItem: SettingsItem) { + self.settingsItem = settingsItem + } + + var title: String { + settingsItem.type.toString() + } + + var flag: Bool? { + settingsItem.flag + } + + var description: String? { + settingsItem.description + } + +} diff --git a/iBox/Sources/Settings/SettingsItemCell.swift b/iBox/Sources/Settings/SettingsItemCell.swift new file mode 100644 index 0000000..f3a239c --- /dev/null +++ b/iBox/Sources/Settings/SettingsItemCell.swift @@ -0,0 +1,110 @@ +// +// SettingsItemCell.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +import SnapKit + +class SettingsItemCell: UITableViewCell { + + static let reuseIdentifier = "SettingsItemCell" + private var viewModel: SettingsCellViewModel? + + // MARK: - UI Components + + let titleLabel = UILabel().then { + $0.font = .cellTitleFont + } + + let descriptionLabel = UILabel().then { + $0.font = .cellDescriptionFont + $0.textColor = .gray + } + + let switchControl = UISwitch().then { + $0.onTintColor = .box2 + } + + let chevronButton = UIButton().then { + $0.configuration = .plain() + $0.configuration?.image = UIImage(systemName: "chevron.right") + $0.configuration?.preferredSymbolConfigurationForImage = .init(pointSize: 10, weight: .bold) + $0.tintColor = .systemGray3 + $0.isUserInteractionEnabled = false + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .clear + selectionStyle = .none + } + + private func setupHierarchy() { + contentView.addSubview(titleLabel) + contentView.addSubview(switchControl) + contentView.addSubview(descriptionLabel) + contentView.addSubview(chevronButton) + } + + private func setupLayout() { + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + + switchControl.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(30) + make.centerY.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(30) + make.centerY.equalToSuperview() + } + + chevronButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + } + + // MARK: - Bind ViewModel + + func bindViewModel(_ viewModel: SettingsCellViewModel) { + self.viewModel = viewModel + titleLabel.text = viewModel.title + + descriptionLabel.isHidden = true + switchControl.isHidden = true + chevronButton.isHidden = true + + if let description = viewModel.description { + descriptionLabel.text = description + descriptionLabel.isHidden = false + } else if let flag = viewModel.flag { + switchControl.isOn = flag + switchControl.isHidden = false + } else { + chevronButton.isHidden = false + } + } + +} diff --git a/iBox/Sources/Settings/SettingsSectionViewModel.swift b/iBox/Sources/Settings/SettingsSectionViewModel.swift new file mode 100644 index 0000000..63efe2f --- /dev/null +++ b/iBox/Sources/Settings/SettingsSectionViewModel.swift @@ -0,0 +1,18 @@ +// +// SettingsSectionViewModel.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Foundation + +class SettingsSectionViewModel { + + let cellViewModels: [SettingsCellViewModel] + + init(cellViewModels: [SettingsCellViewModel]) { + self.cellViewModels = cellViewModels + } + +} diff --git a/iBox/Sources/Settings/SettingsView.swift b/iBox/Sources/Settings/SettingsView.swift new file mode 100644 index 0000000..24a151b --- /dev/null +++ b/iBox/Sources/Settings/SettingsView.swift @@ -0,0 +1,131 @@ +// +// SettingsView.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import Combine +import UIKit + +final class SettingsView: UIView { + + var delegate: SettingsViewDelegate? + private var viewModel: SettingsViewModel? + private var cancellables = Set() + + // MARK: - UI Components + + let tableView = UITableView().then { + $0.register(SettingsItemCell.self, forCellReuseIdentifier: SettingsItemCell.reuseIdentifier) + $0.separatorStyle = .none + $0.sectionHeaderTopPadding = 0 + $0.backgroundColor = .clear + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + tableView.delegate = self + tableView.dataSource = self + } + + private func setupHierarchy() { + addSubview(tableView) + } + + private func setupLayout() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Bind ViewModel + + func bindViewModel(_ viewModel: SettingsViewModel) { + self.viewModel = viewModel + viewModel.transform(input: viewModel.input.eraseToAnyPublisher()) + .receive(on: RunLoop.main) + .sink { [weak self] event in + switch event { + case .updateSectionViewModels: + self?.tableView.reloadData() + } + }.store(in: &cancellables) + } + + // MARK: - Action Functions + + @objc private func handleHapticsSwitchTap(_ controlSwitch: UISwitch) { + guard let viewModel = viewModel else { return } + viewModel.input.send(.setHaptics(controlSwitch.isOn)) + } + +} + +extension SettingsView: UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + guard let viewModel = viewModel else { return 0 } + return viewModel.sectionViewModels.count + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let headerView = UIView() + headerView.backgroundColor = .backgroundColor + return headerView + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 20 + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 55 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let viewModel = viewModel else { return } + let settingsItem = viewModel.sectionViewModels[indexPath.section].cellViewModels[indexPath.row].settingsItem + if (settingsItem.type != SettingsType.haptics) { + delegate?.pushViewController(settingsItem.type) + } + } + +} + +extension SettingsView: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let viewModel = viewModel else { return 0 } + return viewModel.sectionViewModels[section].cellViewModels.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let viewModel = viewModel, + let cell = tableView.dequeueReusableCell(withIdentifier: SettingsItemCell.reuseIdentifier) + as? SettingsItemCell else { return UITableViewCell() } + let cellViewModel = viewModel.sectionViewModels[indexPath.section].cellViewModels[indexPath.row] + cell.bindViewModel(cellViewModel) + let settingsType = cellViewModel.settingsItem.type + if settingsType == .haptics { + cell.switchControl.removeTarget(nil, action: nil, for: .valueChanged) + cell.switchControl.addTarget(self, action: #selector(handleHapticsSwitchTap), for: .valueChanged) + } + return cell + } + +} diff --git a/iBox/Sources/Settings/SettingsViewController.swift b/iBox/Sources/Settings/SettingsViewController.swift new file mode 100644 index 0000000..13ff56e --- /dev/null +++ b/iBox/Sources/Settings/SettingsViewController.swift @@ -0,0 +1,63 @@ +// +// SettingsViewController.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 12/27/23. +// + +import UIKit + +protocol SettingsViewDelegate { + func pushViewController(_ type: SettingsType) + func pushViewController(_ viewController: UIViewController) +} + +final class SettingsViewController: BaseViewController, BaseViewControllerProtocol { + + private let viewModel = SettingsViewModel() + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? SettingsView else { return } + contentView.delegate = self + contentView.bindViewModel(viewModel) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.input.send(.viewWillAppear) + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("μ„€μ •") + } + +} + +extension SettingsViewController: SettingsViewDelegate { + + func pushViewController(_ type: SettingsType) { + switch type { + case .theme: + navigationController?.pushViewController(ThemeViewController(), animated: true) + case .homeTab: + navigationController?.pushViewController(HomeTabSelectorViewController(), animated: true) + case .reset: + navigationController?.pushViewController(ResetViewController(), animated: true) + case .guide: + navigationController?.pushViewController(GuideViewController(), animated: true) + default: break + } + } + + func pushViewController(_ viewController: UIViewController) { + navigationController?.pushViewController(viewController, animated: true) + } + +} diff --git a/iBox/Sources/Settings/SettingsViewModel.swift b/iBox/Sources/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..e70e9ec --- /dev/null +++ b/iBox/Sources/Settings/SettingsViewModel.swift @@ -0,0 +1,55 @@ +// +// SettingsViewModel.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Combine +import Foundation + +class SettingsViewModel { + + enum Input { + case viewWillAppear + case setHaptics(_ isOn: Bool) + } + + enum Output { + case updateSectionViewModels + } + + // MARK: - Properties + + let input = PassthroughSubject() + private let output = PassthroughSubject() + private var cancellables = Set() + var sectionViewModels = [SettingsSectionViewModel]() + + func transform(input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] event in + switch event { + case .viewWillAppear: + self?.sectionViewModels.removeAll() + self?.updateSectionViewModels() + self?.output.send(.updateSectionViewModels) + case let .setHaptics(isOn): + UserDefaultsManager.isHaptics = isOn + } + }.store(in: &cancellables) + return output.eraseToAnyPublisher() + } + + private func updateSectionViewModels() { + sectionViewModels.append(SettingsSectionViewModel(cellViewModels: [ + SettingsCellViewModel(SettingsItem(type: .theme, description: UserDefaultsManager.theme.toString())), + SettingsCellViewModel(SettingsItem(type: .homeTab, description: HomeTabType.allCases[UserDefaultsManager.homeTabIndex].toString())), + SettingsCellViewModel(SettingsItem(type: .haptics, flag: UserDefaultsManager.isHaptics)) + ])) + sectionViewModels.append(SettingsSectionViewModel(cellViewModels: [ + SettingsCellViewModel(SettingsItem(type: .reset)), + SettingsCellViewModel(SettingsItem(type: .guide)) + ])) + } + +} diff --git a/iBox/Sources/Settings/Theme/ThemeCell.swift b/iBox/Sources/Settings/Theme/ThemeCell.swift new file mode 100644 index 0000000..4a4c9fe --- /dev/null +++ b/iBox/Sources/Settings/Theme/ThemeCell.swift @@ -0,0 +1,88 @@ +// +// ThemeCell.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +class ThemeCell: UITableViewCell { + + static let reuseIdentifier = "ThemeCell" + + // MARK: - UI Components + + let themeImageView = UIImageView().then { + $0.tintColor = .label + } + + let titleLabel = UILabel().then { + $0.font = .cellTitleFont + } + + let selectButton = UIButton().then { + $0.configuration = .plain() + } + + // MARK: - Initializer + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .clear + selectionStyle = .none + } + + private func setupHierarchy() { + addSubview(themeImageView) + addSubview(titleLabel) + addSubview(selectButton) + } + + private func setupLayout() { + themeImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(23) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(themeImageView.snp.trailing).offset(10) + make.centerY.equalToSuperview() + } + + selectButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(20) + } + } + + func bind(_ theme: Theme) { + titleLabel.text = theme.toString() + themeImageView.image = UIImage(systemName: theme.toImageString()) + } + + func setupSelectButton(_ selected: Bool) { + if selected { + selectButton.configuration?.image = UIImage(systemName: "circle.inset.filled") + selectButton.tintColor = .box2 + } else { + selectButton.configuration?.image = UIImage(systemName: "circle") + selectButton.tintColor = .gray + } + } + +} diff --git a/iBox/Sources/Settings/Theme/ThemeView.swift b/iBox/Sources/Settings/Theme/ThemeView.swift new file mode 100644 index 0000000..3b68fb9 --- /dev/null +++ b/iBox/Sources/Settings/Theme/ThemeView.swift @@ -0,0 +1,102 @@ +// +// ThemeView.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import Combine +import UIKit + +import SnapKit + +class ThemeView: UIView { + + private var viewModel: ThemeViewModel? + private var cancellables = Set() + + // MARK: - UI Components + + let tableView = UITableView().then { + $0.register(ThemeCell.self, forCellReuseIdentifier: ThemeCell.reuseIdentifier) + $0.separatorStyle = .none + $0.sectionHeaderTopPadding = 0 + $0.backgroundColor = .clear + } + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Methods + + private func setupProperty() { + tableView.delegate = self + tableView.dataSource = self + } + + private func setupHierarchy() { + addSubview(tableView) + } + + private func setupLayout() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Bind ViewModel + + func bineViewModel(_ viewModel: ThemeViewModel) { + self.viewModel = viewModel + viewModel.$selectedIndex + .receive(on: RunLoop.main) + .sink { [weak self] selectedIndex in + guard let window = self?.window else { return } + UserDefaultsManager.theme = Theme.allCases[selectedIndex] + window.overrideUserInterfaceStyle = self?.toUserInterfaceStyle(UserDefaultsManager.theme) ?? .unspecified + self?.tableView.reloadData() + }.store(in: &cancellables) + } + +} + +extension ThemeView: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 55 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let viewModel = viewModel else { return } + viewModel.selectedIndex = indexPath.row + } + +} + +extension ThemeView: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return Theme.allCases.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let viewModel = viewModel, + let cell = tableView.dequeueReusableCell(withIdentifier: ThemeCell.reuseIdentifier) + as? ThemeCell else { return UITableViewCell() } + let theme = Theme.allCases[indexPath.row] + cell.bind(theme) + cell.setupSelectButton(viewModel.selectedIndex == indexPath.row) + return cell + } + +} diff --git a/iBox/Sources/Settings/Theme/ThemeViewController.swift b/iBox/Sources/Settings/Theme/ThemeViewController.swift new file mode 100644 index 0000000..463805f --- /dev/null +++ b/iBox/Sources/Settings/Theme/ThemeViewController.swift @@ -0,0 +1,32 @@ +// +// ThemeViewController.swift +// iBox +// +// Created by jiyeon on 1/3/24. +// + +import UIKit + +class ThemeViewController: BaseViewController, BaseViewControllerProtocol { + + private let viewModel = ThemeViewModel() + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + + guard let contentView = contentView as? ThemeView else { return } + contentView.bineViewModel(viewModel) + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarTitleLabelText("닀크 λͺ¨λ“œ μ„€μ •") + setNavigationBarTitleLabelFont(.subTitlefont) + setNavigationBarBackButtonHidden(false) + } + +} diff --git a/iBox/Sources/Settings/Theme/ThemeViewModel.swift b/iBox/Sources/Settings/Theme/ThemeViewModel.swift new file mode 100644 index 0000000..95aabb2 --- /dev/null +++ b/iBox/Sources/Settings/Theme/ThemeViewModel.swift @@ -0,0 +1,23 @@ +// +// ThemeViewModel.swift +// iBox +// +// Created by jiyeon on 2/22/24. +// + +import Combine +import Foundation + +class ThemeViewModel { + + @Published var selectedIndex: Int + + init() { + switch UserDefaultsManager.theme { + case .light: selectedIndex = 0 + case .dark: selectedIndex = 1 + case .system: selectedIndex = 2 + } + } + +} diff --git a/iBox/Sources/Shared/AddBookmarkManager.swift b/iBox/Sources/Shared/AddBookmarkManager.swift new file mode 100644 index 0000000..fbb7f70 --- /dev/null +++ b/iBox/Sources/Shared/AddBookmarkManager.swift @@ -0,0 +1,105 @@ +// +// URLDataManager.swift +// iBox +// +// Created by μ΅œμ’…μ› on 3/5/24. +// + +import UIKit + +import SwiftSoup + +class AddBookmarkManager { + static let shared = AddBookmarkManager() + + @Published var isFetching: Bool = false + @Published var incomingTitle: String? + @Published var incomingData: String? + @Published var incomingFaviconUrl: String? + @Published var incomingError: BookmarkError? + + private init() {} + + private func update(with data: (title: String?, data: String?, faviconUrl: String?)) { + DispatchQueue.main.async { + self.isFetching = false + self.incomingTitle = data.title?.removingPercentEncoding + self.incomingData = data.data?.removingPercentEncoding + self.incomingFaviconUrl = data.faviconUrl?.removingPercentEncoding + } + } + + private func parseHTML(_ html: String, _ url: URL) { + do { + let doc = try SwiftSoup.parse(html) + let title = try doc.title() + let faviconLink = try doc.select("link[rel='icon']").first()?.attr("href") + + self.update(with: (title: title, data: url.absoluteString, faviconUrl: faviconLink)) + } catch { + self.incomingError = .parseError + } + } + + private func extractDataParameter(from url: URL) -> String? { + let urlString = url.absoluteString + + guard let range = urlString.range(of: "url?data=") else { + return nil + } + + let dataParameter = urlString[range.upperBound...] + return String(dataParameter).removingPercentEncoding + } + + private func fetchWebsiteDetails(from url: URL) { + let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + guard let data = data, error == nil else { + self?.incomingError = .htmlError + return + } + + let encodingName = (response as? HTTPURLResponse)?.textEncodingName ?? "utf-8" + let encoding = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(encodingName as CFString))) + + guard let html = String(data: data, encoding: encoding) else { + self?.incomingError = .decodeError + return + } + + self?.parseHTML(html, url) + } + task.resume() + } + + func navigateToAddBookmarkView(from url: URL, in tabBarController: UITabBarController) { + guard url.scheme == "iBox", let urlString = extractDataParameter(from: url) else { return } + + incomingTitle = nil + incomingData = nil + incomingFaviconUrl = nil + isFetching = true + + + if urlString.hasPrefix("http://") { + update(with: (nil, urlString, nil)) + } else { + guard let url = URL(string: urlString) else { + isFetching = false + return + } + fetchWebsiteDetails(from: url) + } + + tabBarController.selectedIndex = 0 + + DispatchQueue.main.async { + guard let navigationController = tabBarController.selectedViewController as? UINavigationController, + let boxListViewController = navigationController.viewControllers.first as? BoxListViewController else { + return + } + boxListViewController.shouldPresentModalAutomatically = true + } + } + +} diff --git a/iBox/Sources/Shared/Animator/FullSizePresentationController.swift b/iBox/Sources/Shared/Animator/FullSizePresentationController.swift new file mode 100644 index 0000000..8693cab --- /dev/null +++ b/iBox/Sources/Shared/Animator/FullSizePresentationController.swift @@ -0,0 +1,37 @@ +// +// FullSizePresentationController.swift +// iBox +// +// Created by Chan on 4/24/24. +// + +import UIKit + +class FullSizePresentationController: UIPresentationController { + private var dimmingView: UIView! + + override func containerViewDidLayoutSubviews() { + super.containerViewDidLayoutSubviews() + + dimmingView.frame = containerView?.bounds ?? CGRect.zero + presentedView?.frame = frameOfPresentedViewInContainerView + } + + override func presentationTransitionWillBegin() { + guard let containerView = containerView else { return } + + dimmingView = UIView(frame: containerView.bounds) + dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.4) + dimmingView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + containerView.addSubview(dimmingView) + containerView.sendSubviewToBack(dimmingView) + + super.presentationTransitionWillBegin() + } + + override var frameOfPresentedViewInContainerView: CGRect { + guard let containerView = containerView else { return .zero } + return CGRect(x: 0, y: 0, width: containerView.bounds.width, height: containerView.bounds.height) + } +} diff --git a/iBox/Sources/Shared/Animator/SlideInPresentationAnimator.swift b/iBox/Sources/Shared/Animator/SlideInPresentationAnimator.swift new file mode 100644 index 0000000..f003178 --- /dev/null +++ b/iBox/Sources/Shared/Animator/SlideInPresentationAnimator.swift @@ -0,0 +1,46 @@ +// +// SlideInPresentationAnimator.swift +// iBox +// +// Created by Chan on 4/24/24. +// + +import UIKit + +class SlideInPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning { + let isPresentation: Bool + + init(isPresentation: Bool) { + self.isPresentation = isPresentation + super.init() + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.3 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let key = isPresentation ? UITransitionContextViewControllerKey.to : UITransitionContextViewControllerKey.from + guard let controller = transitionContext.viewController(forKey: key) else { return } + + if isPresentation { + transitionContext.containerView.addSubview(controller.view) + } + + let presentedFrame = transitionContext.finalFrame(for: controller) + var dismissedFrame = presentedFrame + dismissedFrame.origin.y = -presentedFrame.height + + let initialFrame = isPresentation ? dismissedFrame : presentedFrame + let finalFrame = isPresentation ? presentedFrame : dismissedFrame + + controller.view.frame = initialFrame + UIView.animate( + withDuration: transitionDuration(using: transitionContext), + animations: { + controller.view.frame = finalFrame + }, completion: { finished in + transitionContext.completeTransition(finished) + }) + } +} diff --git a/iBox/Sources/Shared/AppStateManager.swift b/iBox/Sources/Shared/AppStateManager.swift new file mode 100644 index 0000000..10f8b22 --- /dev/null +++ b/iBox/Sources/Shared/AppStateManager.swift @@ -0,0 +1,19 @@ +// +// AppStateManager.swift +// iBox +// +// Created by Chan on 3/2/24. +// + +import Combine + +class AppStateManager { + static let shared = AppStateManager() + + @Published var versionCheckCompleted: VersionCheckCode = .initial + var currentViewErrorState: ViewErrorCode = .normal + + func updateViewError(_ error: ViewErrorCode) { + currentViewErrorState = error + } +} diff --git a/iBox/Sources/Shared/CoreDataManager.swift b/iBox/Sources/Shared/CoreDataManager.swift new file mode 100644 index 0000000..28ef1c2 --- /dev/null +++ b/iBox/Sources/Shared/CoreDataManager.swift @@ -0,0 +1,333 @@ +// +// CoreDataManager.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 2/9/24. +// + +import CoreData +import Foundation + +class CoreDataManager { + static let shared = CoreDataManager() + + lazy var persistentContainer = { + let container = NSPersistentContainer(name: "iBox") + + container.loadPersistentStores { _, error in + if let error { + fatalError(error.localizedDescription) + } + } + + return container + }() + + private init() {} + + private var lastFolderOrder: Int64 = 0 + private var lastBookmarkOrder = [UUID: Int64]() + + private func save() { + guard persistentContainer.viewContext.hasChanges else { return } + do { + try persistentContainer.viewContext.save() + } catch { + print("Fail to save the context:", error.localizedDescription) + } + } +} + +// 폴더 κ΄€λ ¨ +extension CoreDataManager { + func addInitialFolders(_ folders: [Folder]) { + let context = persistentContainer.viewContext + + for folder in folders { + let newFolder = FolderEntity(context: context) + newFolder.id = folder.id + newFolder.name = folder.name + newFolder.order = lastFolderOrder + lastFolderOrder += 1 + let bookmarks = NSMutableOrderedSet() + lastBookmarkOrder[folder.id] = 0 + for bookmark in folder.bookmarks { + let newBookmark = BookmarkEntity(context: context) + newBookmark.id = bookmark.id + newBookmark.name = bookmark.name + newBookmark.url = bookmark.url + newBookmark.order = lastBookmarkOrder[folder.id] ?? 0 + lastBookmarkOrder[folder.id] = (lastBookmarkOrder[folder.id] ?? 0) + 1 + bookmarks.add(newBookmark) + } + newFolder.bookmarks = bookmarks + } + save() + } + + func addFolder(_ folder: Folder) { + let context = persistentContainer.viewContext + let newFolder = FolderEntity(context: context) + newFolder.id = folder.id + newFolder.name = folder.name + newFolder.order = lastFolderOrder + lastFolderOrder += 1 + let bookmarks = NSMutableOrderedSet() + lastBookmarkOrder[folder.id] = 0 + for bookmark in folder.bookmarks { + let newBookmark = BookmarkEntity(context: context) + newBookmark.id = bookmark.id + newBookmark.name = bookmark.name + newBookmark.url = bookmark.url + newBookmark.order = lastBookmarkOrder[folder.id] ?? 0 + lastBookmarkOrder[folder.id] = (lastBookmarkOrder[folder.id] ?? 0) + 1 + bookmarks.add(newBookmark) + } + newFolder.bookmarks = bookmarks + save() + } + + private func getFolderEntity(id: UUID) -> FolderEntity? { + let context = persistentContainer.viewContext + + let fetchRequest = FolderEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", id as NSUUID) + + do { + let results = try context.fetch(fetchRequest) + return results.first + } catch { + print(error.localizedDescription) + return nil + } + } + + private func getAllFolderEntity() -> [FolderEntity] { + let context = persistentContainer.viewContext + + let fetchRequest = FolderEntity.fetchRequest() + let sortDescriptor = NSSortDescriptor(key: "order", ascending: true) + fetchRequest.sortDescriptors = [sortDescriptor] + + do { + return try context.fetch(fetchRequest) + } catch { + print(error.localizedDescription) + return [] + } + } + + func getFolders() -> [Folder] { + let folderEntities = getAllFolderEntity() + var folders = [Folder]() + + lastFolderOrder = (folderEntities.last?.order ?? -1) + 1 + for folderEntity in folderEntities { + let bookmarkEntities = (folderEntity.bookmarks?.array as? [BookmarkEntity] ?? []).sorted { + $0.order < $1.order + } + guard let folderId = folderEntity.id else { return [] } + lastBookmarkOrder[folderId] = (bookmarkEntities.last?.order ?? -1) + 1 + let bookmarks = bookmarkEntities.map{ Bookmark(id: $0.id ?? UUID(), name: $0.name ?? "" , url: $0.url ?? URL(string: "")!) } + folders.append(Folder(id: folderEntity.id ?? UUID(), name: folderEntity.name ?? "", bookmarks: bookmarks)) + } + + return folders + } + + func deleteFolder(id: UUID) { + let context = persistentContainer.viewContext + + guard let folder = getFolderEntity(id: id) else { return } + let deletedOrder = folder.order + context.delete(folder) + + let subsequentFolderEntities = getAllFolderEntity().filter{ $0.order > deletedOrder } + for folderEntity in subsequentFolderEntities { + folderEntity.order -= 1 + } + lastFolderOrder -= 1 + save() + } + + func deleteAllFolders() { + let context = persistentContainer.viewContext + + let folders = getAllFolderEntity() + for folder in folders { + context.delete(folder) + } + save() + } + + func updateFolder(id: UUID, name: String) { + guard let folder = getFolderEntity(id: id) else { return } + folder.name = name + save() + } + + func moveFolder(from source: Int, to destination: Int) { + let folderEntities = getAllFolderEntity() + + if source < destination { + var startIndex = source + 1 + let endIndex = destination + var startOrder = folderEntities[source].order + while startIndex <= endIndex { + folderEntities[startIndex].order = startOrder + startOrder += 1 + startIndex += 1 + } + folderEntities[source].order = startOrder + } else if destination < source { + var startIndex = destination + let endIndex = source - 1 + var startOrder = folderEntities[destination].order + 1 + let newOrder = folderEntities[destination].order + while startIndex <= endIndex { + folderEntities[startIndex].order = startOrder + startOrder += 1 + startIndex += 1 + } + folderEntities[source].order = newOrder + } + save() + } + +} + +// 뢁마크 κ΄€λ ¨ +extension CoreDataManager { + + func getBookmarkUrl(_ bookmarkId: UUID) -> URL? { + let entity = getBookmarkEntity(id: bookmarkId) + if let entity { + return entity.url + } else { + return nil + } + } + + func addBookmark(_ bookmark: Bookmark, folderId: UUID) { + let context = persistentContainer.viewContext + + guard let folder = getFolderEntity(id: folderId) else { return } + let newBookmark = BookmarkEntity(context: context) + newBookmark.id = bookmark.id + newBookmark.name = bookmark.name + newBookmark.url = bookmark.url + guard let folderId = folder.id else { return } + newBookmark.order = lastBookmarkOrder[folderId] ?? 0 + lastBookmarkOrder[folderId] = (lastBookmarkOrder[folderId] ?? 0) + 1 + newBookmark.folder = folder + save() + } + + func updateBookmark(id: UUID, name: String, url: URL) { + guard let bookmark = getBookmarkEntity(id: id) else { return } + bookmark.name = name + bookmark.url = url + save() + } + + private func getBookmarkEntity(id: UUID) -> BookmarkEntity? { + let context = persistentContainer.viewContext + + let fetchRequest = BookmarkEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", id as NSUUID) + + do { + let results = try context.fetch(fetchRequest) + return results.first + } catch { + print(error.localizedDescription) + return nil + } + } + + func deleteBookmark(id: UUID) { + let context = persistentContainer.viewContext + + guard let bookmark = getBookmarkEntity(id: id) else { return } + let deletedOrder = bookmark.order + context.delete(bookmark) + + guard let folderId = bookmark.folder?.id else { return } + let subsequentBookmarkEntities = getAllBookmarkEntity(in: folderId).filter{ $0.order > deletedOrder } + for bookmarkEntity in subsequentBookmarkEntities { + bookmarkEntity.order -= 1 + } + lastBookmarkOrder[folderId] = (lastBookmarkOrder[folderId] ?? 1) - 1 + save() + } + + private func getAllBookmarkEntity(in folderId: UUID) -> [BookmarkEntity] { + let context = persistentContainer.viewContext + + guard let folder = getFolderEntity(id: folderId) else { return [] } + let fetchRequest = BookmarkEntity.fetchRequest() + let sortDescriptor = NSSortDescriptor(key: "order", ascending: true) + fetchRequest.predicate = NSPredicate(format: "folder == %@", folder) + fetchRequest.sortDescriptors = [sortDescriptor] + + do { + return try context.fetch(fetchRequest) + } catch { + print(error.localizedDescription) + return [] + } + } + + func deleteAllBookmarks(folderId: UUID) { + let context = persistentContainer.viewContext + + let bookmarks = getAllBookmarkEntity(in: folderId) + for bookmark in bookmarks { + context.delete(bookmark) + } + save() + } + + func moveBookmark(from source: IndexPath, to destination: IndexPath, srcId: UUID, destFolderId: UUID) { + if source.section == destination.section { + moveWithinSameFolder(from: source.row, to: destination.row, bookmarkId: srcId, folderId: destFolderId) + } else { + moveToDifferentFolder(from: source, to: destination, bookmarkId: srcId, destFolderId: destFolderId) + } + save() + } + + private func moveWithinSameFolder(from sourceIndex: Int, to destinationIndex: Int, bookmarkId: UUID, folderId: UUID) { + var bookmarks = getAllBookmarkEntity(in: folderId) + let movingBookmark = bookmarks.remove(at: sourceIndex) + + let adjustedDestinationIndex = sourceIndex < destinationIndex ? destinationIndex - 1 : destinationIndex + + bookmarks.insert(movingBookmark, at: adjustedDestinationIndex) + + for (index, bookmark) in bookmarks.enumerated() { + bookmark.order = Int64(index) + } + } + + private func moveToDifferentFolder(from source: IndexPath, to destination: IndexPath, bookmarkId: UUID, destFolderId: UUID) { + guard let movingBookmark = getBookmarkEntity(id: bookmarkId), + let srcFolder = movingBookmark.folder, + let srcFolderId = srcFolder.id else { return } + let srcBookmarks = getAllBookmarkEntity(in: srcFolderId) + let destBookmarks = getAllBookmarkEntity(in: destFolderId) + let destFolder = getFolderEntity(id: destFolderId) + + for bookmark in srcBookmarks where bookmark.order > movingBookmark.order { + bookmark.order -= 1 + } + + movingBookmark.folder = destFolder + movingBookmark.order = Int64(destination.row) + for bookmark in destBookmarks where bookmark.order >= movingBookmark.order { + bookmark.order += 1 + } + } + +} + diff --git a/iBox/Sources/Shared/NetworkManager.swift b/iBox/Sources/Shared/NetworkManager.swift new file mode 100644 index 0000000..f05ac09 --- /dev/null +++ b/iBox/Sources/Shared/NetworkManager.swift @@ -0,0 +1,42 @@ +// +// NetworkManager.swift +// iBox +// +// Created by Chan on 4/2/24. +// + +import Foundation + +class NetworkManager { + static let shared = NetworkManager() + + private init() {} + + func fetchModel(from urlString: String, modelType: T.Type, completion: @escaping (Result) -> Void) { + guard let url = URL(string: urlString) else { + completion(.failure(NSError(domain: "NetworkManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + return + } + + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + completion(.failure(NSError(domain: "NetworkManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) + return + } + + do { + let model = try JSONDecoder().decode(modelType, from: data) + completion(.success(model)) + } catch { + completion(.failure(error)) + } + } + + task.resume() + } +} diff --git a/iBox/Sources/Shared/SlideInPresentationManager.swift b/iBox/Sources/Shared/SlideInPresentationManager.swift new file mode 100644 index 0000000..6e51d0f --- /dev/null +++ b/iBox/Sources/Shared/SlideInPresentationManager.swift @@ -0,0 +1,22 @@ +// +// SlideInPresentationManager.swift +// iBox +// +// Created by Chan on 4/23/24. +// + +import UIKit + +class SlideInPresentationManager: NSObject, UIViewControllerTransitioningDelegate { + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + return FullSizePresentationController(presentedViewController: presented, presenting: presenting) + } + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return SlideInPresentationAnimator(isPresentation: true) + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return SlideInPresentationAnimator(isPresentation: false) + } +} diff --git a/iBox/Sources/Shared/UserDefaultsManager.swift b/iBox/Sources/Shared/UserDefaultsManager.swift new file mode 100644 index 0000000..36c58bb --- /dev/null +++ b/iBox/Sources/Shared/UserDefaultsManager.swift @@ -0,0 +1,60 @@ +// +// UserDefaultsManager.swift +// iBox +// +// Created by jiyeon on 1/8/24. +// + +import Foundation + +final class UserDefaultsManager { + + @UserDefaultsData(key: "theme", defaultValue: Theme.system) + static var theme: Theme + + @UserDefaultsData(key: "favoriteId", defaultValue: nil) + static var favoriteId: UUID? + + @UserDefaultsData(key: "homeTabIndex", defaultValue: 0) + static var homeTabIndex: Int + + @UserDefaultsData(key: "isDefaultDataInserted", defaultValue: false) + static var isDefaultDataInserted: Bool + + @UserDefaultsData(key: "isHaptics", defaultValue: true) + static var isHaptics: Bool + + @UserDefaultsData(key: "selectedFolderId", defaultValue: nil) + static var selectedFolderId: UUID? + +} + +@propertyWrapper +struct UserDefaultsData { + let key: String + let defaultValue: Value + var container: UserDefaults = .standard + + var wrappedValue: Value { + get { + guard let data = container.object(forKey: key) as? Data else { + return defaultValue + } + do { + let value = try JSONDecoder().decode(Value.self, from: data) + return value + } catch { + print("Error decoding UserDefaults data for key \(key): \(error)") + return defaultValue + } + } + set { + do { + let data = try JSONEncoder().encode(newValue) + container.set(data, forKey: key) + } catch { + print("Error encoding UserDefaults data for key \(key): \(error)") + } + } + } +} diff --git a/iBox/Sources/Shared/WebCacheManager.swift b/iBox/Sources/Shared/WebCacheManager.swift new file mode 100644 index 0000000..c3123d3 --- /dev/null +++ b/iBox/Sources/Shared/WebCacheManager.swift @@ -0,0 +1,49 @@ +// +// WebCacheManager.swift +// iBox +// +// Created by jiyeon on 3/26/24. +// + +import UIKit + +class WebCacheManager { + + static let shared = WebCacheManager() + + private let cache = NSCache() + + private init() {} + + func cacheData(forKey uuid: UUID, viewController: WebViewController) { + let wrapper = UUIDWrapper(uuid: uuid) + cache.setObject(viewController, forKey: wrapper) + } + + func viewControllerForKey(_ uuid: UUID) -> WebViewController? { + let wrapper = UUIDWrapper(uuid: uuid) + return cache.object(forKey: wrapper) + } + + func removeViewControllerForKey(_ uuid: UUID) { + let wrapper = UUIDWrapper(uuid: uuid) + cache.removeObject(forKey: wrapper) + } +} + +class UUIDWrapper: NSObject { + let uuid: UUID + + init(uuid: UUID) { + self.uuid = uuid + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? UUIDWrapper else { return false } + return uuid == other.uuid + } + + override var hash: Int { + return uuid.hashValue + } +} diff --git a/iBox/Sources/Shared/WebViewPreloader.swift b/iBox/Sources/Shared/WebViewPreloader.swift new file mode 100644 index 0000000..734c0df --- /dev/null +++ b/iBox/Sources/Shared/WebViewPreloader.swift @@ -0,0 +1,48 @@ +// +// WebViewPreloader.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/18/24. +// + +import Foundation +import WebKit + +class WebViewPreloader { + static let shared = WebViewPreloader() + private var favoriteView: (url: URL, webView: WebView)? + private var defaultUrl = URL(string: "https://profile.intra.42.fr/")! + + private init() {} + + func preloadFavoriteView(url: URL?) { + let webView = WebView() + webView.isOpaque = false + webView.selectedWebsite = url ?? defaultUrl + favoriteView = (url ?? defaultUrl, webView) + } + + func getFavoriteView() -> WebView? { + return favoriteView?.webView + } + + func resetFavoriteView() { + guard let favoriteView else { return } + favoriteView.webView.selectedWebsite = favoriteView.url + } + + func setFavoriteUrl(url: URL?) { + if let favoriteView { + if url == favoriteView.url || + (url == nil && favoriteView.url == defaultUrl ) { + return + } else { + self.favoriteView?.url = url ?? defaultUrl + resetFavoriteView() + } + } else { + preloadFavoriteView(url: url) + } + } + +} diff --git a/iBox/Sources/Versioning/VersionCheckCode.swift b/iBox/Sources/Versioning/VersionCheckCode.swift new file mode 100644 index 0000000..37b250e --- /dev/null +++ b/iBox/Sources/Versioning/VersionCheckCode.swift @@ -0,0 +1,21 @@ +// +// VersionCheckCode.swift +// iBox +// +// Created by Chan on 3/2/24. +// + +enum VersionCheckCode: Equatable { + case initial // μ΄ˆκΈ°κ°’ + case success // 성곡 + case later // λ‚˜μ€‘μ— (ab testing code) + case update // μ—…λ°μ΄νŠΈ (ab testing code) + case urlError // URL κ΄€λ ¨ μ—λŸ¬ + case networkError // λ„€νŠΈμ›Œν¬ μš”μ²­ μ‹€νŒ¨ + case decodingError // λ””μ½”λ”© μ‹€νŒ¨ + case versionOutdated(mandatoryUpdate: Bool, updateUrl: String) // 버전이 ꡬ버전일 λ•Œ + case serverError // μ„œλ²„ μ—λŸ¬ λ˜λŠ” 기타 μ—λŸ¬ + case internalSceneError // λ‚΄λΆ€ 씬 μ—λŸ¬ + case internalInfoError // λ‚΄λΆ€ 인포 μ—λŸ¬ + case maxRetryReached // μ΅œλŒ€ μž¬μ‹œλ„ 횟수 도달 +} diff --git a/iBox/Sources/Versioning/VersioningHandler.swift b/iBox/Sources/Versioning/VersioningHandler.swift new file mode 100644 index 0000000..cccde8f --- /dev/null +++ b/iBox/Sources/Versioning/VersioningHandler.swift @@ -0,0 +1,83 @@ +// +// VersioningHandler.swift +// iBox +// +// Created by Chan on 3/2/24. +// + +import UIKit + +class VersioningHandler { + + func checkAppVersion(retryCount: Int = 0, completion: @escaping (VersionCheckCode) -> Void) { + let maxRetryCount = 3 + let urlString = "https://raw.githubusercontent.com/42Box/versioning/main/db.json" + + NetworkManager.shared.fetchModel(from: urlString, modelType: VersionInfo.self) { result in + switch result { + case .success(let versionInfo): + guard let latestVersion = versionInfo.version.first?.latestVersion, + let minRequiredVersion = versionInfo.version.first?.minRequiredVersion else { + completion(.urlError) + return + } + + self.compareVersion(latestVersion: latestVersion, minRequiredVersion: minRequiredVersion, storeURL: versionInfo.url.storeUrl, completion: completion) + + case .failure(let error): + print("Error: \(error.localizedDescription)") + + if retryCount < maxRetryCount { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.checkAppVersion(retryCount: retryCount + 1, completion: completion) + } + } else { + completion(.maxRetryReached) + } + } + } + } + + func compareVersion(latestVersion: String, minRequiredVersion: String, storeURL: String, completion: @escaping (VersionCheckCode) -> Void) { + guard let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { + completion(.internalInfoError) + return + } + + if appVersion.compare(minRequiredVersion, options: .numeric) == .orderedAscending { + showAlertForUpdate(storeURL: storeURL, isMandatory: true, completion: completion) + } else if appVersion.compare(latestVersion, options: .numeric) == .orderedAscending { + showAlertForUpdate(storeURL: storeURL, isMandatory: false, completion: completion) + } else { + completion(.success) + } + } + + func showAlertForUpdate(storeURL: String, isMandatory: Bool, completion: @escaping (VersionCheckCode) -> Void) { + DispatchQueue.main.async { + guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene, + let rootViewController = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { + completion(.internalSceneError) + return + } + + let message = isMandatory ? "μƒˆλ‘œμš΄ 버전이 ν•„μš”ν•©λ‹ˆλ‹€. μ—…λ°μ΄νŠΈ ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?" : "μƒˆλ‘œμš΄ 버전이 μžˆμŠ΅λ‹ˆλ‹€. μ—…λ°μ΄νŠΈ ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?" + let alert = UIAlertController(title: "μ—…λ°μ΄νŠΈ μ•Œλ¦Ό", message: message, preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: "μ—…λ°μ΄νŠΈ", style: .default, handler: { _ in + if let url = URL(string: storeURL), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + completion(.update) + } + })) + + if !isMandatory { + alert.addAction(UIAlertAction(title: "λ‚˜μ€‘μ—", style: .cancel, handler: { _ in + completion(.later) + })) + } + + rootViewController.present(alert, animated: true) + } + } +} diff --git a/iBox/Sources/Web/RefreshControl.swift b/iBox/Sources/Web/RefreshControl.swift new file mode 100644 index 0000000..6f1a80f --- /dev/null +++ b/iBox/Sources/Web/RefreshControl.swift @@ -0,0 +1,119 @@ +// +// RefreshControl.swift +// iBox +// +// Created by jiyeon on 4/4/24. +// + +import UIKit + +import SnapKit + +enum RefreshControlType { + case addBookmark + case refresh + case back +} + +class RefreshControl: UIView { + + var currentType: RefreshControlType? + + // MARK: - UI Components + + let addBookmarkButton = UIButton().then { + $0.configuration = .plain() + $0.tintColor = .label + $0.configuration?.image = UIImage(systemName: "bookmark.circle") + $0.configuration?.imagePadding = 10 + $0.configuration?.imagePlacement = .top + $0.configuration?.preferredSymbolConfigurationForImage = .init(pointSize: 20.0) + $0.configuration?.attributedTitle = AttributedString("뢁마크 μΆ”κ°€", attributes: AttributeContainer([NSAttributedString.Key.font: UIFont.refreshControlFont])) + $0.layer.cornerRadius = 15 + } + + let refreshButton = UIButton().then { + $0.configuration = .plain() + $0.tintColor = .label + $0.configuration?.image = UIImage(systemName: "arrow.clockwise.circle") + $0.configuration?.imagePadding = 10 + $0.configuration?.imagePlacement = .top + $0.configuration?.preferredSymbolConfigurationForImage = .init(pointSize: 20.0) + $0.configuration?.attributedTitle = AttributedString("μƒˆλ‘œκ³ μΉ¨", attributes: AttributeContainer([NSAttributedString.Key.font: UIFont.refreshControlFont])) + $0.layer.cornerRadius = 15 + } + + let backButton = UIButton().then { + $0.configuration = .plain() + $0.tintColor = .label + $0.configuration?.image = UIImage(systemName: "arrowshape.turn.up.backward.circle") + $0.configuration?.imagePadding = 10 + $0.configuration?.imagePlacement = .top + $0.configuration?.preferredSymbolConfigurationForImage = .init(pointSize: 20.0) + $0.configuration?.attributedTitle = AttributedString("처음으둜 이동", attributes: AttributeContainer([NSAttributedString.Key.font: UIFont.refreshControlFont])) + $0.layer.cornerRadius = 15 + } + + let stackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.spacing = 20 + } + + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MAKR: - Setup Methods + + private func setupProperty() { + backgroundColor = .backgroundColor + isUserInteractionEnabled = true + } + + private func setupHierarchy() { + addSubview(stackView) + stackView.addArrangedSubview(addBookmarkButton) + stackView.addArrangedSubview(refreshButton) + stackView.addArrangedSubview(backButton) + } + + private func setupLayout() { + stackView.snp.makeConstraints { make in + make.leading.bottom.trailing.equalToSuperview().inset(20) + } + } + + func setSelected(_ type: RefreshControlType) { + if type == currentType { return } + currentType = type + clear() + switch type { + case .addBookmark: addBookmarkButton.backgroundColor = .tableViewBackgroundColor + case .refresh: refreshButton.backgroundColor = .tableViewBackgroundColor + case .back: backButton.backgroundColor = .tableViewBackgroundColor + } + if UserDefaultsManager.isHaptics { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + } + } + + func clear() { + [addBookmarkButton, refreshButton, backButton].forEach { button in + button.backgroundColor = .clear + } + } + +} diff --git a/iBox/Sources/Web/WebView.swift b/iBox/Sources/Web/WebView.swift new file mode 100644 index 0000000..760425d --- /dev/null +++ b/iBox/Sources/Web/WebView.swift @@ -0,0 +1,233 @@ +// +// WebView.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/4/24. +// + +import UIKit +import WebKit + +import SnapKit + +class WebView: UIView { + + var delegate: WebViewDelegate? + var errorDelegate: WebViewErrorDelegate? + var lastRequestedURL: URL? + + private var progressObserver: NSKeyValueObservation? + + var selectedWebsite: URL? { + didSet { + loadWebsite() + } + } + + private var refreshControlHeight: CGFloat = 120.0 + private var isActive = false + + // MARK: - UI Components + + + private let webView: WKWebView + + private let progressView = UIProgressView().then { + $0.progressViewStyle = .bar + $0.tintColor = .box2 + $0.sizeToFit() + } + + private var refreshControl: RefreshControl? + + // MARK: - Initializer + + override init(frame: CGRect) { + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: .zero, configuration: config) + super.init(frame: frame) + + setupProperty() + setupHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + progressObserver?.invalidate() + webView.stopLoading() + webView.isOpaque = false + webView.navigationDelegate = nil + webView.scrollView.delegate = nil + } + + // MARK: - Setup Methods + + private func setupProperty() { + backgroundColor = .backgroundColor + webView.navigationDelegate = self + webView.uiDelegate = self + progressObserver = webView.observe(\.estimatedProgress, options: .new) { [weak self] webView, _ in + self?.progressView.setProgress(Float(webView.estimatedProgress), animated: true) + } + } + + private func setupHierarchy() { + addSubview(webView) + addSubview(progressView) + } + + private func setupLayout() { + webView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide.snp.topMargin) + make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottomMargin) + make.leading.equalTo(self.safeAreaLayoutGuide.snp.leadingMargin) + make.trailing.equalTo(self.safeAreaLayoutGuide.snp.trailingMargin) + } + + progressView.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide.snp.topMargin) + make.leading.trailing.equalToSuperview() + } + } + + func setupRefreshControl() { + // pan gesture + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe)) + panGestureRecognizer.delegate = self // UIGestureRecognizerDelegate + addGestureRecognizer(panGestureRecognizer) + // refresh control + let refreshControl = RefreshControl(frame: .init(x: 0, y: -frame.size.height, width: frame.size.width, height: frame.size.height)) + webView.scrollView.addSubview(refreshControl) + webView.scrollView.backgroundColor = .backgroundColor + webView.scrollView.delegate = self // UIScrollViewDelegate + self.refreshControl = refreshControl + } + + private func loadWebsite() { + guard let url = selectedWebsite else { return } + webView.load(URLRequest(url: url)) + webView.allowsBackForwardNavigationGestures = true + } + + @objc func handleSwipe(_ gesture: UIPanGestureRecognizer) { + guard isActive, let refreshControl = refreshControl else { return } + + let translation = gesture.translation(in: self) + if gesture.state == .changed { + if abs(translation.x) > 60.0 { + if translation.x > 0 { // 였λ₯Έμͺ½ μŠ€μ™€μ΄ν”„ : 처음 뢁마크둜 λŒμ•„κ°€κΈ° + refreshControl.setSelected(.back) + } else { // μ™Όμͺ½ μŠ€μ™€μ΄ν”„ : ν˜„μž¬ 링크 뢁마크 μΆ”κ°€ + refreshControl.setSelected(.addBookmark) + } + } else { // μ•„λž˜ : μƒˆλ‘œκ³ μΉ¨ + refreshControl.setSelected(.refresh) + } + } else if gesture.state == .ended { // μ‚¬μš©μžμ˜ ν„°μΉ˜κ°€ 끝났을 λ•Œ + switch refreshControl.currentType { + case .addBookmark: + guard let url = webView.url else { return } + delegate?.pushAddBookMarkViewController(url: url) + case .refresh: + self.webView.reload() + case .back: + loadWebsite() + case .none: break + } + // 제슀처 μ™„λ£Œ ν›„ μ΄ˆκΈ°ν™” + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + refreshControl.clear() + } + } + // 제슀처 μ΄ˆκΈ°ν™” + if gesture.state == .ended || gesture.state == .cancelled { + gesture.setTranslation(CGPoint.zero, in: self) + refreshControl.currentType = nil + } + } + +} + +extension WebView: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + // λ‘œλ”© μ‹œμž‘ μ‹œ ν”„λ‘œκ·Έλ ˆμŠ€ λ°”λ₯Ό 보여주고 μ§„ν–‰λ₯  μ΄ˆκΈ°ν™” + progressView.isHidden = false + progressView.setProgress(0.0, animated: false) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + progressView.setProgress(1.0, animated: true) + // μ•½κ°„μ˜ λ”œλ ˆμ΄ ν›„ ν”„λ‘œκ·Έλ ˆμŠ€ λ°”λ₯Ό μˆ¨κΉ€ + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.progressView.isHidden = true + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + // λ§ˆμ§€λ§‰μœΌλ‘œ μ‹œλ„ν•œ navigation url + lastRequestedURL = navigationAction.request.url + + // "μƒˆ 창으둜 μ—΄κΈ°" 링크 WebView λ‚΄μ—μ„œ μ—΄κΈ° + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + // 초기 λ‘œλ“œμ‹œ μ—λŸ¬ λ°œμƒ + errorDelegate?.webView(self, didFailWithError: error, url: selectedWebsite) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + // λ„€λΉ„κ²Œμ΄μ…˜ 쀑 μ—λŸ¬ λ°œμƒ μ‹œ + if let lastURL = lastRequestedURL { + errorDelegate?.webView(self, didFailWithError: error, url: lastURL) + } else { + // lastRequestedURL이 nil인 경우 λŒ€λΉ„ + errorDelegate?.webView(self, didFailWithError: error, url: nil) + } + } + + func retryLoading() { + webView.reload() + } +} + +extension WebView: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.contentOffset.y < -refreshControlHeight { + isActive = true + } else { + isActive = false + } + } + +} + +extension WebView: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + // λ‹€λ₯Έ 제슀처 인식기와 λ™μ‹œμ— μΈμ‹λ˜λ„λ‘ ν—ˆμš© + return true + } + +} + +extension WebView: WKUIDelegate { + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + return nil + } +} diff --git a/iBox/Sources/Web/WebViewController.swift b/iBox/Sources/Web/WebViewController.swift new file mode 100644 index 0000000..b872a1a --- /dev/null +++ b/iBox/Sources/Web/WebViewController.swift @@ -0,0 +1,99 @@ +// +// WebViewController.swift +// iBox +// +// Created by μ΄μ§€ν˜„ on 1/4/24. +// + +import UIKit +import WebKit + +protocol WebViewDelegate { + func pushAddBookMarkViewController(url: URL) +} + +class WebViewController: BaseViewController, BaseViewControllerProtocol { + var id: UUID? + var slideInPresentationManager = SlideInPresentationManager() + var errorViewController: ErrorPageViewController? + var delegate: AddBookmarkViewControllerProtocol? + var selectedWebsite: URL? + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + setupView() + setupDelegate() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let contentView = contentView as? WebView else { return } + contentView.setupRefreshControl() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if ((isMovingFromParent || isBeingDismissed) && AppStateManager.shared.currentViewErrorState != .normal){ + if let id = self.id { + WebCacheManager.shared.removeViewControllerForKey(id) + } + } + } + + deinit { + AppStateManager.shared.currentViewErrorState = .normal + errorViewController = nil + } + + // MARK: - BaseViewControllerProtocol + + func setupNavigationBar() { + setNavigationBarHidden(true) + } + + func setupDelegate() { + guard let contentView = contentView as? WebView else { return } + contentView.delegate = self + contentView.selectedWebsite = selectedWebsite + + errorViewController = ErrorPageViewController(webView: contentView) + contentView.errorDelegate = errorViewController + errorViewController?.delegate = self + } + + func setupView() { + view.backgroundColor = .backgroundColor + } +} + +extension WebViewController: WebViewDelegate { + + func pushAddBookMarkViewController(url: URL) { + let encodingURL = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + + if let iBoxUrl = URL(string: "iBox://url?data=" + encodingURL) { + if let tabBarController = findMainTabBarController() { + AddBookmarkManager.shared.navigateToAddBookmarkView(from: iBoxUrl, in: tabBarController) + } + } + } +} + +extension WebViewController: ErrorPageControllerDelegate { + func presentErrorPage(_ errorPage: ErrorPageViewController) { + self.present(errorPage, animated: true, completion: nil) + } + + func backButton() { + if let navController = navigationController { + navController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +}