v1.2.3: Add holdings group option to pin held stocks at top

- New setting: "持仓股单独分组置顶" toggle
- When enabled, stocks with holdings are grouped into a "持仓" section at top
- Remaining non-held stocks still grouped by A/HK/US market as usual
- StockGroupView supports custom title for holdings group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vgearen
2026-03-24 09:58:30 +08:00
co-authored by Claude Opus 4.6
parent d898185745
commit 7e502232d3
5 changed files with 60 additions and 22 deletions
+3 -1
View File
@@ -65,6 +65,7 @@ struct AppSettings {
var displayCurrency: DisplayCurrency = .cny
var sortRule: SortRule = .changeDesc
var usPriceMode: USPriceMode = .sessionPrice
var groupHoldings: Bool = false
static let validRefreshIntervals = [3, 5, 10, 30]
@@ -75,7 +76,7 @@ struct AppSettings {
// MARK: - Codable(容错:缺失字段使用默认值)
extension AppSettings: Codable {
enum CodingKeys: String, CodingKey {
case statusBarStockId, refreshInterval, colorScheme, displayCurrency, sortRule, usPriceMode
case statusBarStockId, refreshInterval, colorScheme, displayCurrency, sortRule, usPriceMode, groupHoldings
}
init(from decoder: Decoder) throws {
@@ -86,5 +87,6 @@ extension AppSettings: Codable {
displayCurrency = (try? c.decodeIfPresent(DisplayCurrency.self, forKey: .displayCurrency)) ?? .cny
sortRule = (try? c.decodeIfPresent(SortRule.self, forKey: .sortRule)) ?? .changeDesc
usPriceMode = (try? c.decodeIfPresent(USPriceMode.self, forKey: .usPriceMode)) ?? .sessionPrice
groupHoldings = (try? c.decodeIfPresent(Bool.self, forKey: .groupHoldings)) ?? false
}
}
+43 -16
View File
@@ -7,22 +7,48 @@ struct DropdownView: View {
@State private var selectedStock: Stock? = nil
@State private var lastSelectedStockId: String? = nil
private var groupedStocks: [(Market, [Stock])] {
[Market.aStock, .hkStock, .usStock].compactMap { market in
var s = appState.stocks.filter { $0.market == market }
if sortByChange {
let rule = appState.config.sortRule
let usMode = appState.config.usPriceMode
s.sort { a, b in
let qa = appState.quotes[a.id]
let qb = appState.quotes[b.id]
let va = sortValue(quote: qa, market: market, mode: usMode)
let vb = sortValue(quote: qb, market: market, mode: usMode)
return rule == .changeDesc ? va > vb : va < vb
}
/// 分组数据:(market, stocks, customTitle?)
private var groupedStocks: [(Market, [Stock], String?)] {
let rule = appState.config.sortRule
let usMode = appState.config.usPriceMode
let doSort = sortByChange
func sorted(_ stocks: [Stock], market: Market) -> [Stock] {
guard doSort else { return stocks }
return stocks.sorted { a, b in
let va = sortValue(quote: appState.quotes[a.id], market: market, mode: usMode)
let vb = sortValue(quote: appState.quotes[b.id], market: market, mode: usMode)
return rule == .changeDesc ? va > vb : va < vb
}
return s.isEmpty ? nil : (market, s)
}
var result: [(Market, [Stock], String?)] = []
// 持仓股单独分组
if appState.config.groupHoldings {
let holdings = appState.stocks.filter { $0.holdingShares != nil }
if !holdings.isEmpty {
// 持仓组内按第一只股票的市场类型排序(混合市场)
let sortedHoldings = doSort ? holdings.sorted { a, b in
let va = sortValue(quote: appState.quotes[a.id], market: a.market, mode: usMode)
let vb = sortValue(quote: appState.quotes[b.id], market: b.market, mode: usMode)
return rule == .changeDesc ? va > vb : va < vb
} : holdings
result.append((.aStock, sortedHoldings, "持仓"))
}
let holdingIds = Set(holdings.map(\.id))
for market in [Market.aStock, .hkStock, .usStock] {
let s = appState.stocks.filter { $0.market == market && !holdingIds.contains($0.id) }
if !s.isEmpty { result.append((market, sorted(s, market: market), nil)) }
}
} else {
for market in [Market.aStock, .hkStock, .usStock] {
let s = appState.stocks.filter { $0.market == market }
if !s.isEmpty { result.append((market, sorted(s, market: market), nil)) }
}
}
return result
}
private func sortValue(quote: Quote?, market: Market, mode: USPriceMode) -> Double {
@@ -53,8 +79,9 @@ struct DropdownView: View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 6) {
ForEach(groupedStocks, id: \.0) { market, stocks in
StockGroupView(market: market, stocks: stocks,
ForEach(Array(groupedStocks.enumerated()), id: \.offset) { _, group in
StockGroupView(market: group.0, stocks: group.1,
title: group.2,
onSelect: {
lastSelectedStockId = $0.id
selectedStock = $0
+8
View File
@@ -54,6 +54,14 @@ struct SettingsView: View {
}.pickerStyle(.menu)
}
// 持仓股分组
section("持仓股展示") {
Toggle("持仓股单独分组置顶", isOn: Binding(
get: { appState.config.groupHoldings },
set: { appState.config.groupHoldings = $0 }
))
}
// 排序规则
section("排序规则") {
Picker("", selection: Binding(
+2 -1
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct StockGroupView: View {
let market: Market
let stocks: [Stock]
var title: String? = nil
var onSelect: ((Stock) -> Void)? = nil
@EnvironmentObject var appState: AppState
@State private var isExpanded = true
@@ -15,7 +16,7 @@ struct StockGroupView: View {
HStack(spacing: 4) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.system(size: 10, weight: .medium))
Text(market.rawValue)
Text(title ?? market.rawValue)
.font(.system(size: 11, weight: .semibold))
Text("(\(stocks.count))")
.font(.system(size: 11))