feat: 최근 들은 콘텐츠 로컬 DB 추가
This commit is contained in:
		@@ -16,6 +16,7 @@ import RefreshableScrollView
 | 
			
		||||
struct MyPageView: View {
 | 
			
		||||
    
 | 
			
		||||
    @StateObject var viewModel = MyPageViewModel()
 | 
			
		||||
    @StateObject var recentContentViewModel = RecentContentViewModel()
 | 
			
		||||
    
 | 
			
		||||
    @State private var payload = Payload()
 | 
			
		||||
    @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
 | 
			
		||||
@@ -68,6 +69,7 @@ struct MyPageView: View {
 | 
			
		||||
                                ) {
 | 
			
		||||
                                    viewModel.getMypage()
 | 
			
		||||
                                }
 | 
			
		||||
                                .padding(.horizontal, 24)
 | 
			
		||||
                            } else {
 | 
			
		||||
                                HStack {
 | 
			
		||||
                                    Text("LOGIN")
 | 
			
		||||
@@ -78,6 +80,7 @@ struct MyPageView: View {
 | 
			
		||||
                                .frame(maxWidth: .infinity)
 | 
			
		||||
                                .background(Color.gray22)
 | 
			
		||||
                                .cornerRadius(16)
 | 
			
		||||
                                .padding(.horizontal, 24)
 | 
			
		||||
                                .onTapGesture {
 | 
			
		||||
                                    AppState.shared
 | 
			
		||||
                                        .setAppStep(step: .login)
 | 
			
		||||
@@ -91,6 +94,7 @@ struct MyPageView: View {
 | 
			
		||||
                                token: token,
 | 
			
		||||
                                refresh: { viewModel.getMypage() }
 | 
			
		||||
                            )
 | 
			
		||||
                            .padding(.horizontal, 24)
 | 
			
		||||
                            
 | 
			
		||||
                            if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 | 
			
		||||
                                // Category Buttons
 | 
			
		||||
@@ -105,23 +109,24 @@ struct MyPageView: View {
 | 
			
		||||
                                        viewModel.getMypage()
 | 
			
		||||
                                    }
 | 
			
		||||
                                )
 | 
			
		||||
                                .padding(.horizontal, 24)
 | 
			
		||||
                            }
 | 
			
		||||
                            
 | 
			
		||||
                            if let url = URL(string: "https://blog.naver.com/sodalive_official"),
 | 
			
		||||
                               UIApplication.shared.canOpenURL(url) {
 | 
			
		||||
                                // Voice On Banner
 | 
			
		||||
                                Image("img_introduce_voiceon")
 | 
			
		||||
                                    .padding(.horizontal, 24)
 | 
			
		||||
                                    .onTapGesture {
 | 
			
		||||
                                        UIApplication.shared.open(url)
 | 
			
		||||
                                    }
 | 
			
		||||
                            }
 | 
			
		||||
                            
 | 
			
		||||
                            if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !viewModel.recentContentList.isEmpty {
 | 
			
		||||
                            if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !recentContentViewModel.recentContents.isEmpty {
 | 
			
		||||
                                // Recent 10 Section
 | 
			
		||||
                                RecentContentSection(recentContentList: viewModel.recentContentList)
 | 
			
		||||
                                RecentContentSection(recentContents: recentContentViewModel.recentContents)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(.horizontal, 24)
 | 
			
		||||
                        .padding(.vertical, 32)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
@@ -129,6 +134,7 @@ struct MyPageView: View {
 | 
			
		||||
                .onAppear {
 | 
			
		||||
                    if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 | 
			
		||||
                        viewModel.getMypage()
 | 
			
		||||
                        recentContentViewModel.fetchRecentContents()
 | 
			
		||||
                    }
 | 
			
		||||
                    viewModel.getLatestNotice()
 | 
			
		||||
                }
 | 
			
		||||
@@ -442,24 +448,25 @@ struct CategoryButtonItem: View {
 | 
			
		||||
// MARK: - Recent 10 Content Section
 | 
			
		||||
struct RecentContentSection: View {
 | 
			
		||||
    
 | 
			
		||||
    let recentContentList: [AudioContentMainItem]
 | 
			
		||||
    let recentContents: [RecentContent]
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 14) {
 | 
			
		||||
            HStack(spacing: 0) {
 | 
			
		||||
                Text("최근 들은 ")
 | 
			
		||||
                    .font(.system(size: 16, weight: .bold))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 16))
 | 
			
		||||
                    .foregroundColor(Color(hex: "B0BEC5"))
 | 
			
		||||
                
 | 
			
		||||
                Text("\(recentContentList.count)")
 | 
			
		||||
                    .font(.system(size: 16, weight: .bold))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
                Text("\(recentContents.count)")
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 16))
 | 
			
		||||
                    .foregroundColor(Color(hex: "FDC118"))
 | 
			
		||||
            }
 | 
			
		||||
            .padding(.horizontal, 24)
 | 
			
		||||
            
 | 
			
		||||
            ScrollView(.horizontal, showsIndicators: false) {
 | 
			
		||||
                HStack(spacing: 16) {
 | 
			
		||||
                    ForEach(0..<recentContentList.count, id: \.self) { index in
 | 
			
		||||
                        RecentItemView()
 | 
			
		||||
                    ForEach(0..<recentContents.count, id: \.self) { index in
 | 
			
		||||
                        RecentItemView(content: recentContents[index])
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal, 24)
 | 
			
		||||
@@ -469,67 +476,41 @@ struct RecentContentSection: View {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct RecentItemView: View {
 | 
			
		||||
    
 | 
			
		||||
    let content: RecentContent
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(spacing: 8) {
 | 
			
		||||
            // Thumbnail placeholder
 | 
			
		||||
            RoundedRectangle(cornerRadius: 16)
 | 
			
		||||
                .fill(Color.gray)
 | 
			
		||||
                .frame(width: 168, height: 168)
 | 
			
		||||
                .overlay(
 | 
			
		||||
                    VStack {
 | 
			
		||||
                        Spacer()
 | 
			
		||||
                        HStack {
 | 
			
		||||
                            Spacer()
 | 
			
		||||
                            Text("00:12:33")
 | 
			
		||||
                                .font(.system(size: 12))
 | 
			
		||||
                                .foregroundColor(.white)
 | 
			
		||||
                                .padding(.horizontal, 10)
 | 
			
		||||
                                .padding(.vertical, 3)
 | 
			
		||||
                                .background(Color.black.opacity(0.7))
 | 
			
		||||
                                .cornerRadius(39)
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(8)
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
                .overlay(
 | 
			
		||||
                    VStack {
 | 
			
		||||
                        HStack {
 | 
			
		||||
                            Text("신작")
 | 
			
		||||
                                .font(.system(size: 12))
 | 
			
		||||
                                .foregroundColor(.white)
 | 
			
		||||
                                .padding(.horizontal, 10)
 | 
			
		||||
                                .padding(.vertical, 3)
 | 
			
		||||
                                .background(LinearGradient(
 | 
			
		||||
                                    gradient: Gradient(colors: [Color(hex: "0001B1"), Color(hex: "3B5FF1")]),
 | 
			
		||||
                                    startPoint: .leading,
 | 
			
		||||
                                    endPoint: .trailing
 | 
			
		||||
                                ))
 | 
			
		||||
                                .cornerRadius(12)
 | 
			
		||||
                            
 | 
			
		||||
                            Spacer()
 | 
			
		||||
                            
 | 
			
		||||
                            Image(systemName: "shield.fill")
 | 
			
		||||
                                .foregroundColor(.red)
 | 
			
		||||
                                .frame(width: 20, height: 20)
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(8)
 | 
			
		||||
                        
 | 
			
		||||
                        Spacer()
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
            
 | 
			
		||||
            VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                Text("우디(Woody)")
 | 
			
		||||
                    .font(.system(size: 18))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
            VStack(alignment: .leading, spacing: 0) {
 | 
			
		||||
                KFImage(URL(string: content.coverImageUrl))
 | 
			
		||||
                    .cancelOnDisappear(true)
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .scaledToFill()
 | 
			
		||||
                    .frame(width: 160, height: 160, alignment: .top)
 | 
			
		||||
                    .cornerRadius(16)
 | 
			
		||||
                
 | 
			
		||||
                Text("우기라스")
 | 
			
		||||
                    .font(.system(size: 14))
 | 
			
		||||
                Text(content.title)
 | 
			
		||||
                    .font(.custom(Font.preRegular.rawValue, size: 18))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
                    .multilineTextAlignment(.leading)
 | 
			
		||||
                    .fixedSize(horizontal: false, vertical: true)
 | 
			
		||||
                    .lineLimit(1)
 | 
			
		||||
                    .padding(.horizontal, 6)
 | 
			
		||||
                    .padding(.top, 8)
 | 
			
		||||
                
 | 
			
		||||
                
 | 
			
		||||
                Text(content.creatorNickname)
 | 
			
		||||
                    .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
			
		||||
                    .foregroundColor(Color(hex: "78909C"))
 | 
			
		||||
                    .lineLimit(1)
 | 
			
		||||
                    .padding(.horizontal, 6)
 | 
			
		||||
                    .padding(.top, 4)
 | 
			
		||||
            }
 | 
			
		||||
            .frame(width: 160)
 | 
			
		||||
            .onTapGesture {
 | 
			
		||||
                AppState.shared.setAppStep(step: .contentDetail(contentId: Int(content.contentId)))
 | 
			
		||||
            }
 | 
			
		||||
            .padding(.leading, 6)
 | 
			
		||||
        }
 | 
			
		||||
        .frame(width: 168)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,6 @@ final class MyPageViewModel: ObservableObject {
 | 
			
		||||
    @Published var rewardCan: Int = 0
 | 
			
		||||
    @Published var point: Int = 0
 | 
			
		||||
    @Published var isAuth: Bool = false
 | 
			
		||||
    @Published var recentContentList: [AudioContentMainItem] = []
 | 
			
		||||
    
 | 
			
		||||
    @Published var errorMessage = ""
 | 
			
		||||
    @Published var isShowPopup = false
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
//
 | 
			
		||||
//  PersistenceController.swift
 | 
			
		||||
//  SodaLive
 | 
			
		||||
//
 | 
			
		||||
//  Created by klaus on 7/28/25.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import CoreData
 | 
			
		||||
 | 
			
		||||
struct PersistenceController {
 | 
			
		||||
    static let shared = PersistenceController()
 | 
			
		||||
 | 
			
		||||
    let container: NSPersistentContainer
 | 
			
		||||
 | 
			
		||||
    init() {
 | 
			
		||||
        container = NSPersistentContainer(name: "DataModel")
 | 
			
		||||
        container.loadPersistentStores { description, error in
 | 
			
		||||
            if let error = error {
 | 
			
		||||
                fatalError("Error loading Core Data stores: \(error)")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
//
 | 
			
		||||
//  RecentContent+CoreDataClass.swift
 | 
			
		||||
//  SodaLive
 | 
			
		||||
//
 | 
			
		||||
//  Created by klaus on 7/28/25.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
import CoreData
 | 
			
		||||
 | 
			
		||||
@objc(RecentContent)
 | 
			
		||||
public class RecentContent: NSManagedObject {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
extension RecentContent {
 | 
			
		||||
    @nonobjc public class func fetchRequest() -> NSFetchRequest<RecentContent> {
 | 
			
		||||
        return NSFetchRequest<RecentContent>(entityName: "RecentContent")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @NSManaged public var contentId: Int64
 | 
			
		||||
    @NSManaged public var coverImageUrl: String
 | 
			
		||||
    @NSManaged public var title: String
 | 
			
		||||
    @NSManaged public var creatorNickname: String
 | 
			
		||||
    @NSManaged public var listenedAt: Date
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								SodaLive/Sources/MyPage/Recent/DB/RecentContentService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								SodaLive/Sources/MyPage/Recent/DB/RecentContentService.swift
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
//
 | 
			
		||||
//  RecentContentService.swift
 | 
			
		||||
//  SodaLive
 | 
			
		||||
//
 | 
			
		||||
//  Created by klaus on 7/28/25.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import CoreData
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
class RecentContentService {
 | 
			
		||||
    private let viewContext: NSManagedObjectContext
 | 
			
		||||
    
 | 
			
		||||
    init(context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) {
 | 
			
		||||
        self.viewContext = context
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func getRecentContents(limit: Int = 10) -> [RecentContent] {
 | 
			
		||||
        let request = RecentContent.fetchRequest()
 | 
			
		||||
        request.sortDescriptors = [NSSortDescriptor(key: "listenedAt", ascending: false)]
 | 
			
		||||
        request.fetchLimit = limit
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            return try viewContext.fetch(request)
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("Error fetching recent contents: \(error)")
 | 
			
		||||
            return []
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func insertRecentContent(contentId: Int64, coverImageUrl: String, title: String, creatorNickname: String) {
 | 
			
		||||
        // Check if content already exists
 | 
			
		||||
        if let existingContent = findContent(byId: contentId) {
 | 
			
		||||
            // Update timestamp
 | 
			
		||||
            existingContent.listenedAt = Date()
 | 
			
		||||
            saveContext()
 | 
			
		||||
        } else {
 | 
			
		||||
            // Create new content
 | 
			
		||||
            let newContent = RecentContent(context: viewContext)
 | 
			
		||||
            newContent.contentId = contentId
 | 
			
		||||
            newContent.coverImageUrl = coverImageUrl
 | 
			
		||||
            newContent.title = title
 | 
			
		||||
            newContent.creatorNickname = creatorNickname
 | 
			
		||||
            newContent.listenedAt = Date()
 | 
			
		||||
            saveContext()
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Keep only most recent 10 items
 | 
			
		||||
        keepMostRecent(limit: 10)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func deleteByContentId(contentId: Int64) {
 | 
			
		||||
        if let content = findContent(byId: contentId) {
 | 
			
		||||
            viewContext.delete(content)
 | 
			
		||||
            saveContext()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func getCount() -> Int {
 | 
			
		||||
        let request = RecentContent.fetchRequest()
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            return try viewContext.count(for: request)
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("Error counting recent contents: \(error)")
 | 
			
		||||
            return 0
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func truncate() {
 | 
			
		||||
        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = RecentContent.fetchRequest()
 | 
			
		||||
        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            try viewContext.execute(deleteRequest)
 | 
			
		||||
            saveContext()
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("Error truncating recent contents: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func keepMostRecent(limit: Int) {
 | 
			
		||||
        let count = getCount()
 | 
			
		||||
        if count <= limit { return }
 | 
			
		||||
        
 | 
			
		||||
        let request = RecentContent.fetchRequest()
 | 
			
		||||
        request.sortDescriptors = [NSSortDescriptor(key: "listenedAt", ascending: false)]
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            let allContents = try viewContext.fetch(request)
 | 
			
		||||
            for i in limit..<allContents.count {
 | 
			
		||||
                viewContext.delete(allContents[i])
 | 
			
		||||
            }
 | 
			
		||||
            saveContext()
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("Error keeping most recent contents: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func findContent(byId contentId: Int64) -> RecentContent? {
 | 
			
		||||
        let request = RecentContent.fetchRequest()
 | 
			
		||||
        request.predicate = NSPredicate(format: "contentId == %lld", contentId)
 | 
			
		||||
        request.fetchLimit = 1
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            let results = try viewContext.fetch(request)
 | 
			
		||||
            return results.first
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("Error finding content by ID: \(error)")
 | 
			
		||||
            return nil
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func saveContext() {
 | 
			
		||||
        if viewContext.hasChanges {
 | 
			
		||||
            do {
 | 
			
		||||
                try viewContext.save()
 | 
			
		||||
            } catch {
 | 
			
		||||
                print("Error saving context: \(error)")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								SodaLive/Sources/MyPage/Recent/RecentContentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								SodaLive/Sources/MyPage/Recent/RecentContentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
//
 | 
			
		||||
//  RecentContentViewModel.swift
 | 
			
		||||
//  SodaLive
 | 
			
		||||
//
 | 
			
		||||
//  Created by klaus on 7/28/25.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
class RecentContentViewModel: ObservableObject {
 | 
			
		||||
    @Published var recentContents: [RecentContent] = []
 | 
			
		||||
    @Published var contentCount: Int = 0
 | 
			
		||||
    
 | 
			
		||||
    private let service: RecentContentService
 | 
			
		||||
    private var cancellables = Set<AnyCancellable>()
 | 
			
		||||
    
 | 
			
		||||
    init(service: RecentContentService = RecentContentService()) {
 | 
			
		||||
        self.service = service
 | 
			
		||||
        fetchRecentContents()
 | 
			
		||||
        fetchContentCount()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchRecentContents(limit: Int = 10) {
 | 
			
		||||
        self.recentContents = service.getRecentContents(limit: limit)
 | 
			
		||||
        self.contentCount = recentContents.count
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchContentCount() {
 | 
			
		||||
        self.contentCount = service.getCount()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func insertRecentContent(contentId: Int64, coverImageUrl: String, title: String, creatorNickname: String) {
 | 
			
		||||
        service.insertRecentContent(
 | 
			
		||||
            contentId: contentId,
 | 
			
		||||
            coverImageUrl: coverImageUrl,
 | 
			
		||||
            title: title,
 | 
			
		||||
            creatorNickname: creatorNickname
 | 
			
		||||
        )
 | 
			
		||||
        fetchRecentContents()
 | 
			
		||||
        fetchContentCount()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func deleteByContentId(contentId: Int64) {
 | 
			
		||||
        service.deleteByContentId(contentId: contentId)
 | 
			
		||||
        fetchRecentContents()
 | 
			
		||||
        fetchContentCount()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func truncate() {
 | 
			
		||||
        service.truncate()
 | 
			
		||||
        fetchRecentContents()
 | 
			
		||||
        fetchContentCount()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user