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)
}