feat: 최근 들은 콘텐츠 로컬 DB 추가

This commit is contained in:
Yu Sung
2025-07-28 22:34:34 +09:00
parent a73cafa08c
commit 70af4cb3dd
10 changed files with 306 additions and 67 deletions

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="RecentContent" representedClassName=".RecentContent" syncable="YES">
<attribute name="contentId" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="coverImageUrl" optional="YES" attributeType="String"/>
<attribute name="creatorNickname" optional="YES" attributeType="String"/>
<attribute name="listenedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="title" optional="YES" attributeType="String"/>
</entity>
</model>

View File

@@ -16,6 +16,7 @@ struct ContentDetailPlayView: View {
@Binding var isShowPreviewAlert: Bool @Binding var isShowPreviewAlert: Bool
@StateObject var contentPlayManager = ContentPlayManager.shared @StateObject var contentPlayManager = ContentPlayManager.shared
@StateObject var recentContentViewModel = RecentContentViewModel()
@State private var isRepeat = UserDefaults.bool(forKey: .isContentPlayLoop) @State private var isRepeat = UserDefaults.bool(forKey: .isContentPlayLoop)
@State private var isEditing = false @State private var isEditing = false
@@ -101,6 +102,13 @@ struct ContentDetailPlayView: View {
isPreview: !audioContent.existOrdered && audioContent.price > 0 isPreview: !audioContent.existOrdered && audioContent.price > 0
) )
isShowPreviewAlert = true isShowPreviewAlert = true
recentContentViewModel.insertRecentContent(
contentId: Int64(audioContent.contentId),
coverImageUrl: audioContent.coverImageUrl,
title: audioContent.title,
creatorNickname: audioContent.creator.nickname
)
} }
} }

View File

@@ -11,6 +11,7 @@ import MediaPlayer
import Combine import Combine
import Kingfisher import Kingfisher
import SwiftUICore
final class ContentPlayerPlayManager: NSObject, ObservableObject { final class ContentPlayerPlayManager: NSObject, ObservableObject {
enum LoopState { enum LoopState {
@@ -23,6 +24,8 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject {
private let repository = ContentGenerateUrlRepository() private let repository = ContentGenerateUrlRepository()
@StateObject var recentContentViewModel = RecentContentViewModel()
@Published var id = 0 @Published var id = 0
@Published var title = "" @Published var title = ""
@Published var nickname = "" @Published var nickname = ""
@@ -132,6 +135,13 @@ final class ContentPlayerPlayManager: NSObject, ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
self.fetchAlbumArtAndUpdateNowPlayingInfo() self.fetchAlbumArtAndUpdateNowPlayingInfo()
recentContentViewModel.insertRecentContent(
contentId: Int64(id),
coverImageUrl: coverImageUrl,
title: title,
creatorNickname: nickname
)
} }
private func checkPlaybackStart(bufferedTime: Double, isLikelyToKeepUp: Bool) { private func checkPlaybackStart(bufferedTime: Double, isLikelyToKeepUp: Bool) {

View File

@@ -16,6 +16,7 @@ import RefreshableScrollView
struct MyPageView: View { struct MyPageView: View {
@StateObject var viewModel = MyPageViewModel() @StateObject var viewModel = MyPageViewModel()
@StateObject var recentContentViewModel = RecentContentViewModel()
@State private var payload = Payload() @State private var payload = Payload()
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@@ -68,6 +69,7 @@ struct MyPageView: View {
) { ) {
viewModel.getMypage() viewModel.getMypage()
} }
.padding(.horizontal, 24)
} else { } else {
HStack { HStack {
Text("LOGIN") Text("LOGIN")
@@ -78,6 +80,7 @@ struct MyPageView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray22) .background(Color.gray22)
.cornerRadius(16) .cornerRadius(16)
.padding(.horizontal, 24)
.onTapGesture { .onTapGesture {
AppState.shared AppState.shared
.setAppStep(step: .login) .setAppStep(step: .login)
@@ -91,6 +94,7 @@ struct MyPageView: View {
token: token, token: token,
refresh: { viewModel.getMypage() } refresh: { viewModel.getMypage() }
) )
.padding(.horizontal, 24)
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// Category Buttons // Category Buttons
@@ -105,23 +109,24 @@ struct MyPageView: View {
viewModel.getMypage() viewModel.getMypage()
} }
) )
.padding(.horizontal, 24)
} }
if let url = URL(string: "https://blog.naver.com/sodalive_official"), if let url = URL(string: "https://blog.naver.com/sodalive_official"),
UIApplication.shared.canOpenURL(url) { UIApplication.shared.canOpenURL(url) {
// Voice On Banner // Voice On Banner
Image("img_introduce_voiceon") Image("img_introduce_voiceon")
.padding(.horizontal, 24)
.onTapGesture { .onTapGesture {
UIApplication.shared.open(url) 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 // Recent 10 Section
RecentContentSection(recentContentList: viewModel.recentContentList) RecentContentSection(recentContents: recentContentViewModel.recentContents)
} }
} }
.padding(.horizontal, 24)
.padding(.vertical, 32) .padding(.vertical, 32)
} }
} }
@@ -129,6 +134,7 @@ struct MyPageView: View {
.onAppear { .onAppear {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
viewModel.getMypage() viewModel.getMypage()
recentContentViewModel.fetchRecentContents()
} }
viewModel.getLatestNotice() viewModel.getLatestNotice()
} }
@@ -442,24 +448,25 @@ struct CategoryButtonItem: View {
// MARK: - Recent 10 Content Section // MARK: - Recent 10 Content Section
struct RecentContentSection: View { struct RecentContentSection: View {
let recentContentList: [AudioContentMainItem] let recentContents: [RecentContent]
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("최근 들은 ") Text("최근 들은 ")
.font(.system(size: 16, weight: .bold)) .font(.custom(Font.preBold.rawValue, size: 16))
.foregroundColor(.white) .foregroundColor(Color(hex: "B0BEC5"))
Text("\(recentContentList.count)") Text("\(recentContents.count)")
.font(.system(size: 16, weight: .bold)) .font(.custom(Font.preBold.rawValue, size: 16))
.foregroundColor(.white) .foregroundColor(Color(hex: "FDC118"))
} }
.padding(.horizontal, 24)
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) { HStack(spacing: 16) {
ForEach(0..<recentContentList.count, id: \.self) { index in ForEach(0..<recentContents.count, id: \.self) { index in
RecentItemView() RecentItemView(content: recentContents[index])
} }
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
@@ -469,67 +476,41 @@ struct RecentContentSection: View {
} }
struct RecentItemView: View { struct RecentItemView: View {
let content: RecentContent
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
// Thumbnail placeholder VStack(alignment: .leading, spacing: 0) {
RoundedRectangle(cornerRadius: 16) KFImage(URL(string: content.coverImageUrl))
.fill(Color.gray) .cancelOnDisappear(true)
.frame(width: 168, height: 168) .resizable()
.overlay( .scaledToFill()
VStack { .frame(width: 160, height: 160, alignment: .top)
Spacer() .cornerRadius(16)
HStack {
Spacer() Text(content.title)
Text("00:12:33") .font(.custom(Font.preRegular.rawValue, size: 18))
.font(.system(size: 12))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 10) .multilineTextAlignment(.leading)
.padding(.vertical, 3) .fixedSize(horizontal: false, vertical: true)
.background(Color.black.opacity(0.7)) .lineLimit(1)
.cornerRadius(39) .padding(.horizontal, 6)
} .padding(.top, 8)
.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") Text(content.creatorNickname)
.foregroundColor(.red) .font(.custom(Font.preRegular.rawValue, size: 14))
.frame(width: 20, height: 20)
}
.padding(8)
Spacer()
}
)
VStack(alignment: .leading, spacing: 4) {
Text("우디(Woody)")
.font(.system(size: 18))
.foregroundColor(.white)
Text("우기라스")
.font(.system(size: 14))
.foregroundColor(Color(hex: "78909C")) .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)
} }
} }

View File

@@ -23,7 +23,6 @@ final class MyPageViewModel: ObservableObject {
@Published var rewardCan: Int = 0 @Published var rewardCan: Int = 0
@Published var point: Int = 0 @Published var point: Int = 0
@Published var isAuth: Bool = false @Published var isAuth: Bool = false
@Published var recentContentList: [AudioContentMainItem] = []
@Published var errorMessage = "" @Published var errorMessage = ""
@Published var isShowPopup = false @Published var isShowPopup = false

View File

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

View File

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

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

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

View File

@@ -12,6 +12,7 @@ struct SettingsView: View {
@State private var isShowLogoutAllDeviceDialog = false @State private var isShowLogoutAllDeviceDialog = false
@StateObject var viewModel = SettingsViewModel() @StateObject var viewModel = SettingsViewModel()
@StateObject var recentContentViewModel = RecentContentViewModel()
var body: some View { var body: some View {
let cardWidth = screenSize().width - 26.7 let cardWidth = screenSize().width - 26.7
@@ -200,7 +201,10 @@ struct SettingsView: View {
ContentPlayerPlayManager.shared.resetPlayer() ContentPlayerPlayManager.shared.resetPlayer()
viewModel.logout { viewModel.logout {
self.isShowLogoutDialog = false self.isShowLogoutDialog = false
UserDefaults.reset() UserDefaults.reset()
recentContentViewModel.truncate()
AppState.shared.isChangeAdultContentVisible = true AppState.shared.isChangeAdultContentVisible = true
AppState.shared.setAppStep(step: .splash) AppState.shared.setAppStep(step: .splash)
} }