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 @@
+
+

+
+
+
+[
](https://apps.apple.com)
+
+
+
+# πββοΈπββοΈ Introduction
+μλ
νμΈμ! 42Box iOS νμ
λλ€. π¦Β
+42Boxλ μ¬λ¬ μΉ λ§ν¬λ₯Ό μ½κ² μ μ₯νκ³ ν΄λλ³λ‘ μ 리ν μ μλ μ±μ
λλ€.Β
+λμκ΄ μλΉμ€, μΆμ
κ΄λ¦¬ λ± 42μμΈ μνμ νμμ μΈ μλΉμ€λ€μ κΈ°λ³Έ ν΄λμ λͺ¨μ μ 곡νκ³ μμ΄μ.Β
+π’Β μλΉμ€λ₯Ό μννκ² μ΄μ©νμ€ μ μλλ‘ ν΅μ¬ κΈ°λ₯μ μκ°ν©λλ€. πΒ
+
+
+### βοΈ Key Function
+1. κΈ°λ³Έ 42ν΄λ μ 곡
+
+ > 42μμΈ μλΉμ€μ κ΄λ ¨λ λ§ν¬λ₯Ό μ¬μ μ μ₯ν μ μ© ν΄λλ₯Ό μ 곡ν©λλ€.
+
+2. λΆλ§ν¬ κ΄λ¦¬
+
+ > μΉ λ§ν¬λ₯Ό μμ½κ² μ μ₯νκ³ λλ§μ ν΄λλ‘ μ 리ν μ μμ΅λλ€.
+
+3. μ¦κ²¨μ°ΎκΈ°
+
+ > κ°μ₯ μμ£Ό λ°©λ¬Ένλ μΉ λ§ν¬μ λΉ λ₯΄κ² μ κ·ΌνκΈ° μν΄ μ¦κ²¨μ°ΎκΈ° νμΌλ‘ μΆκ°ν μ μμ΅λλ€.
+
+4. 곡μ
+
+ > μΈλΆ μ±μμ μΉ λ§ν¬λ₯Ό 곡μ ν΄ λΆλ§ν¬λ₯Ό μ½κ² μΆκ°ν μ μμ΅λλ€.
+
+5. λ§ν¬ 미리보기
+
+ > λ§ν¬λ₯Ό μμ ν μ΄μ§ μκ³ λ λΆλ§ν¬λ₯Ό κΈΈκ² λλ¬ λ―Έλ¦¬ λ³Ό μ μμ΅λλ€.
+
+6. μ μ€μ²λ‘ λΆλ§ν¬ μΆκ°
+
+ > λΈλΌμ°μ§νλ λμ κ°λ¨ν μ μ€μ²λ‘ νμ¬ μΉ λ§ν¬λ₯Ό λΆλ§ν¬λ‘ μ½κ² μ μ₯ν μ μμ΅λλ€.
+
+7. ν
λ§ λ° μ€μ
+
+ > ν
λ§μ μμ νλ©΄ μ€μ λ± κ°μΈ λ§μΆ€ν μ€μ μ ν μ μμ΅λλ€.
+
+### π· ScreenShot
+
+| ν΄λ κ΄λ¦¬ | μΈλΆ μ±μμ 곡μ | μ μ€μ²λ‘ λΆλ§ν¬ μΆκ° |
+|:---:|:---:|:---:|
+|
|
|
|
+
+
+| μ¦κ²¨μ°ΎκΈ° μ€μ | ν
λ§ λ° μ€μ |
+|:---:|:---:|
+|
|
|
+
+
+
+
+# βοΈ Development Environment
+
+
+
+### π 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
+
+
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==-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
\ 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==-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
\ 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