feat: 최근 들은 콘텐츠 로컬 DB 추가
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
KFImage(URL(string: content.coverImageUrl))
|
||||
.cancelOnDisappear(true)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 160, height: 160, alignment: .top)
|
||||
.cornerRadius(16)
|
||||
|
||||
Text(content.title)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 18))
|
||||
.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)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 8)
|
||||
|
||||
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)
|
||||
|
||||
Text("우기라스")
|
||||
.font(.system(size: 14))
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user