diff --git a/SodaLive/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents b/SodaLive/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents new file mode 100644 index 0000000..0494504 --- /dev/null +++ b/SodaLive/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift index 85b8ebf..45102cf 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift @@ -16,6 +16,7 @@ struct ContentDetailPlayView: View { @Binding var isShowPreviewAlert: Bool @StateObject var contentPlayManager = ContentPlayManager.shared + @StateObject var recentContentViewModel = RecentContentViewModel() @State private var isRepeat = UserDefaults.bool(forKey: .isContentPlayLoop) @State private var isEditing = false @@ -101,6 +102,13 @@ struct ContentDetailPlayView: View { isPreview: !audioContent.existOrdered && audioContent.price > 0 ) isShowPreviewAlert = true + + recentContentViewModel.insertRecentContent( + contentId: Int64(audioContent.contentId), + coverImageUrl: audioContent.coverImageUrl, + title: audioContent.title, + creatorNickname: audioContent.creator.nickname + ) } } diff --git a/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift index e0280f2..4f80e76 100644 --- a/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift +++ b/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift @@ -11,6 +11,7 @@ import MediaPlayer import Combine import Kingfisher +import SwiftUICore final class ContentPlayerPlayManager: NSObject, ObservableObject { enum LoopState { @@ -23,6 +24,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { private let repository = ContentGenerateUrlRepository() + @StateObject var recentContentViewModel = RecentContentViewModel() + @Published var id = 0 @Published var title = "" @Published var nickname = "" @@ -132,6 +135,13 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject { .store(in: &cancellables) self.fetchAlbumArtAndUpdateNowPlayingInfo() + + recentContentViewModel.insertRecentContent( + contentId: Int64(id), + coverImageUrl: coverImageUrl, + title: title, + creatorNickname: nickname + ) } private func checkPlaybackStart(bufferedTime: Double, isLikelyToKeepUp: Bool) { diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index cf6ee74..9301051 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -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.. NSFetchRequest { + return NSFetchRequest(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 +} diff --git a/SodaLive/Sources/MyPage/Recent/DB/RecentContentService.swift b/SodaLive/Sources/MyPage/Recent/DB/RecentContentService.swift new file mode 100644 index 0000000..689cea5 --- /dev/null +++ b/SodaLive/Sources/MyPage/Recent/DB/RecentContentService.swift @@ -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 = 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.. 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)") + } + } + } +} diff --git a/SodaLive/Sources/MyPage/Recent/RecentContentViewModel.swift b/SodaLive/Sources/MyPage/Recent/RecentContentViewModel.swift new file mode 100644 index 0000000..63d88b6 --- /dev/null +++ b/SodaLive/Sources/MyPage/Recent/RecentContentViewModel.swift @@ -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() + + 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() + } +} diff --git a/SodaLive/Sources/Settings/SettingsView.swift b/SodaLive/Sources/Settings/SettingsView.swift index 10003d1..4f15f78 100644 --- a/SodaLive/Sources/Settings/SettingsView.swift +++ b/SodaLive/Sources/Settings/SettingsView.swift @@ -12,6 +12,7 @@ struct SettingsView: View { @State private var isShowLogoutAllDeviceDialog = false @StateObject var viewModel = SettingsViewModel() + @StateObject var recentContentViewModel = RecentContentViewModel() var body: some View { let cardWidth = screenSize().width - 26.7 @@ -200,7 +201,10 @@ struct SettingsView: View { ContentPlayerPlayManager.shared.resetPlayer() viewModel.logout { self.isShowLogoutDialog = false + UserDefaults.reset() + recentContentViewModel.truncate() + AppState.shared.isChangeAdultContentVisible = true AppState.shared.setAppStep(step: .splash) }