diff --git a/ProjectManager/ProjectManager.xcodeproj/project.pbxproj b/ProjectManager/ProjectManager.xcodeproj/project.pbxproj index fa92d95202..541d85949f 100644 --- a/ProjectManager/ProjectManager.xcodeproj/project.pbxproj +++ b/ProjectManager/ProjectManager.xcodeproj/project.pbxproj @@ -7,18 +7,50 @@ objects = { /* Begin PBXBuildFile section */ + 28913B732AC5666D00E626F7 /* TodoDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B722AC5666D00E626F7 /* TodoDateFormatter.swift */; }; + 28913B762AC5950E00E626F7 /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B752AC5950E00E626F7 /* Array+.swift */; }; + 28913B7A2AC5AC9A00E626F7 /* ToDo.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 28913B782AC5AC9A00E626F7 /* ToDo.xcdatamodeld */; }; + 28913B7C2AC5AD1300E626F7 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B7B2AC5AD1300E626F7 /* Category.swift */; }; + 28913B7F2AC8033000E626F7 /* ToDo+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B7D2AC8033000E626F7 /* ToDo+CoreDataClass.swift */; }; + 28913B802AC8033000E626F7 /* ToDo+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B7E2AC8033000E626F7 /* ToDo+CoreDataProperties.swift */; }; + 28913B832AC8114F00E626F7 /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B822AC8114F00E626F7 /* DataManager.swift */; }; + 28913B852AC83ACE00E626F7 /* ToDoDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28913B842AC83ACE00E626F7 /* ToDoDetailViewController.swift */; }; + 28CA66E52AC0F72600FFD7A9 /* ListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA66E42AC0F72600FFD7A9 /* ListCell.swift */; }; + 28CA66E72AC0FE3100FFD7A9 /* ListHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA66E62AC0FE3100FFD7A9 /* ListHeader.swift */; }; + 28CA66EB2AC1805300FFD7A9 /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA66EA2AC1805300FFD7A9 /* ListViewModel.swift */; }; + 28CA66F22AC1A08600FFD7A9 /* Reusable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA66F12AC1A08600FFD7A9 /* Reusable.swift */; }; + 28DBCB962AC9292C00DFC58E /* InsetTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DBCB952AC9292C00DFC58E /* InsetTextField.swift */; }; + 28DBCB982AC94A5F00DFC58E /* UIFont+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DBCB972AC94A5F00DFC58E /* UIFont+.swift */; }; + 28DBCB9A2ACAD19500DFC58E /* ToDoDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DBCB992ACAD19500DFC58E /* ToDoDetailViewModel.swift */; }; + 28DBCBA22ACD40E600DFC58E /* UITextView+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DBCBA12ACD40E600DFC58E /* UITextView+.swift */; }; C7431F0625F51E1D0094C4CF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7431F0525F51E1D0094C4CF /* AppDelegate.swift */; }; C7431F0825F51E1D0094C4CF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7431F0725F51E1D0094C4CF /* SceneDelegate.swift */; }; - C7431F0A25F51E1D0094C4CF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7431F0925F51E1D0094C4CF /* ViewController.swift */; }; + C7431F0A25F51E1D0094C4CF /* ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7431F0925F51E1D0094C4CF /* ListViewController.swift */; }; C7431F0F25F51E1E0094C4CF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C7431F0E25F51E1E0094C4CF /* Assets.xcassets */; }; C7431F1225F51E1E0094C4CF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C7431F1025F51E1E0094C4CF /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 28913B722AC5666D00E626F7 /* TodoDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoDateFormatter.swift; sourceTree = ""; }; + 28913B752AC5950E00E626F7 /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; + 28913B792AC5AC9A00E626F7 /* ToDoModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = ToDoModel.xcdatamodel; sourceTree = ""; }; + 28913B7B2AC5AD1300E626F7 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; + 28913B7D2AC8033000E626F7 /* ToDo+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ToDo+CoreDataClass.swift"; sourceTree = ""; }; + 28913B7E2AC8033000E626F7 /* ToDo+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ToDo+CoreDataProperties.swift"; sourceTree = ""; }; + 28913B822AC8114F00E626F7 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; + 28913B842AC83ACE00E626F7 /* ToDoDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToDoDetailViewController.swift; sourceTree = ""; }; + 28CA66E42AC0F72600FFD7A9 /* ListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCell.swift; sourceTree = ""; }; + 28CA66E62AC0FE3100FFD7A9 /* ListHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListHeader.swift; sourceTree = ""; }; + 28CA66EA2AC1805300FFD7A9 /* ListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModel.swift; sourceTree = ""; }; + 28CA66F12AC1A08600FFD7A9 /* Reusable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reusable.swift; sourceTree = ""; }; + 28DBCB952AC9292C00DFC58E /* InsetTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetTextField.swift; sourceTree = ""; }; + 28DBCB972AC94A5F00DFC58E /* UIFont+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+.swift"; sourceTree = ""; }; + 28DBCB992ACAD19500DFC58E /* ToDoDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToDoDetailViewModel.swift; sourceTree = ""; }; + 28DBCBA12ACD40E600DFC58E /* UITextView+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+.swift"; sourceTree = ""; }; C7431F0225F51E1D0094C4CF /* ProjectManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ProjectManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; C7431F0525F51E1D0094C4CF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C7431F0725F51E1D0094C4CF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - C7431F0925F51E1D0094C4CF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + C7431F0925F51E1D0094C4CF /* ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewController.swift; sourceTree = ""; }; C7431F0E25F51E1E0094C4CF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C7431F1125F51E1E0094C4CF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C7431F1325F51E1E0094C4CF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -35,6 +67,93 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 28913B742AC594EE00E626F7 /* Extension */ = { + isa = PBXGroup; + children = ( + 28913B752AC5950E00E626F7 /* Array+.swift */, + 28DBCB972AC94A5F00DFC58E /* UIFont+.swift */, + ); + path = Extension; + sourceTree = ""; + }; + 28913B772AC5AC3100E626F7 /* ToDo */ = { + isa = PBXGroup; + children = ( + 28913B782AC5AC9A00E626F7 /* ToDo.xcdatamodeld */, + 28913B7D2AC8033000E626F7 /* ToDo+CoreDataClass.swift */, + 28913B7E2AC8033000E626F7 /* ToDo+CoreDataProperties.swift */, + 28913B7B2AC5AD1300E626F7 /* Category.swift */, + 28913B822AC8114F00E626F7 /* DataManager.swift */, + ); + path = ToDo; + sourceTree = ""; + }; + 2897D3752AC05F8400662BAA /* ViewModel */ = { + isa = PBXGroup; + children = ( + 28CA66EA2AC1805300FFD7A9 /* ListViewModel.swift */, + 28DBCB992ACAD19500DFC58E /* ToDoDetailViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + 2897D3762AC05F8D00662BAA /* View */ = { + isa = PBXGroup; + children = ( + 28DBCBA02ACD40DC00DFC58E /* Extension */, + C7431F0925F51E1D0094C4CF /* ListViewController.swift */, + 28CA66E62AC0FE3100FFD7A9 /* ListHeader.swift */, + 28CA66E42AC0F72600FFD7A9 /* ListCell.swift */, + 28913B842AC83ACE00E626F7 /* ToDoDetailViewController.swift */, + 28DBCB952AC9292C00DFC58E /* InsetTextField.swift */, + ); + path = View; + sourceTree = ""; + }; + 2897D3772AC05F9500662BAA /* Model */ = { + isa = PBXGroup; + children = ( + 28913B772AC5AC3100E626F7 /* ToDo */, + ); + path = Model; + sourceTree = ""; + }; + 2897D3782AC05F9D00662BAA /* Application */ = { + isa = PBXGroup; + children = ( + C7431F0525F51E1D0094C4CF /* AppDelegate.swift */, + C7431F0725F51E1D0094C4CF /* SceneDelegate.swift */, + ); + path = Application; + sourceTree = ""; + }; + 2897D3792AC05FBE00662BAA /* Resource */ = { + isa = PBXGroup; + children = ( + C7431F1025F51E1E0094C4CF /* LaunchScreen.storyboard */, + C7431F0E25F51E1E0094C4CF /* Assets.xcassets */, + ); + path = Resource; + sourceTree = ""; + }; + 28CA66F02AC1A02C00FFD7A9 /* Utility */ = { + isa = PBXGroup; + children = ( + 28913B742AC594EE00E626F7 /* Extension */, + 28CA66F12AC1A08600FFD7A9 /* Reusable.swift */, + 28913B722AC5666D00E626F7 /* TodoDateFormatter.swift */, + ); + path = Utility; + sourceTree = ""; + }; + 28DBCBA02ACD40DC00DFC58E /* Extension */ = { + isa = PBXGroup; + children = ( + 28DBCBA12ACD40E600DFC58E /* UITextView+.swift */, + ); + path = Extension; + sourceTree = ""; + }; C7431EF925F51E1D0094C4CF = { isa = PBXGroup; children = ( @@ -54,11 +173,12 @@ C7431F0425F51E1D0094C4CF /* ProjectManager */ = { isa = PBXGroup; children = ( - C7431F0525F51E1D0094C4CF /* AppDelegate.swift */, - C7431F0725F51E1D0094C4CF /* SceneDelegate.swift */, - C7431F0925F51E1D0094C4CF /* ViewController.swift */, - C7431F0E25F51E1E0094C4CF /* Assets.xcassets */, - C7431F1025F51E1E0094C4CF /* LaunchScreen.storyboard */, + 2897D3782AC05F9D00662BAA /* Application */, + 2897D3772AC05F9500662BAA /* Model */, + 2897D3762AC05F8D00662BAA /* View */, + 2897D3752AC05F8400662BAA /* ViewModel */, + 2897D3792AC05FBE00662BAA /* Resource */, + 28CA66F02AC1A02C00FFD7A9 /* Utility */, C7431F1325F51E1E0094C4CF /* Info.plist */, ); path = ProjectManager; @@ -133,9 +253,25 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C7431F0A25F51E1D0094C4CF /* ViewController.swift in Sources */, + 28913B732AC5666D00E626F7 /* TodoDateFormatter.swift in Sources */, + 28913B852AC83ACE00E626F7 /* ToDoDetailViewController.swift in Sources */, + 28913B832AC8114F00E626F7 /* DataManager.swift in Sources */, + 28DBCB962AC9292C00DFC58E /* InsetTextField.swift in Sources */, + 28CA66E52AC0F72600FFD7A9 /* ListCell.swift in Sources */, + 28913B802AC8033000E626F7 /* ToDo+CoreDataProperties.swift in Sources */, + 28CA66F22AC1A08600FFD7A9 /* Reusable.swift in Sources */, + C7431F0A25F51E1D0094C4CF /* ListViewController.swift in Sources */, + 28CA66EB2AC1805300FFD7A9 /* ListViewModel.swift in Sources */, + 28DBCBA22ACD40E600DFC58E /* UITextView+.swift in Sources */, + 28DBCB9A2ACAD19500DFC58E /* ToDoDetailViewModel.swift in Sources */, + 28DBCB982AC94A5F00DFC58E /* UIFont+.swift in Sources */, C7431F0625F51E1D0094C4CF /* AppDelegate.swift in Sources */, + 28CA66E72AC0FE3100FFD7A9 /* ListHeader.swift in Sources */, + 28913B7F2AC8033000E626F7 /* ToDo+CoreDataClass.swift in Sources */, C7431F0825F51E1D0094C4CF /* SceneDelegate.swift in Sources */, + 28913B7A2AC5AC9A00E626F7 /* ToDo.xcdatamodeld in Sources */, + 28913B762AC5950E00E626F7 /* Array+.swift in Sources */, + 28913B7C2AC5AD1300E626F7 /* Category.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -329,6 +465,19 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + 28913B782AC5AC9A00E626F7 /* ToDo.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 28913B792AC5AC9A00E626F7 /* ToDoModel.xcdatamodel */, + ); + currentVersion = 28913B792AC5AC9A00E626F7 /* ToDoModel.xcdatamodel */; + path = ToDo.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = C7431EFA25F51E1D0094C4CF /* Project object */; } diff --git a/ProjectManager/ProjectManager.xcodeproj/xcshareddata/xcschemes/ProjectManager.xcscheme b/ProjectManager/ProjectManager.xcodeproj/xcshareddata/xcschemes/ProjectManager.xcscheme new file mode 100644 index 0000000000..c4576fde44 --- /dev/null +++ b/ProjectManager/ProjectManager.xcodeproj/xcshareddata/xcschemes/ProjectManager.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ProjectManager/ProjectManager/AppDelegate.swift b/ProjectManager/ProjectManager/Application/AppDelegate.swift similarity index 100% rename from ProjectManager/ProjectManager/AppDelegate.swift rename to ProjectManager/ProjectManager/Application/AppDelegate.swift diff --git a/ProjectManager/ProjectManager/SceneDelegate.swift b/ProjectManager/ProjectManager/Application/SceneDelegate.swift similarity index 95% rename from ProjectManager/ProjectManager/SceneDelegate.swift rename to ProjectManager/ProjectManager/Application/SceneDelegate.swift index 1e63da054f..59112a618c 100644 --- a/ProjectManager/ProjectManager/SceneDelegate.swift +++ b/ProjectManager/ProjectManager/Application/SceneDelegate.swift @@ -15,7 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = (scene as? UIWindowScene) else { return } window = UIWindow(windowScene: windowScene) - window?.rootViewController = ViewController() + window?.rootViewController = UINavigationController(rootViewController: ListViewController()) window?.makeKeyAndVisible() } diff --git a/ProjectManager/ProjectManager/Model/ToDo/Category.swift b/ProjectManager/ProjectManager/Model/ToDo/Category.swift new file mode 100644 index 0000000000..4f94dcd2d8 --- /dev/null +++ b/ProjectManager/ProjectManager/Model/ToDo/Category.swift @@ -0,0 +1,12 @@ +// +// Category.swift +// ProjectManager +// +// Created by Moon on 2023/09/28. +// + +enum Category: String { + case todo = "TODO" + case doing = "DOING" + case done = "DONE" +} diff --git a/ProjectManager/ProjectManager/Model/ToDo/DataManager.swift b/ProjectManager/ProjectManager/Model/ToDo/DataManager.swift new file mode 100644 index 0000000000..4d8f70245f --- /dev/null +++ b/ProjectManager/ProjectManager/Model/ToDo/DataManager.swift @@ -0,0 +1,75 @@ +// +// DataManager.swift +// ProjectManager +// +// Created by Moon on 2023/09/30. +// + +import CoreData + +final class DataManager { + private let persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "ToDo") + + container.loadPersistentStores { storeDescription, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + + return container + }() + + private let viewContext: NSManagedObjectContext + + init() { + viewContext = persistentContainer.viewContext + } + + func createToDo(title: String? = "", deadline: Date? = Date(), body: String? = "", category: Category) -> ToDo { + let todo = ToDo(context: viewContext) + todo.title = title + todo.deadline = deadline + todo.body = body + todo.category = category.rawValue + + return todo + } + + func fetchToDoList() -> [ToDo] { + do { + let fetchRequest = ToDo.fetchRequest() + + return try viewContext.fetch(fetchRequest) + } catch let error as NSError { + print("Unresolved error: \(error), \(error.userInfo)") + + return [] + } + } + + func saveContext() { + guard viewContext.hasChanges else { + return + } + + do { + try viewContext.save() + postCalledSaveContext() + } catch let error as NSError { + print("Unresolved error: \(error), \(error.userInfo)") + } + } + + func deleteItem(_ todo: ToDo) { + viewContext.delete(todo) + } + + private func postCalledSaveContext() { + NotificationCenter.default + .post( + name: NSNotification.Name("CalledSaveContext"), + object: nil + ) + } +} diff --git a/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataClass.swift b/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataClass.swift new file mode 100644 index 0000000000..2257fb1ffd --- /dev/null +++ b/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// ToDo+CoreDataClass.swift +// ProjectManager +// +// Created by 조호준 on 2023/09/30. +// +// + +import Foundation +import CoreData + +@objc(ToDo) +public class ToDo: NSManagedObject { + +} diff --git a/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataProperties.swift b/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataProperties.swift new file mode 100644 index 0000000000..e2c16692f6 --- /dev/null +++ b/ProjectManager/ProjectManager/Model/ToDo/ToDo+CoreDataProperties.swift @@ -0,0 +1,28 @@ +// +// ToDo+CoreDataProperties.swift +// ProjectManager +// +// Created by 조호준 on 2023/09/30. +// +// + +import Foundation +import CoreData + + +extension ToDo { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ToDo") + } + + @NSManaged public var title: String? + @NSManaged public var body: String? + @NSManaged public var deadline: Date? + @NSManaged public var category: String? + +} + +extension ToDo : Identifiable { + +} diff --git a/ProjectManager/ProjectManager/Model/ToDo/ToDo.xcdatamodeld/ToDoModel.xcdatamodel/contents b/ProjectManager/ProjectManager/Model/ToDo/ToDo.xcdatamodeld/ToDoModel.xcdatamodel/contents new file mode 100644 index 0000000000..2d7aa960f5 --- /dev/null +++ b/ProjectManager/ProjectManager/Model/ToDo/ToDo.xcdatamodeld/ToDoModel.xcdatamodel/contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ProjectManager/ProjectManager/Assets.xcassets/AccentColor.colorset/Contents.json b/ProjectManager/ProjectManager/Resource/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from ProjectManager/ProjectManager/Assets.xcassets/AccentColor.colorset/Contents.json rename to ProjectManager/ProjectManager/Resource/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/ProjectManager/ProjectManager/Assets.xcassets/AppIcon.appiconset/Contents.json b/ProjectManager/ProjectManager/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ProjectManager/ProjectManager/Assets.xcassets/AppIcon.appiconset/Contents.json rename to ProjectManager/ProjectManager/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ProjectManager/ProjectManager/Assets.xcassets/Contents.json b/ProjectManager/ProjectManager/Resource/Assets.xcassets/Contents.json similarity index 100% rename from ProjectManager/ProjectManager/Assets.xcassets/Contents.json rename to ProjectManager/ProjectManager/Resource/Assets.xcassets/Contents.json diff --git a/ProjectManager/ProjectManager/Base.lproj/LaunchScreen.storyboard b/ProjectManager/ProjectManager/Resource/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from ProjectManager/ProjectManager/Base.lproj/LaunchScreen.storyboard rename to ProjectManager/ProjectManager/Resource/Base.lproj/LaunchScreen.storyboard diff --git a/ProjectManager/ProjectManager/Utility/Extension/Array+.swift b/ProjectManager/ProjectManager/Utility/Extension/Array+.swift new file mode 100644 index 0000000000..d61a173a6a --- /dev/null +++ b/ProjectManager/ProjectManager/Utility/Extension/Array+.swift @@ -0,0 +1,12 @@ +// +// Array+.swift +// ProjectManager +// +// Created by Moon on 2023/09/28. +// + +extension Array { + subscript(safe index: Int) -> Element? { + return indices ~= index ? self[index] : nil + } +} diff --git a/ProjectManager/ProjectManager/Utility/Extension/UIFont+.swift b/ProjectManager/ProjectManager/Utility/Extension/UIFont+.swift new file mode 100644 index 0000000000..38a9bf168d --- /dev/null +++ b/ProjectManager/ProjectManager/Utility/Extension/UIFont+.swift @@ -0,0 +1,18 @@ +// +// UIFont+.swift +// ProjectManager +// +// Created by Moon on 2023/10/01. +// + +import UIKit + +extension UIFont { + static func preferredFont(for style: TextStyle, weight: Weight) -> UIFont { + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + let font = UIFont.systemFont(ofSize: descriptor.pointSize, weight: weight) + let metrics = UIFontMetrics(forTextStyle: style) + + return metrics.scaledFont(for: font) + } +} diff --git a/ProjectManager/ProjectManager/Utility/Reusable.swift b/ProjectManager/ProjectManager/Utility/Reusable.swift new file mode 100644 index 0000000000..c7838958a5 --- /dev/null +++ b/ProjectManager/ProjectManager/Utility/Reusable.swift @@ -0,0 +1,18 @@ +// +// Reusable.swift +// ProjectManager +// +// Created by Moon on 2023/09/25. +// + +import UIKit + +protocol Reusable { } + +extension Reusable where Self: UIView { + static var identifier: String { + return String(describing: self) + } +} + +extension UITableViewCell: Reusable { } diff --git a/ProjectManager/ProjectManager/Utility/TodoDateFormatter.swift b/ProjectManager/ProjectManager/Utility/TodoDateFormatter.swift new file mode 100644 index 0000000000..0eb909128c --- /dev/null +++ b/ProjectManager/ProjectManager/Utility/TodoDateFormatter.swift @@ -0,0 +1,29 @@ +// +// TodoDateFormatter.swift +// ProjectManager +// +// Created by Moon on 2023/09/28. +// + +import Foundation + +enum DateFormat: String { + case todo = "yyyy. M. d." +} + +final class TodoDateFormatter { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = DateFormat.todo.rawValue + + return formatter + }() + + private init() { } + + static func string(from date: Date, format: DateFormat) -> String { + dateFormatter.dateFormat = format.rawValue + + return dateFormatter.string(from: date) + } +} diff --git a/ProjectManager/ProjectManager/View/Extension/UITextView+.swift b/ProjectManager/ProjectManager/View/Extension/UITextView+.swift new file mode 100644 index 0000000000..2b386e0284 --- /dev/null +++ b/ProjectManager/ProjectManager/View/Extension/UITextView+.swift @@ -0,0 +1,19 @@ +// +// UITextView+.swift +// ProjectManager +// +// Created by Moon on 2023/10/04. +// + +import UIKit +import Combine + +extension UITextView { + var textPublisher: AnyPublisher { + NotificationCenter.default + .publisher(for: UITextView.textDidChangeNotification, object: self) + .compactMap { $0.object as? UITextView } // 위 publisher의 리턴이 Notification이기 때문에 전달한 object(Any 타입)를 UITextView로 캐스팅 + .compactMap(\.text) // 위에서 캐스팅한 텍스트 뷰에서 text 추출 + .eraseToAnyPublisher() // 위에서 받은 text를 Publisher로 감쌈 + } +} diff --git a/ProjectManager/ProjectManager/View/InsetTextField.swift b/ProjectManager/ProjectManager/View/InsetTextField.swift new file mode 100644 index 0000000000..ab06b9cfa7 --- /dev/null +++ b/ProjectManager/ProjectManager/View/InsetTextField.swift @@ -0,0 +1,24 @@ +// +// InsetTextField.swift +// ProjectManager +// +// Created by Moon on 2023/10/01. +// + +import UIKit + +final class InsetTextField: UITextField { + private let inset = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15) + + override func textRect(forBounds bounds: CGRect) -> CGRect { + return bounds.inset(by: inset) + } + + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + return bounds.inset(by: inset) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + return bounds.inset(by: inset) + } +} diff --git a/ProjectManager/ProjectManager/View/ListCell.swift b/ProjectManager/ProjectManager/View/ListCell.swift new file mode 100644 index 0000000000..676b1ae5b5 --- /dev/null +++ b/ProjectManager/ProjectManager/View/ListCell.swift @@ -0,0 +1,112 @@ +// +// ListCell.swift +// ProjectManager +// +// Created by Moon on 2023/09/25. +// + +import UIKit + +final class ListCell: UITableViewCell { + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(for: .title3, weight: .semibold) + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .body) + label.textColor = .systemGray3 + label.numberOfLines = 3 + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private let deadlineLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .callout) + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private let contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .leading + + return stackView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.frame = contentView.frame + .inset(by: UIEdgeInsets( + top: 8, + left: .zero, + bottom: .zero, + right: .zero) + ) + } + + func setUpContent(_ todo: ToDo) { + titleLabel.text = todo.title + descriptionLabel.text = todo.body + deadlineLabel.text = TodoDateFormatter.string( + from: todo.deadline ?? Date(), + format: DateFormat.todo + ) + } +} + +// MARK: - Configure UI +extension ListCell { + private func configureUI() { + addSubviews() + setUpContentStackViewConstraints() + setUpBackgroundColors() + } + + private func addSubviews() { + [titleLabel, descriptionLabel, deadlineLabel].forEach { + contentStackView.addArrangedSubview($0) + } + + contentView.addSubview(contentStackView) + } + + private func setUpContentStackViewConstraints() { + contentStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor + .constraint(equalTo: contentView.leadingAnchor, constant: 8), + contentStackView.trailingAnchor + .constraint(equalTo: contentView.trailingAnchor, constant: -8), + contentStackView.topAnchor + .constraint(equalTo: contentView.topAnchor, constant: 8), + contentStackView.bottomAnchor + .constraint(equalTo: contentView.bottomAnchor, constant: -8) + ]) + } + + private func setUpBackgroundColors() { + backgroundColor = .systemGray6 + contentView.backgroundColor = .systemBackground + } +} diff --git a/ProjectManager/ProjectManager/View/ListHeader.swift b/ProjectManager/ProjectManager/View/ListHeader.swift new file mode 100644 index 0000000000..78c7e12eff --- /dev/null +++ b/ProjectManager/ProjectManager/View/ListHeader.swift @@ -0,0 +1,110 @@ +// +// ListHeader.swift +// ProjectManager +// +// Created by Moon on 2023/09/25. +// + +import UIKit + +final class ListHeader: UIView { + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(for: .largeTitle, weight: .semibold) + label.text = "TODO" + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private var contentAmountLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .title2) + label.textColor = .white + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.layer.backgroundColor = UIColor.black.cgColor + label.layer.cornerRadius = 20 + + return label + }() + + private let contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 8 + + return stackView + }() + + // contentAmountLabel에 TODO 개수를 표시하기 위한 데이터를 바인딩 할 뷰 모델 + private let listViewModel: ListViewModel + + init(viewModel: ListViewModel) { + listViewModel = viewModel + + super.init(frame: .zero) + + configureUI() + setUpBindings() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // contentAmountLabel에 TODO 개수 표시 + private func setUpBindings() { + listViewModel.bindCount { [weak self] viewModel in + self?.contentAmountLabel.text = viewModel.count + } + } +} + +// MARK: - Configure UI +extension ListHeader { + private func configureUI() { + setUpView() + addSubviews() + setUpConstraints() + } + + private func setUpView() { + backgroundColor = .systemGray6 + } + + private func addSubviews() { + [titleLabel, contentAmountLabel].forEach { + contentStackView.addArrangedSubview($0) + } + + addSubview(contentStackView) + } + + private func setUpConstraints() { + setUpContentAmountLabelConstraints() + setUpContentStackViewConstraints() + } + + private func setUpContentAmountLabelConstraints() { + contentAmountLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentAmountLabel.widthAnchor + .constraint(equalTo: contentAmountLabel.heightAnchor, multiplier: 1) + ]) + } + + private func setUpContentStackViewConstraints() { + contentStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor + .constraint(equalTo: leadingAnchor, constant: 8), + contentStackView.topAnchor + .constraint(equalTo: topAnchor, constant: 8), + contentStackView.bottomAnchor + .constraint(equalTo: bottomAnchor, constant: -8) + ]) + } +} diff --git a/ProjectManager/ProjectManager/View/ListViewController.swift b/ProjectManager/ProjectManager/View/ListViewController.swift new file mode 100644 index 0000000000..da3fb58de8 --- /dev/null +++ b/ProjectManager/ProjectManager/View/ListViewController.swift @@ -0,0 +1,157 @@ +// +// ProjectManager - ViewController.swift +// Created by yagom. +// Copyright © yagom. All rights reserved. +// + +import UIKit + +final class ListViewController: UIViewController { + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = .systemGray6 + tableView.register(ListCell.self, forCellReuseIdentifier: ListCell.identifier) + tableView.separatorInset.left = .zero + + return tableView + }() + + private let listViewModel = ListViewModel() + + override func viewDidLoad() { + super.viewDidLoad() + + configureUI() + setUpViewDidLoadNotification() + setUpBindings() + } + + // viewDidLoad 시점을 뷰모델에 알려 데이터를 로드할 수 있도록 함 + private func setUpViewDidLoadNotification() { + NotificationCenter.default + .post( + name: NSNotification.Name("ListViewControllerViewDidLoad"), + object: nil + ) + } + + // listViewModel의 todoList가 바뀌면 테이블뷰를 업데이트 + private func setUpBindings() { + listViewModel.bindTodoList { [weak self] in + self?.tableView.reloadData() + } + } + + @objc + private func addTodo() { + let viewModel = ToDoDetailViewModel(dataManager: listViewModel.dataManager) + let detailViewController = ToDoDetailViewController(viewModel: viewModel) + + present(detailViewController, animated: true) + } +} + +// MARK: - Configure UI +extension ListViewController { + private func configureUI() { + setUpView() + setUpNavigation() + addSubviews() + setUpTableViewConstraints() + } + + private func setUpView() { + view.backgroundColor = .systemBackground + } + + private func setUpNavigation() { + navigationItem.title = "Project Manager" + navigationController?.navigationBar + .titleTextAttributes = [ + NSAttributedString.Key.font : UIFont.preferredFont(for: .title3, weight: .bold) + ] + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "plus"), + style: .plain, + target: self, + action: #selector(addTodo) + ) + } + + private func addSubviews() { + view.addSubview(tableView) + } + + private func setUpTableViewConstraints() { + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + tableView.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + tableView.topAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.bottomAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } +} + +// MARK: - Table View Data Source +extension ListViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return ListHeader(viewModel: listViewModel) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return listViewModel.todoList?.count ?? .zero + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: ListCell.identifier) as? ListCell, + let todoList = listViewModel.todoList, + let todo = todoList[safe: indexPath.row] + else { + return UITableViewCell() + } + + cell.setUpContent(todo) + + return cell + } +} + +// MARK: - Table View Delegate +extension ListViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + + guard let todo = listViewModel.todoList?[safe: indexPath.row] else { + return + } + + let viewModel = ToDoDetailViewModel( + todo: todo, + dataManager: listViewModel.dataManager + ) + let detailViewController = ToDoDetailViewController(viewModel: viewModel) + + present(detailViewController, animated: true) + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, _ in + NotificationCenter.default + .post( + name: NSNotification.Name("SwipeDelete"), + object: nil, + userInfo: ["index" : indexPath.row] + ) + } + + return UISwipeActionsConfiguration(actions: [deleteAction]) + } +} diff --git a/ProjectManager/ProjectManager/View/ToDoDetailViewController.swift b/ProjectManager/ProjectManager/View/ToDoDetailViewController.swift new file mode 100644 index 0000000000..2e7332d7ca --- /dev/null +++ b/ProjectManager/ProjectManager/View/ToDoDetailViewController.swift @@ -0,0 +1,328 @@ +// +// ToDoDetailViewController.swift +// ProjectManager +// +// Created by Moon on 2023/09/30. +// + +import UIKit +import Combine + +final class ToDoDetailViewController: UIViewController { + // MARK: - Title Bar property + private let leftButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("Cancel", for: .normal) + button.setTitleColor(.systemBlue, for: .normal) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.font = .preferredFont(forTextStyle: .title3) + + return button + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = Category.todo.rawValue + label.font = .preferredFont(for: .title3, weight: .bold) + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private lazy var rightButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("Done", for: .normal) + button.setTitleColor(.systemBlue, for: .normal) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.font = .preferredFont(forTextStyle: .title3) + button.addAction(dismissAction(), for: .touchUpInside) + + return button + }() + + private let titleBarStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.backgroundColor = .systemGray6 + stackView.layoutMargins = .init(top: 10, left: 20, bottom: 10, right: 20) + stackView.isLayoutMarginsRelativeArrangement = true + + return stackView + }() + + // MARK: - Content property + private let titleTextField: InsetTextField = { + let textField = InsetTextField() + textField.placeholder = "Title" + textField.font = .preferredFont(forTextStyle: .title3) + textField.backgroundColor = .systemBackground + textField.adjustsFontForContentSizeCategory = true + + return textField + }() + + private let datePicker: UIDatePicker = { + let datePicker = UIDatePicker() + datePicker.preferredDatePickerStyle = .wheels + datePicker.datePickerMode = .date + + return datePicker + }() + + private let bodyTextView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .title3) + textView.adjustsFontForContentSizeCategory = true + + return textView + }() + + private let contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 20 + stackView.layoutMargins = .init(top: 10, left: 20, bottom: 30, right: 20) + stackView.isLayoutMarginsRelativeArrangement = true + + return stackView + }() + + private let titleShadowView: UIView = { + let view = UIView() + view.layer.shadowOpacity = 0.5 + view.layer.shadowOffset = CGSize(width: .zero, height: 3) + view.layer.shadowRadius = 3 + + return view + }() + + private let bodyShadowView: UIView = { + let view = UIView() + view.layer.shadowOpacity = 0.5 + view.layer.shadowOffset = CGSize(width: .zero, height: 3) + view.layer.shadowRadius = 3 + + return view + }() + + private let viewModel: ToDoDetailViewModel + private var cancellables = Set() + + init(viewModel: ToDoDetailViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + configureUI() + setUpDelegates() + setUpLeftButton(isNew: viewModel.isEnableEdit) + setUpBindings() + setUpDatePickerBinding() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + postToDoDetailViewWillDisappear() + } + + private func setUpDelegates() { + bodyTextView.delegate = self + } + + // 추가 버튼으로 새로 만들어졌을 때는 Cancel, 기존에 있던 todo일 때는 Edit 기능 수행 + private func setUpLeftButton(isNew: Bool) { + if isNew { + leftButton.setTitle("Cancel", for: .normal) + leftButton.addAction(dismissAction(), for: .touchUpInside) // TODO: 저장 없이 닫을 수 있도록 기능 추가 + } else { + leftButton.setTitle("Edit", for: .normal) + leftButton.addAction(enableEditContentAction(), for: .touchUpInside)// 유저와 상호작용이 가능하도록 하고 배경색 변경 + } + } + + // datePicker의 값이 변경될 때마다 뷰 모델에 데이터를 보낼 수 있도록 타겟 추가 + private func setUpDatePickerBinding() { + datePicker.addTarget( + self, + action: #selector(bindDatePicker), + for: .valueChanged + ) + } + + private func dismissAction() -> UIAction { + return UIAction { [weak self] _ in + self?.dismiss(animated: true) + } + } + + // 유저와 상호작용이 가능하도록 하고 배경색 변경할 수 있도록 뷰 모델에 알림 + private func enableEditContentAction() -> UIAction { + return UIAction { [weak self] _ in + self?.postEditButtonAction() + } + } + + private func postEditButtonAction() { + NotificationCenter.default + .post( + name: NSNotification.Name("editButtonAction"), + object: nil + ) + } + + private func postToDoDetailViewWillDisappear() { + NotificationCenter.default + .post( + name: NSNotification.Name("ToDoDetailViewWillDisappear"), + object: nil + ) + } +} + +// MARK: - Configure UI +extension ToDoDetailViewController { + private func configureUI() { + setUpView() + addSubviews() + setUpConstraints() + } + + private func setUpView() { + view.backgroundColor = .systemBackground + } + + private func addSubviews() { + [leftButton, titleLabel, rightButton].forEach { + titleBarStackView.addArrangedSubview($0) + } + + titleShadowView.addSubview(titleTextField) + bodyShadowView.addSubview(bodyTextView) + + [titleShadowView, datePicker, bodyShadowView].forEach { + contentStackView.addArrangedSubview($0) + } + + view.addSubview(titleBarStackView) + view.addSubview(contentStackView) + } + + private func setUpConstraints() { + setUpTitleBarStackViewConstraints() + setUpTitleTextFieldConstraints() + setUpBodyTextViewConstraints() + setUpContentStackViewConstraints() + } + + private func setUpTitleBarStackViewConstraints() { + titleBarStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + titleBarStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + titleBarStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + titleBarStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + ]) + } + + private func setUpTitleTextFieldConstraints() { + titleTextField.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + titleTextField.leadingAnchor.constraint(equalTo: titleShadowView.leadingAnchor), + titleTextField.trailingAnchor.constraint(equalTo: titleShadowView.trailingAnchor), + titleTextField.topAnchor.constraint(equalTo: titleShadowView.topAnchor), + titleTextField.bottomAnchor.constraint(equalTo: titleShadowView.bottomAnchor) + ]) + } + + private func setUpBodyTextViewConstraints() { + bodyTextView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + bodyTextView.leadingAnchor.constraint(equalTo: bodyShadowView.leadingAnchor), + bodyTextView.trailingAnchor.constraint(equalTo: bodyShadowView.trailingAnchor), + bodyTextView.topAnchor.constraint(equalTo: bodyShadowView.topAnchor), + bodyTextView.bottomAnchor.constraint(equalTo: bodyShadowView.bottomAnchor) + ]) + } + + private func setUpContentStackViewConstraints() { + contentStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + contentStackView.topAnchor.constraint(equalTo: titleBarStackView.bottomAnchor), + contentStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } +} + +// MARK: - UITextViewDelegate +extension ToDoDetailViewController: UITextViewDelegate { + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard let originalText = textView.text else { + return true + } + + let newLength = originalText.count + text.count - range.length + + return newLength <= 1000 + } +} + +// MARK: - Combine +extension ToDoDetailViewController { + private func setUpBindings() { + bindViewModelToView() + bindViewToViewModel() + } + + // 뷰에서 입력 받은 데이터를 뷰 모델로 보냄 + private func bindViewToViewModel() { + titleTextField.publisher(for: \.text) + .assign(to: \.title, on: viewModel) + .store(in: &cancellables) + bindDatePicker() + bodyTextView.textPublisher + .assign(to: \.body, on: viewModel) + .store(in: &cancellables) + } + + // 최초 실행 시 한 번만 값을 보내기 때문에 값이 변경 될 때마다 값을 보낼 수 있도록 + // datePicker.addTarget의 selector로 사용 + @objc + private func bindDatePicker() { + datePicker.publisher(for: \.date) + .assign(to: \.deadline, on: viewModel) + .store(in: &cancellables) + } + + // 뷰 모델의 데이터를 받아 뷰에 적용 + private func bindViewModelToView() { + viewModel.todoSubject + .sink(receiveValue: { [weak self] in + self?.titleLabel.text = $0.category + self?.titleTextField.text = $0.title + self?.datePicker.date = $0.deadline ?? Date() + self?.bodyTextView.text = $0.body + }) + .store(in: &cancellables) + + [titleTextField, datePicker, bodyTextView].forEach { + viewModel.$isEnableEdit + .assign(to: \.isUserInteractionEnabled, on: $0) + .store(in: &cancellables) + viewModel.$background + .assign(to: \.backgroundColor, on: $0) + .store(in: &cancellables) + } + } +} diff --git a/ProjectManager/ProjectManager/ViewController.swift b/ProjectManager/ProjectManager/ViewController.swift deleted file mode 100644 index e3e7d65db7..0000000000 --- a/ProjectManager/ProjectManager/ViewController.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ProjectManager - ViewController.swift -// Created by yagom. -// Copyright © yagom. All rights reserved. -// - -import UIKit - -class ViewController: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .yellow - } -} diff --git a/ProjectManager/ProjectManager/ViewModel/ListViewModel.swift b/ProjectManager/ProjectManager/ViewModel/ListViewModel.swift new file mode 100644 index 0000000000..0e39779249 --- /dev/null +++ b/ProjectManager/ProjectManager/ViewModel/ListViewModel.swift @@ -0,0 +1,95 @@ +// +// ListViewModel.swift +// ProjectManager +// +// Created by Moon on 2023/09/25. +// + +import Foundation + +final class ListViewModel { + let dataManager = DataManager() + + // Model이 변경되면 ListViewController의 테이블뷰를 업데이트 + // ListHeader에 표시되는 count도 업데이트 + var todoList: [ToDo]? { + didSet { + bindTodoList?() + count = String(todoList?.count ?? 0) + } + } + + // ListHeader의 contentAmountLabel와 바인딩할 변수 + var count: String { + didSet { + bindCount?(self) + } + } + + // ListHeader의 contentAmountLabel와 바인딩하기 위한 클로저 + private var bindCount: ((ListViewModel) -> Void)? + + // ListViewController의 테이블뷰를 업데이트하기 위한 클로저 + private var bindTodoList: (() -> Void)? + + init() { + count = String(todoList?.count ?? 0) + + setUpNotifications() + } + + private func setUpNotifications() { + // viewDidLoad 시점을 받아 모델을 로드할 수 있도록 함 + NotificationCenter.default + .addObserver( + self, + selector: #selector(loadTodoList), + name: NSNotification.Name("ListViewControllerViewDidLoad"), + object: nil + ) + // dataManager에서 저장이 이루어졌을 때 모델을 다시 로드 + NotificationCenter.default + .addObserver( + self, + selector: #selector(loadTodoList), + name: NSNotification.Name("CalledSaveContext"), + object: nil + ) + // 스와이프로 삭제 시 index를 받아 해당 ToDo를 삭제 + NotificationCenter.default + .addObserver( + self, + selector: #selector(deleteToDo), + name: NSNotification.Name("SwipeDelete"), + object: nil + ) + } + + @objc + private func loadTodoList() { + todoList = dataManager.fetchToDoList() + } + + @objc + private func deleteToDo(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let index = userInfo["index"] as? Int, + let todo = todoList?[safe: index] + else { + return + } + + dataManager.deleteItem(todo) + loadTodoList() + } + + func bindCount(_ handler: @escaping (ListViewModel) -> Void) { + handler(self) + bindCount = handler + } + + func bindTodoList(_ handler: @escaping () -> Void) { + handler() + bindTodoList = handler + } +} diff --git a/ProjectManager/ProjectManager/ViewModel/ToDoDetailViewModel.swift b/ProjectManager/ProjectManager/ViewModel/ToDoDetailViewModel.swift new file mode 100644 index 0000000000..a72680d157 --- /dev/null +++ b/ProjectManager/ProjectManager/ViewModel/ToDoDetailViewModel.swift @@ -0,0 +1,82 @@ +// +// ToDoDetailViewModel.swift +// ProjectManager +// +// Created by Moon on 2023/10/02. +// + +import UIKit +import Combine + +final class ToDoDetailViewModel { + private let todo: ToDo + private let dataManager: DataManager + + // 뷰에 데이터 보내는 용(뷰의 상태) + let todoSubject: CurrentValueSubject + + @Published var isEnableEdit: Bool + @Published var background: UIColor? + + // 뷰에서 데이터 받는 용(뷰의 인풋) + var title: String? + var deadline: Date = .init() + var body: String = .init() + + // ToDo를 새로 만드는 경우 + init(dataManager: DataManager) { + self.dataManager = dataManager + self.todo = dataManager.createToDo(category: .todo) + self.todoSubject = .init(todo) + self.isEnableEdit = true + self.background = .systemBackground + + setUpNotifications() + } + + // 셀을 선택해서 ToDo를 받는 경우 + init(todo: ToDo, dataManager: DataManager) { + self.todo = todo + self.dataManager = dataManager + self.todoSubject = .init(todo) + self.isEnableEdit = false + self.background = .systemGray6 + + setUpNotifications() + } + + private func setUpNotifications() { + // 유저와 상호작용이 가능하도록 하고 배경색 변경 + NotificationCenter.default + .addObserver(self, + selector: #selector(enableEditContent), + name: NSNotification.Name("editButtonAction"), + object: nil + ) + // viewWillDisappear 시점을 받아 dataManager에서 저장 수행 + NotificationCenter.default + .addObserver( + self, + selector: #selector(saveTodo), + name: NSNotification.Name("ToDoDetailViewWillDisappear"), + object: nil + ) + } + + // 유저와 상호작용이 가능하도록 하고 배경색 변경 + @objc + private func enableEditContent() { + self.isEnableEdit = true + self.background = .systemBackground + } + + // viewWillDisappear 시점을 받아 dataManager에서 저장 수행 + @objc + private func saveTodo() { + todo.title = self.title + todo.deadline = self.deadline + todo.body = self.body + + dataManager.saveContext() + } +}