Skip to content

Commit bf3cce4

Browse files
graycreateclaude
andcommitted
feat: Add user balance display to Me page
Implement balance display feature showing gold, silver, and bronze coins on the user profile page. Changes: - Add BalanceInfo model with HTML parsing for V2EX balance data - Implement FetchBalance action and reducer to retrieve balance from API - Create BalanceView widget with coin badges for visual display - Add balance persistence to AccountState - Add /balance endpoint to API service - Display balance under username in Me page when available The balance is fetched automatically when the Me page appears for signed-in users and persists across app sessions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a969d5f commit bf3cce4

File tree

8 files changed

+243
-11
lines changed

8 files changed

+243
-11
lines changed

V2er.xcodeproj/project.pbxproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@
168168
/* End PBXContainerItemProxy section */
169169

170170
/* Begin PBXFileReference section */
171-
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = "<group>"; };
172171
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = "<group>"; };
173172
5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = "<group>"; };
174173
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = "<group>"; };
@@ -312,6 +311,7 @@
312311
5DF417732712DA7500E6D135 /* MyRecentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyRecentState.swift; sourceTree = "<group>"; };
313312
5DF80E3526A2D045002ADC79 /* MultilineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextField.swift; sourceTree = "<group>"; };
314313
5DF92A5C26859DDD00E6086E /* HeadIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadIndicatorView.swift; sourceTree = "<group>"; };
314+
A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMenuView.swift; sourceTree = "<group>"; };
315315
/* End PBXFileReference section */
316316

317317
/* Begin PBXFrameworksBuildPhase section */

V2er/General/AccountState.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,14 @@ struct AccountState {
5959
return userName == Self.userName && userName != .default
6060
}
6161

62+
static var balance: BalanceInfo? {
63+
return getAccount()?.balance
64+
}
65+
66+
static func updateBalance(_ balance: BalanceInfo) {
67+
guard var account = getAccount() else { return }
68+
account.balance = balance
69+
saveAccount(account)
70+
}
71+
6272
}

V2er/State/DataFlow/Actions/MeActions.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,21 @@ import Foundation
1010

1111
struct MeActions {
1212
private static let R: Reducer = .me
13-
// struct ShowLoginPageAction: Action {
14-
// var target: Reducer = R
15-
// }
13+
14+
struct FetchBalance {
15+
struct Start: AwaitAction {
16+
var target: Reducer = R
17+
18+
func execute(in store: Store) async {
19+
let result: APIResult<BalanceInfo> = await APIService.shared
20+
.htmlGet(endpoint: .balance)
21+
dispatch(FetchBalance.Done(result: result))
22+
}
23+
}
24+
25+
struct Done: Action {
26+
var target: Reducer = R
27+
let result: APIResult<BalanceInfo>
28+
}
29+
}
1630
}

V2er/State/DataFlow/Model/AccountInfo.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,129 @@
77
//
88

99
import Foundation
10+
import SwiftSoup
11+
12+
struct BalanceInfo: BaseModel, Codable {
13+
var rawData: String?
14+
var gold: Int = 0
15+
var silver: Int = 0
16+
var bronze: Int = 0
17+
18+
init() {}
19+
20+
enum CodingKeys: String, CodingKey {
21+
case gold, silver, bronze
22+
}
23+
24+
init(from html: Element?) {
25+
guard let root = html else { return }
26+
27+
// Strategy 1: Parse from div#money (main balance page)
28+
// HTML: <div id="money"><a href="/balance" class="balance_area">47 <img...> 28 <img...> 26 <img...></a></div>
29+
if let moneyDiv = root.pickOne("div#money a.balance_area") {
30+
parseFromMoneyDiv(moneyDiv)
31+
}
32+
33+
// Strategy 2: Parse from table cells (alternative format)
34+
// Structure: table.data > tbody > tr > td
35+
if gold == 0 && silver == 0 && bronze == 0 {
36+
parseFromTableCells(root: root)
37+
}
38+
39+
// Strategy 3: Parse from table rows with separate cells
40+
// Some V2EX pages show balance as: <td align="right">47</td><td>金币</td>
41+
if gold == 0 && silver == 0 && bronze == 0 {
42+
parseFromTableRows(root: root)
43+
}
44+
}
45+
46+
private mutating func parseFromMoneyDiv(_ element: Element) {
47+
// Parse from: "47 <img src="/static/img/gold@2x.png"> 28 <img src="/static/img/silver@2x.png"> 26 <img src="/static/img/bronze@2x.png">"
48+
let text = element.value(.text)
49+
50+
// Split by whitespace and filter out empty strings
51+
let numbers = text.components(separatedBy: .whitespaces)
52+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
53+
.filter { !$0.isEmpty }
54+
.compactMap { parseNumber($0) }
55+
56+
// Assign in order: gold, silver, bronze
57+
if numbers.count >= 1 { gold = numbers[0] }
58+
if numbers.count >= 2 { silver = numbers[1] }
59+
if numbers.count >= 3 { bronze = numbers[2] }
60+
}
61+
62+
private mutating func parseFromTableCells(root: Element) {
63+
// Try to parse from cells that contain balance information
64+
// V2EX balance page typically shows: "XX 金币", "XX 银币", "XX 铜币"
65+
let cells = root.pickAll("table.data tbody tr td")
66+
67+
for cell in cells {
68+
let text = cell.value(.text)
69+
70+
if text.contains("金币") {
71+
gold = extractNumberBefore(keyword: "金币", from: text)
72+
} else if text.contains("银币") {
73+
silver = extractNumberBefore(keyword: "银币", from: text)
74+
} else if text.contains("铜币") {
75+
bronze = extractNumberBefore(keyword: "铜币", from: text)
76+
}
77+
}
78+
}
79+
80+
private mutating func parseFromTableRows(root: Element) {
81+
// Try alternative parsing method for table rows
82+
let rows = root.pickAll("table.data tbody tr")
83+
84+
for row in rows {
85+
let cells = row.pickAll("td")
86+
if cells.count >= 2 {
87+
let valueCell = cells[0].value(.text).trimmingCharacters(in: .whitespacesAndNewlines)
88+
let labelCell = cells[1].value(.text).trimmingCharacters(in: .whitespacesAndNewlines)
89+
90+
if let value = parseNumber(valueCell) {
91+
if labelCell.contains("金币") {
92+
gold = value
93+
} else if labelCell.contains("银币") {
94+
silver = value
95+
} else if labelCell.contains("铜币") {
96+
bronze = value
97+
}
98+
}
99+
}
100+
}
101+
}
102+
103+
private func parseNumber(_ text: String) -> Int? {
104+
// Remove commas and parse the number
105+
// Example: "2,025,101,344" -> 2025101344
106+
// Example: "47" -> 47
107+
let cleaned = text.replacingOccurrences(of: ",", with: "")
108+
.trimmingCharacters(in: .whitespacesAndNewlines)
109+
return Int(cleaned)
110+
}
111+
112+
private func extractNumberBefore(keyword: String, from text: String) -> Int {
113+
// Extract number before the keyword, handling commas
114+
// Example: "47 金币" -> 47
115+
// Example: "2,025 金币" -> 2025
116+
let components = text.components(separatedBy: keyword)
117+
if let firstPart = components.first {
118+
let cleaned = firstPart.trimmingCharacters(in: .whitespacesAndNewlines)
119+
return parseNumber(cleaned) ?? 0
120+
}
121+
return 0
122+
}
123+
124+
func isValid() -> Bool {
125+
return gold > 0 || silver > 0 || bronze > 0
126+
}
127+
}
10128

11129
struct AccountInfo: Codable {
12130
var username: String
13131
var avatar: String
132+
var balance: BalanceInfo?
14133

15134
func isValid() -> Bool {
16135
return notEmpty(username, avatar)

V2er/State/DataFlow/Reducers/MeReducer.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ func meStateReducer(_ state: MeState, _ action: Action) -> (MeState, Action?) {
1313
var followingAction: Action?
1414

1515
switch action {
16-
// case let action as MeActions.ShowLoginPageAction:
17-
// guard !state.showLoginView else { break }
18-
// state.showLoginView = true
16+
case let action as MeActions.FetchBalance.Done:
17+
if case .success(let balanceInfo) = action.result {
18+
if let balance = balanceInfo {
19+
AccountState.updateBalance(balance)
20+
log("Balance updated: gold=\(balance.gold), silver=\(balance.silver), bronze=\(balance.bronze)")
21+
} else {
22+
log("Balance info is nil")
23+
}
24+
} else if case .failure(let error) = action.result {
25+
log("Failed to fetch balance: \(error)")
26+
}
1927
default:
2028
break
2129
}

V2er/State/Networking/Endpoint.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ enum Endpoint {
3131
case followUser(id: String), unfollowUser(id: String)
3232
case starNode(id: String), dailyMission
3333
case checkin, downMyTopic(id: String), pinTopic(id: String)
34+
case balance
3435
case search
3536
case general(url: String)
3637

@@ -147,6 +148,8 @@ enum Endpoint {
147148
info.path = "/fade/topic/\(id)"
148149
case let .pinTopic(id):
149150
info.path = "/sticky/topic/\(id)"
151+
case .balance:
152+
info.path = "/balance"
150153
case let .search:
151154
info.path = "https://www.sov2ex.com/api/search"
152155
case let .general(url):

V2er/View/Me/MePage.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ struct MePage: BaseHomePageView {
4949
.background(Color.dim)
5050
}
5151
}
52+
.onAppear {
53+
if AccountState.hasSignIn() {
54+
dispatch(MeActions.FetchBalance.Start())
55+
}
56+
}
5257
}
5358

5459
@ViewBuilder
@@ -59,8 +64,9 @@ struct MePage: BaseHomePageView {
5964
VStack(alignment: .leading, spacing: 6) {
6065
Text(AccountState.userName)
6166
.font(.headline)
62-
Text("")
63-
.font(.footnote)
67+
if let balance = AccountState.balance, balance.isValid() {
68+
BalanceView(balance: balance, size: 14)
69+
}
6470
}
6571
Spacer()
6672
}
@@ -107,10 +113,9 @@ struct MePage: BaseHomePageView {
107113

108114
}
109115

110-
111116
struct AccountPage_Previews: PreviewProvider {
112117
static var selected = TabId.me
113-
118+
114119
static var previews: some View {
115120
MePage(selecedTab: selected)
116121
.environmentObject(Store.shared)

V2er/View/Widget/BalanceView.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// BalanceView.swift
3+
// V2er
4+
//
5+
// Created by ghui on 2025/10/18.
6+
// Copyright © 2025 lessmore.io. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
struct BalanceView: View {
12+
var balance: BalanceInfo
13+
var size: CGFloat = 14
14+
15+
var body: some View {
16+
HStack(spacing: 8) {
17+
if balance.gold > 0 {
18+
BalanceBadge(count: balance.gold, icon: "🟡", color: .yellow, size: size)
19+
}
20+
if balance.silver > 0 {
21+
BalanceBadge(count: balance.silver, icon: "⚪️", color: .gray, size: size)
22+
}
23+
if balance.bronze > 0 {
24+
BalanceBadge(count: balance.bronze, icon: "🟤", color: .orange, size: size)
25+
}
26+
}
27+
}
28+
}
29+
30+
struct BalanceBadge: View {
31+
var count: Int
32+
var icon: String
33+
var color: Color
34+
var size: CGFloat
35+
36+
var body: some View {
37+
HStack(spacing: 4) {
38+
Text(icon)
39+
.font(.system(size: size - 2))
40+
Text("\(count)")
41+
.font(.system(size: size, weight: .medium))
42+
.foregroundColor(.primaryText)
43+
}
44+
.padding(.horizontal, 8)
45+
.padding(.vertical, 4)
46+
.background(
47+
RoundedRectangle(cornerRadius: 12)
48+
.fill(Color.lightGray.opacity(0.3))
49+
)
50+
}
51+
}
52+
53+
struct BalanceView_Previews: PreviewProvider {
54+
static var previews: some View {
55+
VStack(spacing: 20) {
56+
BalanceView(balance: BalanceInfo(gold: 47, silver: 28, bronze: 26))
57+
BalanceView(balance: BalanceInfo(gold: 100, silver: 0, bronze: 50))
58+
BalanceView(balance: BalanceInfo(gold: 0, silver: 10, bronze: 0))
59+
}
60+
.padding()
61+
.background(Color.itemBackground)
62+
}
63+
}
64+
65+
// Extension to allow preview initialization
66+
extension BalanceInfo {
67+
init(gold: Int, silver: Int, bronze: Int) {
68+
self.init()
69+
self.gold = gold
70+
self.silver = silver
71+
self.bronze = bronze
72+
}
73+
}

0 commit comments

Comments
 (0)