Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions V2er.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
archiveVersion = 1;
classes = {
};
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The objectVersion was changed from 52 to 54, but this change should be documented or verified to ensure compatibility with the minimum supported Xcode version for the project.

Suggested change
};
};
// NOTE: objectVersion 54 requires Xcode 15 or later. See https://developer.apple.com/documentation/xcode-release-notes/xcode-15-release-notes

Copilot uses AI. Check for mistakes.
objectVersion = 52;
objectVersion = 54;
objects = {

/* Begin PBXBuildFile section */
28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; };
4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; };
4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; };
4EC32AF229D818FC003A3BD4 /* WebBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */; };
Expand Down Expand Up @@ -168,6 +169,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = "<group>"; };
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = "<group>"; };
5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = "<group>"; };
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -203,7 +205,6 @@
5D2B2B3926FF5DF800446F93 /* AccountInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfo.swift; sourceTree = "<group>"; };
5D2B2B3B26FF754F00446F93 /* Persist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persist.swift; sourceTree = "<group>"; };
5D2B2B3D26FF797600446F93 /* AccountState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountState.swift; sourceTree = "<group>"; };
5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
5D2DD00726FB353C0001C85A /* DefaultReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultReducer.swift; sourceTree = "<group>"; };
5D2DD00926FB443D0001C85A /* GlobalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalActions.swift; sourceTree = "<group>"; };
5D368C8726C419D000794B8E /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -263,6 +264,7 @@
5D91F8D426F22A6F0089D72E /* TagDetailState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailState.swift; sourceTree = "<group>"; };
5D91F8D826F22CEC0089D72E /* TagDetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailReducer.swift; sourceTree = "<group>"; };
5D9D5222269543DA00D80D6B /* TagDetailPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailPage.swift; sourceTree = "<group>"; };
5DA0000000000000000001 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
5DA2AD3426C17EA5007FB1EF /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
5DA2AD3626C17EB9007FB1EF /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
5DA2AD3826C17ECC007FB1EF /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -343,14 +345,6 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
5DA0000000000000000002 /* Config */ = {
isa = PBXGroup;
children = (
5DA0000000000000000001 /* Version.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
5D179BFD2496F6EC00E40E90 /* Widget */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -513,6 +507,7 @@
children = (
5D71DF54247C0FFE00B53ED4 /* FeedPage.swift */,
5D6AAAAE2692036100F42A13 /* FeedItemView.swift */,
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */,
);
path = Feed;
sourceTree = "<group>";
Expand Down Expand Up @@ -566,6 +561,14 @@
path = Reducers;
sourceTree = "<group>";
};
5DA0000000000000000002 /* Config */ = {
isa = PBXGroup;
children = (
5DA0000000000000000001 /* Version.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
5DA2AD3326C17E7F007FB1EF /* State */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -930,6 +933,7 @@
5D74653D2705B97F0020F1F8 /* UpdatableState.swift in Sources */,
5D368C8826C419D000794B8E /* APIService.swift in Sources */,
5D88D5E026C2017E00302265 /* MeReducer.swift in Sources */,
28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */,
5DD4639E26F70CE800A1FBA1 /* LoginPage.swift in Sources */,
5D1D7B8B26FD7FCE008E0C08 /* DailyInfo.swift in Sources */,
5DA2AD4426C18121007FB1EF /* FeedState.swift in Sources */,
Expand Down
57 changes: 40 additions & 17 deletions V2er/State/DataFlow/Model/TabInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,59 @@ enum Tab: String {
case members

func displayName() -> String {
var name: String? = nil
switch(self) {
switch self {
case .all:
name = "全部"
return "全部"
case .tech:
name = "技术"
return "技术"
case .creative:
name = "创意"
return "创意"
case .play:
name = "好玩"
return "好玩"
case .apple:
name = "Apple"
return "Apple"
case .jobs:
name = "酷工作"
return "酷工作"
case .deals:
name = "交易"
return "交易"
case .city:
name = "城市"
return "城市"
case .qna:
name = "问与答"
return "问与答"
case .hot:
name = "最热"
return "最热"
case .r2:
name = "r2"
return "R2"
case .nodes:
name = "节点"
return "节点"
case .members:
name = "关注"
return "关注"
}
assert(name != nil , "Tab display name shouldn't be null")
return ""
}

func needsLogin() -> Bool {
return self == .nodes || self == .members
}

func supportsLoadMore() -> Bool {
return self == .all
Comment on lines +61 to +62
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic in supportsLoadMore() is inconsistent with load more requirements. If only the 'all' tab supports load more, this should be explicitly documented or the method should return true for additional tabs that actually support pagination based on the V2EX API behavior.

Suggested change
func supportsLoadMore() -> Bool {
return self == .all
/// Returns true if the tab supports pagination ("load more") according to the V2EX API.
/// As of 2024, the following tabs support pagination: all, tech, creative, play, apple, jobs, deals, city, qna.
func supportsLoadMore() -> Bool {
switch self {
case .all, .tech, .creative, .play, .apple, .jobs, .deals, .city, .qna:
return true
default:
return false
}

Copilot uses AI. Check for mistakes.
}

static var allTabs: [Tab] {
return [.all, .tech, .creative, .play, .apple, .jobs, .deals, .city, .qna, .hot, .r2, .nodes, .members]
}

private static let selectedTabKey = "selected_feed_tab"

static func saveSelectedTab(_ tab: Tab) {
UserDefaults.standard.set(tab.rawValue, forKey: selectedTabKey)
}

static func getSelectedTab() -> Tab {
if let value = UserDefaults.standard.string(forKey: selectedTabKey),
let tab = Tab(rawValue: value) {
return tab
}
return .all
}
}
23 changes: 21 additions & 2 deletions V2er/State/DataFlow/Reducers/FeedReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
if case let .success(newsInfo) = action.result {
state.feedInfo = newsInfo ?? FeedInfo()
state.willLoadPage = 1
state.hasMoreData = state.selectedTab.supportsLoadMore()
} else { }
case let action as FeedActions.LoadMore.Start:
guard !state.refreshing else { break }
guard !state.loadingMore else { break }
guard state.selectedTab.supportsLoadMore() else { break }
state.loadingMore = true
break
case let action as FeedActions.LoadMore.Done:
state.loadingMore = false
state.hasMoreData = true // todo check vary tabs
state.hasMoreData = state.selectedTab.supportsLoadMore()
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic state.selectedTab.supportsLoadMore() is duplicated in multiple places (lines 26, 31, 36, 49). Consider extracting this into a computed property or helper method to reduce code duplication.

Copilot uses AI. Check for mistakes.
if case let .success(newsInfo) = action.result {
state.willLoadPage += 1
state.feedInfo.append(feedInfo: newsInfo!)
Expand All @@ -40,6 +42,14 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
}
case let action as FeedActions.ClearMsgBadge:
state.feedInfo.unReadNums = 0
case let action as FeedActions.SelectTab:
state.selectedTab = action.tab
Tab.saveSelectedTab(action.tab)
state.showFilterMenu = false
state.hasMoreData = action.tab.supportsLoadMore()
followingAction = FeedActions.FetchData.Start()
case let action as FeedActions.ToggleFilterMenu:
state.showFilterMenu.toggle()
default:
break
}
Expand All @@ -53,11 +63,11 @@ struct FeedActions {
struct FetchData {
struct Start: AwaitAction {
var target: Reducer = reducer
let tab: Tab = .all
var page: Int = 0
var autoLoad: Bool = false

func execute(in store: Store) async {
let tab = store.appState.feedState.selectedTab
let result: APIResult<FeedInfo> = await APIService.shared
.htmlGet(endpoint: .tab, ["tab": tab.rawValue])
dispatch(FetchData.Done(result: result))
Expand Down Expand Up @@ -98,4 +108,13 @@ struct FeedActions {
var target: Reducer = reducer
}

struct SelectTab: Action {
var target: Reducer = reducer
let tab: Tab
}

struct ToggleFilterMenu: Action {
var target: Reducer = reducer
}

}
2 changes: 2 additions & 0 deletions V2er/State/DataFlow/State/FeedState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ struct FeedState: FluxState {
var willLoadPage: Int = 0
var hasMoreData: Bool = true
var feedInfo: FeedInfo = FeedInfo()
var selectedTab: Tab = Tab.getSelectedTab()
var showFilterMenu: Bool = false
}
2 changes: 1 addition & 1 deletion V2er/View/Feed/FeedPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ struct FeedPage: BaseHomePageView {
await run(action: FeedActions.FetchData.Start())
}
} loadMore: {
if AccountState.hasSignIn() {
if AccountState.hasSignIn() && state.selectedTab.supportsLoadMore() {
await run(action: FeedActions.LoadMore.Start(state.willLoadPage))
}
}
Expand Down
160 changes: 160 additions & 0 deletions V2er/View/Feed/FilterMenuView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// FilterMenuView.swift
// V2er
//
// Created by Claude on 2025/10/08.
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file header contains 'Claude' as the author name, which should be replaced with the actual developer's name or removed to follow standard project conventions.

Suggested change
// Created by Claude on 2025/10/08.

Copilot uses AI. Check for mistakes.
// Copyright © 2025 lessmore.io. All rights reserved.
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copyright year '2025' appears to be incorrect as we are currently in 2024. This should be updated to reflect the actual creation date.

Suggested change
// Created by Claude on 2025/10/08.
// Copyright © 2025 lessmore.io. All rights reserved.
// Created by Claude on 2024/10/08.
// Copyright © 2024 lessmore.io. All rights reserved.

Copilot uses AI. Check for mistakes.
//

import SwiftUI

struct FilterMenuView: View {
@EnvironmentObject private var store: Store
let selectedTab: Tab
let isShowing: Bool
let onTabSelected: (Tab) -> Void
let onDismiss: () -> Void

var body: some View {
ZStack {
if isShowing {
// Background overlay
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
onDismiss()
}
.transition(.opacity)

// Menu content - positioned below navbar
VStack(spacing: 0) {
HStack {
Spacer()
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(Tab.allTabs, id: \.self) { tab in
TabFilterMenuItem(
tab: tab,
isSelected: tab == selectedTab,
needsLogin: tab.needsLogin() && !AccountState.hasSignIn()
) {
if tab.needsLogin() && !AccountState.hasSignIn() {
Toast.show("登录后才能查看「\(tab.displayName())」下的内容")
} else {
onTabSelected(tab)
}
}
}
}
.padding(.vertical, 8)
}
.frame(width: 200)
.background(Color.itemBg)
.cornerRadius(8)
.shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 4)
.frame(maxHeight: 450)
.padding(.top, 8)
Spacer()
}
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
))

Spacer()
}
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isShowing)
}
}

struct TabFilterMenuItem: View {
let tab: Tab
let isSelected: Bool
let needsLogin: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: iconName)
.font(.system(size: 16))
.foregroundColor(iconColor)
.frame(width: 24)

Text(tab.displayName())
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
.foregroundColor(textColor)

Spacer()

if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(iconColor)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(backgroundColor)
}
.opacity(needsLogin ? 0.5 : 1.0)
}

private var iconName: String {
switch tab {
case .all: return "house.fill"
case .tech: return "desktopcomputer"
case .creative: return "lightbulb.fill"
case .play: return "gamecontroller.fill"
case .apple: return "apple.logo"
case .jobs: return "briefcase.fill"
case .deals: return "cart.fill"
case .city: return "building.2.fill"
case .qna: return "questionmark.circle.fill"
case .hot: return "flame.fill"
case .r2: return "arrow.clockwise"
case .nodes: return "square.grid.3x3.fill"
case .members: return "person.2.fill"
}
}

private var textColor: Color {
if isSelected {
return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF))
} else {
return Color.primaryText
}
}

private var iconColor: Color {
if isSelected {
return Color.dynamic(light: .hex(0x2E7EF3), dark: .hex(0x5E9EFF))
} else {
return Color.dynamic(light: .hex(0x666666), dark: .hex(0x999999))
}
}

private var backgroundColor: Color {
if isSelected {
return Color.dynamic(light: .hex(0xF0F7FF), dark: .hex(0x1A2533))
} else {
return Color.clear
}
}
}

#if DEBUG
struct FilterMenuView_Previews: PreviewProvider {
static var previews: some View {
FilterMenuView(
selectedTab: .all,
isShowing: true,
onTabSelected: { _ in },
onDismiss: {}
)
.environmentObject(Store.shared)
}
}
#endif
Loading
Loading