Compare commits
10 Commits
8517c5afc3
...
eecee74af2
| Author | SHA1 | Date | |
|---|---|---|---|
| eecee74af2 | |||
| 4c0a63a1f4 | |||
| 73c4f260d3 | |||
| 1d12ff5625 | |||
| 3463e9b7b2 | |||
| d0209d298b | |||
| 6b3273ad2f | |||
| 9f6f3eb6b2 | |||
| 260bf6017f | |||
| f4939c0099 |
14
TODO.md
Normal file
14
TODO.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
## 增加接口
|
||||||
|
|
||||||
|
### 1. 获取用户的关注数
|
||||||
|
GET: /api/follow_num?user_id=$user_id
|
||||||
|
|
||||||
|
### 2. 保存用户的设备token
|
||||||
|
url: /api/device_token
|
||||||
|
method: post
|
||||||
|
params:
|
||||||
|
{
|
||||||
|
user_id: string,
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
@ -27,6 +27,7 @@
|
|||||||
C85F58C02D64D10F00D761E9 /* dimensionhub.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dimensionhub.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
C85F58C02D64D10F00D761E9 /* dimensionhub.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dimensionhub.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C85F58D22D64D11000D761E9 /* dimensionhubTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = dimensionhubTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C85F58D22D64D11000D761E9 /* dimensionhubTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = dimensionhubTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C85F58DC2D64D11000D761E9 /* dimensionhubUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = dimensionhubUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C85F58DC2D64D11000D761E9 /* dimensionhubUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = dimensionhubUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C8E6E2052DAF4A130014CDB3 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -88,6 +89,7 @@
|
|||||||
C85F58B72D64D10F00D761E9 = {
|
C85F58B72D64D10F00D761E9 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
C8E6E2052DAF4A130014CDB3 /* TODO.md */,
|
||||||
C85F58C22D64D10F00D761E9 /* dimensionhub */,
|
C85F58C22D64D10F00D761E9 /* dimensionhub */,
|
||||||
C85F58D52D64D11000D761E9 /* dimensionhubTests */,
|
C85F58D52D64D11000D761E9 /* dimensionhubTests */,
|
||||||
C85F58DF2D64D11000D761E9 /* dimensionhubUITests */,
|
C85F58DF2D64D11000D761E9 /* dimensionhubUITests */,
|
||||||
|
|||||||
@ -10,5 +10,23 @@
|
|||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
<dict>
|
||||||
|
<key>C85F58BF2D64D10F00D761E9</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>C85F58D12D64D11000D761E9</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>C85F58DB2D64D11000D761E9</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -61,6 +61,13 @@ struct API {
|
|||||||
return await doRequest(request: request, as: T.self)
|
return await doRequest(request: request, as: T.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户的关注数
|
||||||
|
static func getFlowNum<T: Codable>(userId: String, as: T.Type) async -> APIResponse<T> {
|
||||||
|
let request = URLRequest(url: URL(string: baseUrl + "/api/follow_num?user_id=\(userId)")!)
|
||||||
|
|
||||||
|
return await doRequest(request: request, as: T.self)
|
||||||
|
}
|
||||||
|
|
||||||
// 前后刷新获取数据
|
// 前后刷新获取数据
|
||||||
static func loadMoreUpdateDramas<T: Codable>(userId: String, mode: LoadMode, id: Int, as: T.Type) async -> APIResponse<T> {
|
static func loadMoreUpdateDramas<T: Codable>(userId: String, mode: LoadMode, id: Int, as: T.Type) async -> APIResponse<T> {
|
||||||
let request = URLRequest(url: URL(string: baseUrl + "/api/load_more_dramas?user_id=\(userId)&mode=\(mode.rawValue)&id=\(id)")!)
|
let request = URLRequest(url: URL(string: baseUrl + "/api/load_more_dramas?user_id=\(userId)&mode=\(mode.rawValue)&id=\(id)")!)
|
||||||
|
|||||||
@ -77,13 +77,13 @@ final class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 保存文件到缓存
|
// 保存文件到缓存
|
||||||
private func saveCacheFile(filename: String, data: Data) throws {
|
func saveCacheFile(filename: String, data: Data) throws {
|
||||||
let fileURL = cacheDir.appendingPathComponent(filename)
|
let fileURL = cacheDir.appendingPathComponent(filename)
|
||||||
try data.write(to: fileURL)
|
try data.write(to: fileURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载图片
|
// 下载图片
|
||||||
private func downloadImage(from urlString: String) async throws -> Data? {
|
func downloadImage(from urlString: String) async throws -> Data? {
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -106,6 +106,10 @@ final class CacheManager {
|
|||||||
return sha256(str: urlString) + "." + url.pathExtension.lowercased()
|
return sha256(str: urlString) + "." + url.pathExtension.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCacheFileName(url: URL) -> String {
|
||||||
|
return sha256(str: url.absoluteString) + "." + url.pathExtension.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
private func sha256(str: String) -> String {
|
private func sha256(str: String) -> String {
|
||||||
let data = Data(str.utf8)
|
let data = Data(str.utf8)
|
||||||
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||||
|
|||||||
@ -28,11 +28,9 @@ struct FlexImage: View {
|
|||||||
|
|
||||||
let cacheManager = CacheManager.shared
|
let cacheManager = CacheManager.shared
|
||||||
if let data = cacheManager.readFileContents(urlString: urlString) {
|
if let data = cacheManager.readFileContents(urlString: urlString) {
|
||||||
|
//print("url: \(urlString), hit cache")
|
||||||
self.mode = .local(data)
|
self.mode = .local(data)
|
||||||
} else {
|
} else {
|
||||||
Task.detached {
|
|
||||||
await cacheManager.preloadImage(url: urlString)
|
|
||||||
}
|
|
||||||
self.mode = .remote(urlString)
|
self.mode = .remote(urlString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,7 +38,7 @@ struct FlexImage: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
switch self.mode {
|
switch self.mode {
|
||||||
case .remote(let url):
|
case .remote(let url):
|
||||||
AsyncImage(url: URL(string: url)) { phase in
|
FlexAsyncImage(url: URL(string: url)) { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .empty:
|
case .empty:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -50,7 +48,7 @@ struct FlexImage: View {
|
|||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
.clipped()
|
.clipped()
|
||||||
default:
|
case .failure:
|
||||||
Image(placeholder)
|
Image(placeholder)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
@ -75,6 +73,61 @@ struct FlexImage: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
enum FlexImagePhase {
|
||||||
//FlexImage()
|
case empty
|
||||||
|
case success(Image)
|
||||||
|
case failure
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FlexAsyncImage<Content: View>: View {
|
||||||
|
let url: URL?
|
||||||
|
@State private var phase: FlexImagePhase = .empty
|
||||||
|
@ViewBuilder let content: (FlexImagePhase) -> Content
|
||||||
|
|
||||||
|
init(url: URL?, @ViewBuilder content: @escaping (FlexImagePhase) -> Content) {
|
||||||
|
self.url = url
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content(phase)
|
||||||
|
.task(id: url) {
|
||||||
|
guard let url else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cacheManager = CacheManager.shared
|
||||||
|
|
||||||
|
phase = .empty
|
||||||
|
do {
|
||||||
|
if let data = cacheManager.readFileContents(urlString: url.absoluteString),
|
||||||
|
let cachedImage = await decodeImageData(data) {
|
||||||
|
phase = .success(Image(uiImage: cachedImage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
if let image = await decodeImageData(data) {
|
||||||
|
let cacheFilename = cacheManager.getCacheFileName(url: url)
|
||||||
|
try? cacheManager.saveCacheFile(filename: cacheFilename, data: data)
|
||||||
|
|
||||||
|
phase = .success(Image(uiImage: image))
|
||||||
|
} else {
|
||||||
|
phase = .empty
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
phase = .failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeImageData(_ data: Data) async -> UIImage? {
|
||||||
|
await Task.detached {
|
||||||
|
UIImage(data: data)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
FlexImage(urlString: "https://lain.bgm.tv/pic/cover/l/60/fe/358801_WuBx6.jpg", width: 80, height: 80, placeholder: "ph_img_big")
|
||||||
}
|
}
|
||||||
|
|||||||
40
dimensionhub/Views/FollowList/FollowDramaModel.swift
Normal file
40
dimensionhub/Views/FollowList/FollowDramaModel.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// FollowListModel.swift
|
||||||
|
// dimensionhub
|
||||||
|
//
|
||||||
|
// Created by 安礼成 on 2025/4/8.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class FollowDramaModel {
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
var drama: FollowListModel.DramaItem
|
||||||
|
|
||||||
|
// 选中的频道信息
|
||||||
|
var checkedChannelId: Int = 0
|
||||||
|
var episodes: [FollowListModel.DramaItem.Channel.Episode]
|
||||||
|
|
||||||
|
init(drama: FollowListModel.DramaItem) {
|
||||||
|
self.drama = drama
|
||||||
|
self.checkedChannelId = 0
|
||||||
|
if let channel = drama.channels.first {
|
||||||
|
self.episodes = channel.episodes
|
||||||
|
} else {
|
||||||
|
self.episodes = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeChannel(channelId: Int) {
|
||||||
|
self.checkedChannelId = channelId
|
||||||
|
if drama.channels.indices.contains(channelId) {
|
||||||
|
self.episodes = drama.channels[channelId].episodes
|
||||||
|
} else {
|
||||||
|
self.episodes = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -12,21 +12,27 @@ import Observation
|
|||||||
final class FollowListModel {
|
final class FollowListModel {
|
||||||
|
|
||||||
struct DramaItem: Codable {
|
struct DramaItem: Codable {
|
||||||
struct Episode: Codable, Identifiable {
|
|
||||||
let id = UUID().uuidString
|
struct Channel: Codable {
|
||||||
let name: String
|
struct Episode: Codable, Identifiable {
|
||||||
let thumb: String
|
let id = UUID().uuidString
|
||||||
let num_name: String
|
let name: String
|
||||||
let play: String
|
let thumb: String
|
||||||
|
let num_name: String
|
||||||
enum CodingKeys: String, CodingKey {
|
let play: String
|
||||||
case name, thumb, num_name, play
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, thumb, num_name, play
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name: String
|
||||||
|
let episodes: [Episode]
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: Int
|
let id: Int
|
||||||
let title: String
|
let title: String
|
||||||
let episodes: [Episode]
|
let channels: [Channel]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FavorResponse: Codable {
|
struct FavorResponse: Codable {
|
||||||
@ -55,7 +61,10 @@ final class FollowListModel {
|
|||||||
private func preloadImages(dramas: [DramaItem]) {
|
private func preloadImages(dramas: [DramaItem]) {
|
||||||
let cacheManager = CacheManager.shared
|
let cacheManager = CacheManager.shared
|
||||||
dramas.forEach { dramaItem in
|
dramas.forEach { dramaItem in
|
||||||
let urls = dramaItem.episodes.map { $0.thumb }
|
let urls = dramaItem.channels.flatMap { channel in
|
||||||
|
channel.episodes.map { $0.thumb }
|
||||||
|
}
|
||||||
|
|
||||||
if urls.count > 0 {
|
if urls.count > 0 {
|
||||||
Task.detached(priority: .medium) {
|
Task.detached(priority: .medium) {
|
||||||
try? await cacheManager.preloadImages(urls: urls)
|
try? await cacheManager.preloadImages(urls: urls)
|
||||||
|
|||||||
@ -25,7 +25,7 @@ struct FollowListView: View {
|
|||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
LazyVStack(alignment: .center) {
|
LazyVStack(alignment: .center) {
|
||||||
ForEach(followModel.dramas, id: \.id) { drama in
|
ForEach(followModel.dramas, id: \.id) { drama in
|
||||||
DramaCellView(dramaItem: drama)
|
DramaCellView(dramaModel: FollowDramaModel(drama: drama))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,21 +54,34 @@ extension FollowListView {
|
|||||||
|
|
||||||
// 显示剧集的列表信息
|
// 显示剧集的列表信息
|
||||||
struct DramaCellView: View {
|
struct DramaCellView: View {
|
||||||
let dramaItem: FollowListModel.DramaItem
|
let dramaModel: FollowDramaModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|
||||||
NavigationLink(destination: DetailView(id: dramaItem.id)) {
|
NavigationLink(destination: DetailView(id: dramaModel.drama.id)) {
|
||||||
Text(dramaItem.title)
|
Text(dramaModel.drama.title)
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
.foregroundColor(Color(hex: "#333333"))
|
.foregroundColor(Color(hex: "#333333"))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渠道列表
|
||||||
|
HStack(alignment: .center, spacing: 15) {
|
||||||
|
ForEach(Array(dramaModel.drama.channels.enumerated()), id: \.offset) { idx, channel in
|
||||||
|
Text(channel.name)
|
||||||
|
.font(.system(size: 13, weight: idx == dramaModel.checkedChannelId ? .medium : .regular))
|
||||||
|
.foregroundColor(idx == dramaModel.checkedChannelId ? Color(hex: "#202020") : Color(hex: "#666666"))
|
||||||
|
.onTapGesture {
|
||||||
|
dramaModel.changeChannel(channelId: idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
LazyHStack(alignment: .center, spacing: 5) {
|
LazyHStack(alignment: .center, spacing: 5) {
|
||||||
ForEach(dramaItem.episodes) { item in
|
ForEach(dramaModel.episodes) { item in
|
||||||
DramaCellEpisodeView(item: item)
|
DramaCellEpisodeView(item: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +91,7 @@ extension FollowListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct DramaCellEpisodeView: View {
|
struct DramaCellEpisodeView: View {
|
||||||
let item: FollowListModel.DramaItem.Episode
|
let item: FollowListModel.DramaItem.Channel.Episode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
|
|||||||
@ -34,13 +34,13 @@ struct IndexMainView: View {
|
|||||||
|
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Text("亚次元")
|
Text("亚次元")
|
||||||
.font(.system(size: 18, weight: .bold))
|
.font(.system(size: 20, weight: .bold))
|
||||||
.padding([.top, .bottom], 5)
|
.padding([.top, .bottom], 5)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("♡ \(indexModel.follow_num)")
|
Text("♡ \(indexModel.follow_num)")
|
||||||
.font(.system(size: 17))
|
.font(.system(size: 18))
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.black)
|
||||||
.padding([.top, .bottom], 5)
|
.padding([.top, .bottom], 5)
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
@ -52,6 +52,9 @@ struct IndexMainView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
|
.task {
|
||||||
|
await indexModel.reloadFollowNum(userId: userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding([.leading, .trailing], 15)
|
.padding([.leading, .trailing], 15)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
@ -65,15 +68,15 @@ struct IndexMainView: View {
|
|||||||
LazyVStack(alignment: .center, spacing: 10) {
|
LazyVStack(alignment: .center, spacing: 10) {
|
||||||
ForEach(Array(indexModel.dramaGroupElements.enumerated()), id: \.offset) { idx, item in
|
ForEach(Array(indexModel.dramaGroupElements.enumerated()), id: \.offset) { idx, item in
|
||||||
switch item {
|
switch item {
|
||||||
case .label(groupId: let groupId, groupName: let groupName):
|
case .label(let groupId, let groupName):
|
||||||
DramaGroupLabelView(group_name: groupName) {
|
DramaGroupLabelView(group_name: groupName) {
|
||||||
selectGroupId = groupId
|
selectGroupId = groupId
|
||||||
indexModel.selectedDate = groupId
|
indexModel.selectedDate = groupId
|
||||||
showDateNavPopover = true
|
showDateNavPopover = true
|
||||||
}
|
}
|
||||||
case .item(groupId: let groupId, item: let item):
|
case .item(let groupId, let item):
|
||||||
DramaGroupItemView(groupId: groupId, item: item)
|
DramaGroupItemView(groupId: groupId, item: item)
|
||||||
.id(item.id)
|
.id(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,8 +51,10 @@ final class IndexModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum GroupElement {
|
enum GroupElement {
|
||||||
case label(groupId: String, groupName: String)
|
// groupId, groupName
|
||||||
case item(groupId: String, item: UpdateDramaGroup.Item)
|
case label(String, String)
|
||||||
|
// groupId, UpdateDramaGroup.Item
|
||||||
|
case item(String, UpdateDramaGroup.Item)
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedDate: String
|
var selectedDate: String
|
||||||
@ -94,17 +96,13 @@ final class IndexModel {
|
|||||||
|
|
||||||
self.cancel = self.scrollIDPublisher
|
self.cancel = self.scrollIDPublisher
|
||||||
.sink { userId, scrollID in
|
.sink { userId, scrollID in
|
||||||
|
// 更新group的label的部分
|
||||||
self.dramaGroupElements.forEach { element in
|
switch self.dramaGroupElements[scrollID] {
|
||||||
switch element {
|
case .label(_, _):
|
||||||
case .label(_, _):
|
()
|
||||||
()
|
case .item(let groupId, let item):
|
||||||
case .item(let groupId, let item):
|
DispatchQueue.main.async {
|
||||||
if item.id == scrollID {
|
self.setFixedDrameGroup(groupId: groupId)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.setFixedDrameGroup(groupId: groupId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +142,19 @@ final class IndexModel {
|
|||||||
self.isLoaded = true
|
self.isLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reloadFollowNum(userId: String) async {
|
||||||
|
let response = await API.getFlowNum(userId: userId, as: Int.self)
|
||||||
|
switch response {
|
||||||
|
case .error(let code, let message):
|
||||||
|
print("reloadFollowNum get error: \(code), message: \(message)")
|
||||||
|
case .result(let follow_num):
|
||||||
|
await MainActor.run {
|
||||||
|
self.follow_num = follow_num >= 100 ? "99+" : "\(follow_num)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
func setFixedDrameGroup(groupId: String) {
|
func setFixedDrameGroup(groupId: String) {
|
||||||
if let newFixedDramaGroup = self.updateDramaGroups.first(where: {$0.group_id == groupId}),
|
if let newFixedDramaGroup = self.updateDramaGroups.first(where: {$0.group_id == groupId}),
|
||||||
newFixedDramaGroup.group_id != self.fixedDramaGroup?.group_id {
|
newFixedDramaGroup.group_id != self.fixedDramaGroup?.group_id {
|
||||||
@ -158,7 +169,7 @@ final class IndexModel {
|
|||||||
|
|
||||||
// 按照id来判断不一定正确,需要借助其他值
|
// 按照id来判断不一定正确,需要借助其他值
|
||||||
let dramaIds = self.getDramaIds(self.updateDramaGroups)
|
let dramaIds = self.getDramaIds(self.updateDramaGroups)
|
||||||
print("current ids: \(dramaIds)")
|
//print("current ids: \(dramaIds)")
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case .prev:
|
case .prev:
|
||||||
@ -207,9 +218,9 @@ final class IndexModel {
|
|||||||
private func transformUpdateDramaGroups(groups: [UpdateDramaGroup]) -> [GroupElement] {
|
private func transformUpdateDramaGroups(groups: [UpdateDramaGroup]) -> [GroupElement] {
|
||||||
var groupElements: [GroupElement] = []
|
var groupElements: [GroupElement] = []
|
||||||
for group in groups {
|
for group in groups {
|
||||||
groupElements.append(.label(groupId: group.group_id, groupName: group.group_name))
|
groupElements.append(.label(group.group_id, group.group_name))
|
||||||
for item in group.items {
|
for item in group.items {
|
||||||
groupElements.append(.item(groupId: group.group_id, item: item))
|
groupElements.append(.item(group.group_id, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groupElements
|
return groupElements
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
//
|
|
||||||
// SearchBar.swift
|
|
||||||
// dimensionhub
|
|
||||||
//
|
|
||||||
// Created by 安礼成 on 2025/4/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// 搜索框
|
|
||||||
struct SearchBar: View {
|
|
||||||
@State private var isSearching = false
|
|
||||||
|
|
||||||
@Binding<String> var searchText: String
|
|
||||||
var placeholder: String
|
|
||||||
var onSearch: () -> Void
|
|
||||||
|
|
||||||
@FocusState private var isFocused
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// 自定义搜索栏
|
|
||||||
HStack(alignment: .center, spacing: 8) {
|
|
||||||
// 输入框
|
|
||||||
TextField(placeholder, text: $searchText)
|
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
|
||||||
.keyboardType(.default)
|
|
||||||
.focused($isFocused)
|
|
||||||
.submitLabel(.search)
|
|
||||||
.onSubmit {
|
|
||||||
onSearch()
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.padding(8)
|
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.overlay(alignment: .trailing) {
|
|
||||||
HStack {
|
|
||||||
if isSearching {
|
|
||||||
Button(action: {
|
|
||||||
searchText = ""
|
|
||||||
}) {
|
|
||||||
Image(systemName: "multiply.circle.fill")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
// 搜索按钮
|
|
||||||
Button {
|
|
||||||
onSearch()
|
|
||||||
} label: {
|
|
||||||
Text("搜索")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 10)
|
|
||||||
.disabled(searchText.isEmpty)
|
|
||||||
}
|
|
||||||
.onChange(of: searchText) {
|
|
||||||
if searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
||||||
isSearching = false
|
|
||||||
} else {
|
|
||||||
isSearching = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
isFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// SearchBar()
|
|
||||||
//}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// SearchDramaGroupView.swift
|
|
||||||
// dimensionhub
|
|
||||||
//
|
|
||||||
// Created by 安礼成 on 2025/4/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// 显示分组信息
|
|
||||||
struct SearchDramaGroupView: View {
|
|
||||||
let group: SearchModel.DramaGroup
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
LazyVStack(alignment: .center, spacing: 10) {
|
|
||||||
ForEach(group.items, id: \.id) { item in
|
|
||||||
NavigationLink(destination: DetailView(id: item.id)) {
|
|
||||||
FlexImage(urlString: item.thumb, width: 370, height: 180, placeholder: "ph_img_big")
|
|
||||||
.frame(width: 370, height: 180)
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(item.name)
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(item.status)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(5)
|
|
||||||
.background(
|
|
||||||
Color.black.opacity(0.6)
|
|
||||||
)
|
|
||||||
.cornerRadius(5)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// SearchDramaGroupView()
|
|
||||||
//}
|
|
||||||
@ -10,58 +10,146 @@ import SwiftData
|
|||||||
|
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@Environment(\.modelContext) var modelContext
|
@Environment(\.modelContext) var modelContext
|
||||||
@State var showHistoryNum: Int = 2
|
|
||||||
|
|
||||||
@AppStorage("userId") private var userId: String = Utils.defaultUserId()
|
@AppStorage("userId") private var userId: String = Utils.defaultUserId()
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
|
|
||||||
@State var searchModel = SearchModel()
|
@State var searchModel = SearchModel()
|
||||||
|
|
||||||
|
@FocusState private var isFocused: Bool
|
||||||
|
@State private var isSearching = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: 12) {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(spacing: 12) {
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.black)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
HStack(spacing: 8) {
|
||||||
|
TextField("搜索...", text: $searchText)
|
||||||
SearchBar(searchText: $searchText, placeholder: "搜索...") {
|
.focused($isFocused)
|
||||||
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
.textFieldStyle(PlainTextFieldStyle())
|
||||||
if !trimmedSearchText.isEmpty {
|
.padding(8)
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
.background(Color(.systemGray6))
|
||||||
Task.detached {
|
.cornerRadius(8)
|
||||||
await searchModel.search(userId: userId, name: trimmedSearchText)
|
.submitLabel(.search)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.keyboardType(.default)
|
||||||
|
.onSubmit {
|
||||||
|
doSearch()
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
// let historyModel = SearchHistory(keyword: trimmedSearchText, timestamp: Date())
|
Button(action: {
|
||||||
// modelContext.insert(historyModel)
|
searchText = ""
|
||||||
|
}) {
|
||||||
|
Image(systemName: "multiply.circle.fill")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
.opacity(isSearching ? 1 : 0)
|
||||||
|
.disabled(!isSearching)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
doSearch()
|
||||||
|
isFocused = false // 关闭键盘
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("搜索")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
|
.padding(.trailing, 10)
|
||||||
|
.disabled(searchText.isEmpty)
|
||||||
|
}
|
||||||
|
.onChange(of: searchText) {
|
||||||
|
isSearching = !searchText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.padding([.top, .bottom], 8)
|
.padding(.top, 12)
|
||||||
|
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
if searchModel.dramaGroups.isEmpty {
|
||||||
// 基于日期的更新列表
|
Spacer()
|
||||||
LazyVStack(alignment: .center, spacing: 10) {
|
Text("什么都没有找到")
|
||||||
ForEach(searchModel.dramaGroups, id: \.group_id) { group in
|
.font(.system(size: 18))
|
||||||
SearchDramaGroupView(group: group)
|
.foregroundColor(Color(hex: "#333333"))
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
LazyVStack(alignment: .center, spacing: 10) {
|
||||||
|
ForEach(searchModel.dramaGroups, id: \.group_id) { group in
|
||||||
|
SearchDramaGroupView(group: group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.padding(8)
|
.padding(.horizontal, 12)
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(.keyboard, edges: .bottom) // 避免键盘遮挡
|
||||||
|
.onAppear {
|
||||||
|
// 避免过早触发系统输入错误
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
isFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func doSearch() {
|
||||||
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
Task.detached {
|
||||||
|
await searchModel.search(userId: userId, name: trimmed)
|
||||||
|
}
|
||||||
|
// 可选:添加历史记录
|
||||||
|
// let history = SearchHistory(keyword: trimmed, timestamp: Date())
|
||||||
|
// modelContext.insert(history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示分组信息
|
||||||
|
struct SearchDramaGroupView: View {
|
||||||
|
let group: SearchModel.DramaGroup
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVStack(alignment: .center, spacing: 10) {
|
||||||
|
ForEach(group.items, id: \.id) { item in
|
||||||
|
NavigationLink(destination: DetailView(id: item.id)) {
|
||||||
|
FlexImage(urlString: item.thumb, width: 370, height: 180, placeholder: "ph_img_big")
|
||||||
|
.frame(width: 370, height: 180)
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(item.name)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(item.status)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
.background(
|
||||||
|
Color.black.opacity(0.6)
|
||||||
|
)
|
||||||
|
.cornerRadius(5)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SearchView {
|
extension SearchView {
|
||||||
|
|||||||
@ -68,7 +68,9 @@ struct dimensionhubApp: App {
|
|||||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
registerForPushNotifications()
|
Task.detached {
|
||||||
|
await self.registerForPushNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -144,9 +146,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
|
||||||
//let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
// 处理通知数据
|
// 处理通知数据
|
||||||
//handleRemoteNotification(userInfo: userInfo)
|
handleRemoteNotification(userInfo: userInfo)
|
||||||
|
|
||||||
// 设置如何显示通知
|
// 设置如何显示通知
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
@ -171,9 +173,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||||||
// MARK: 消息处理
|
// MARK: 消息处理
|
||||||
|
|
||||||
private func handleRemoteNotification(userInfo: [AnyHashable: Any]) {
|
private func handleRemoteNotification(userInfo: [AnyHashable: Any]) {
|
||||||
if let customData = userInfo["custom_data"] as? [String: AnyObject],
|
guard let customData = userInfo["custom_data"] as? [String: AnyObject],
|
||||||
let dramaId = customData["drama_id"] as? Int {
|
let target = customData["target"] as? String,
|
||||||
AppNavigation.shared.append(dest: .detail(id: dramaId))
|
let params = customData["params"] as? [String : AnyObject] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch target {
|
||||||
|
case "detail":
|
||||||
|
if let dramaId = params["drama_id"] as? Int {
|
||||||
|
AppNavigation.shared.append(dest: .detail(id: dramaId))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user