Compare commits

..

No commits in common. "eecee74af27171855059ae831896697cf7a38f84" and "8517c5afc3405222f7a8cee0e3039df5100f6ddc" have entirely different histories.

15 changed files with 206 additions and 354 deletions

14
TODO.md
View File

@ -1,14 +0,0 @@
## 增加接口
### 1. 获取用户的关注数
GET: /api/follow_num?user_id=$user_id
### 2. 保存用户的设备token
url: /api/device_token
method: post
params:
{
user_id: string,
token: string
}

View File

@ -27,7 +27,6 @@
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; };
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 */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -89,7 +88,6 @@
C85F58B72D64D10F00D761E9 = {
isa = PBXGroup;
children = (
C8E6E2052DAF4A130014CDB3 /* TODO.md */,
C85F58C22D64D10F00D761E9 /* dimensionhub */,
C85F58D52D64D11000D761E9 /* dimensionhubTests */,
C85F58DF2D64D11000D761E9 /* dimensionhubUITests */,

View File

@ -10,23 +10,5 @@
<integer>0</integer>
</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>
</plist>

View File

@ -61,13 +61,6 @@ struct API {
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> {
let request = URLRequest(url: URL(string: baseUrl + "/api/load_more_dramas?user_id=\(userId)&mode=\(mode.rawValue)&id=\(id)")!)

View File

@ -77,13 +77,13 @@ final class CacheManager {
}
//
func saveCacheFile(filename: String, data: Data) throws {
private func saveCacheFile(filename: String, data: Data) throws {
let fileURL = cacheDir.appendingPathComponent(filename)
try data.write(to: fileURL)
}
//
func downloadImage(from urlString: String) async throws -> Data? {
private func downloadImage(from urlString: String) async throws -> Data? {
guard let url = URL(string: urlString) else {
return nil
}
@ -106,10 +106,6 @@ final class CacheManager {
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 {
let data = Data(str.utf8)
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))

View File

@ -28,9 +28,11 @@ struct FlexImage: View {
let cacheManager = CacheManager.shared
if let data = cacheManager.readFileContents(urlString: urlString) {
//print("url: \(urlString), hit cache")
self.mode = .local(data)
} else {
Task.detached {
await cacheManager.preloadImage(url: urlString)
}
self.mode = .remote(urlString)
}
}
@ -38,7 +40,7 @@ struct FlexImage: View {
var body: some View {
switch self.mode {
case .remote(let url):
FlexAsyncImage(url: URL(string: url)) { phase in
AsyncImage(url: URL(string: url)) { phase in
switch phase {
case .empty:
ProgressView()
@ -48,7 +50,7 @@ struct FlexImage: View {
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.clipped()
case .failure:
default:
Image(placeholder)
.resizable()
.aspectRatio(contentMode: .fill)
@ -73,61 +75,6 @@ struct FlexImage: View {
}
enum FlexImagePhase {
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")
//FlexImage()
}

View File

@ -1,40 +0,0 @@
//
// 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 = []
}
}
}

View File

@ -12,8 +12,6 @@ import Observation
final class FollowListModel {
struct DramaItem: Codable {
struct Channel: Codable {
struct Episode: Codable, Identifiable {
let id = UUID().uuidString
let name: String
@ -26,13 +24,9 @@ final class FollowListModel {
}
}
let name: String
let episodes: [Episode]
}
let id: Int
let title: String
let channels: [Channel]
let episodes: [Episode]
}
struct FavorResponse: Codable {
@ -61,10 +55,7 @@ final class FollowListModel {
private func preloadImages(dramas: [DramaItem]) {
let cacheManager = CacheManager.shared
dramas.forEach { dramaItem in
let urls = dramaItem.channels.flatMap { channel in
channel.episodes.map { $0.thumb }
}
let urls = dramaItem.episodes.map { $0.thumb }
if urls.count > 0 {
Task.detached(priority: .medium) {
try? await cacheManager.preloadImages(urls: urls)

View File

@ -25,7 +25,7 @@ struct FollowListView: View {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(alignment: .center) {
ForEach(followModel.dramas, id: \.id) { drama in
DramaCellView(dramaModel: FollowDramaModel(drama: drama))
DramaCellView(dramaItem: drama)
}
}
}
@ -54,34 +54,21 @@ extension FollowListView {
//
struct DramaCellView: View {
let dramaModel: FollowDramaModel
let dramaItem: FollowListModel.DramaItem
var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading) {
NavigationLink(destination: DetailView(id: dramaModel.drama.id)) {
Text(dramaModel.drama.title)
NavigationLink(destination: DetailView(id: dramaItem.id)) {
Text(dramaItem.title)
.font(.system(size: 20))
.foregroundColor(Color(hex: "#333333"))
.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) {
LazyHStack(alignment: .center, spacing: 5) {
ForEach(dramaModel.episodes) { item in
ForEach(dramaItem.episodes) { item in
DramaCellEpisodeView(item: item)
}
}
@ -91,7 +78,7 @@ extension FollowListView {
}
struct DramaCellEpisodeView: View {
let item: FollowListModel.DramaItem.Channel.Episode
let item: FollowListModel.DramaItem.Episode
var body: some View {
VStack(alignment: .center) {

View File

@ -34,13 +34,13 @@ struct IndexMainView: View {
HStack(alignment: .center) {
Text("亚次元")
.font(.system(size: 20, weight: .bold))
.font(.system(size: 18, weight: .bold))
.padding([.top, .bottom], 5)
Spacer()
HStack {
Text("\(indexModel.follow_num)")
.font(.system(size: 18))
.font(.system(size: 17))
.foregroundColor(.black)
.padding([.top, .bottom], 5)
.padding(.leading, 10)
@ -52,9 +52,6 @@ struct IndexMainView: View {
}
)
.zIndex(1)
.task {
await indexModel.reloadFollowNum(userId: userId)
}
}
.padding([.leading, .trailing], 15)
.frame(height: 50)
@ -68,15 +65,15 @@ struct IndexMainView: View {
LazyVStack(alignment: .center, spacing: 10) {
ForEach(Array(indexModel.dramaGroupElements.enumerated()), id: \.offset) { idx, item in
switch item {
case .label(let groupId, let groupName):
case .label(groupId: let groupId, groupName: let groupName):
DramaGroupLabelView(group_name: groupName) {
selectGroupId = groupId
indexModel.selectedDate = groupId
showDateNavPopover = true
}
case .item(let groupId, let item):
case .item(groupId: let groupId, item: let item):
DramaGroupItemView(groupId: groupId, item: item)
.id(idx)
.id(item.id)
}
}
}

View File

@ -51,10 +51,8 @@ final class IndexModel {
}
enum GroupElement {
// groupId, groupName
case label(String, String)
// groupId, UpdateDramaGroup.Item
case item(String, UpdateDramaGroup.Item)
case label(groupId: String, groupName: String)
case item(groupId: String, item: UpdateDramaGroup.Item)
}
var selectedDate: String
@ -96,15 +94,19 @@ final class IndexModel {
self.cancel = self.scrollIDPublisher
.sink { userId, scrollID in
// grouplabel
switch self.dramaGroupElements[scrollID] {
self.dramaGroupElements.forEach { element in
switch element {
case .label(_, _):
()
case .item(let groupId, let item):
if item.id == scrollID {
DispatchQueue.main.async {
self.setFixedDrameGroup(groupId: groupId)
}
}
}
}
//
let ids = self.getDramaIds(self.updateDramaGroups).suffix(6)
@ -142,19 +144,6 @@ final class IndexModel {
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) {
if let newFixedDramaGroup = self.updateDramaGroups.first(where: {$0.group_id == groupId}),
newFixedDramaGroup.group_id != self.fixedDramaGroup?.group_id {
@ -169,7 +158,7 @@ final class IndexModel {
// id
let dramaIds = self.getDramaIds(self.updateDramaGroups)
//print("current ids: \(dramaIds)")
print("current ids: \(dramaIds)")
switch mode {
case .prev:
@ -218,9 +207,9 @@ final class IndexModel {
private func transformUpdateDramaGroups(groups: [UpdateDramaGroup]) -> [GroupElement] {
var groupElements: [GroupElement] = []
for group in groups {
groupElements.append(.label(group.group_id, group.group_name))
groupElements.append(.label(groupId: group.group_id, groupName: group.group_name))
for item in group.items {
groupElements.append(.item(group.group_id, item))
groupElements.append(.item(groupId: group.group_id, item: item))
}
}
return groupElements

View File

@ -0,0 +1,79 @@
//
// 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()
//}

View File

@ -0,0 +1,47 @@
//
// 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()
//}

View File

@ -10,146 +10,58 @@ import SwiftData
struct SearchView: View {
@Environment(\.modelContext) var modelContext
@AppStorage("userId") private var userId: String = Utils.defaultUserId()
@State var showHistoryNum: Int = 2
@AppStorage("userId") private var userId: String = Utils.defaultUserId()
@Environment(\.dismiss) var dismiss
@State var searchText: String = ""
@State var searchModel = SearchModel()
@FocusState private var isFocused: Bool
@State private var isSearching = false
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
VStack {
HStack(alignment: .center, spacing: 0) {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.black)
}
HStack(spacing: 8) {
TextField("搜索...", text: $searchText)
.focused($isFocused)
.textFieldStyle(PlainTextFieldStyle())
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(8)
.submitLabel(.search)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.keyboardType(.default)
.onSubmit {
doSearch()
}
.overlay(alignment: .trailing) {
Button(action: {
searchText = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.gray)
.padding(.trailing, 8)
}
.opacity(isSearching ? 1 : 0)
.disabled(!isSearching)
Spacer()
SearchBar(searchText: $searchText, placeholder: "搜索...") {
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedSearchText.isEmpty {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
Task.detached {
await searchModel.search(userId: userId, name: trimmedSearchText)
}
Button {
if !searchText.isEmpty {
doSearch()
isFocused = false //
// let historyModel = SearchHistory(keyword: trimmedSearchText, timestamp: Date())
// modelContext.insert(historyModel)
}
} 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)
.padding(.top, 12)
.padding([.top, .bottom], 8)
if searchModel.dramaGroups.isEmpty {
Spacer()
Text("什么都没有找到")
.font(.system(size: 18))
.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("")
.navigationBarBackButtonHidden(true)
.padding(.horizontal, 12)
.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)
.ignoresSafeArea(edges: .bottom)
}
}
}
}
}
}
extension SearchView {

View File

@ -68,9 +68,7 @@ struct dimensionhubApp: App {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
Task.detached {
await self.registerForPushNotifications()
}
registerForPushNotifications()
return true
}
@ -146,9 +144,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
willPresent notification: UNNotification,
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])
@ -173,20 +171,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// MARK:
private func handleRemoteNotification(userInfo: [AnyHashable: Any]) {
guard let customData = userInfo["custom_data"] as? [String: AnyObject],
let target = customData["target"] as? String,
let params = customData["params"] as? [String : AnyObject] else {
return
}
switch target {
case "detail":
if let dramaId = params["drama_id"] as? Int {
if let customData = userInfo["custom_data"] as? [String: AnyObject],
let dramaId = customData["drama_id"] as? Int {
AppNavigation.shared.append(dest: .detail(id: dramaId))
}
default:
()
}
}
private func handleDeepLink(_ link: String) {