mirror of
https://github.com/carey314/mio-plugin-fund.git
synced 2026-08-10 05:44:31 +00:00
Merge pull request #1 from carey314/feat/v0.3.0-toast-confirm-feedback
v0.3.0: Toast + ConfirmDialog feedback + 2 P0 fixes
This commit is contained in:
commit
e44b816318
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.0</string>
|
<string>0.3.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>4</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>FundPlugin.FundPlugin</string>
|
<string>FundPlugin.FundPlugin</string>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -13,7 +13,7 @@ final class FundPlugin: NSObject, MioPlugin {
|
|||||||
var id: String { "fund" }
|
var id: String { "fund" }
|
||||||
var name: String { "看盘侠" }
|
var name: String { "看盘侠" }
|
||||||
var icon: String { "chart.line.uptrend.xyaxis" }
|
var icon: String { "chart.line.uptrend.xyaxis" }
|
||||||
var version: String { "0.2.0" }
|
var version: String { "0.3.0" }
|
||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
FundDebugLog.write("plugin activate")
|
FundDebugLog.write("plugin activate")
|
||||||
|
|||||||
@ -113,6 +113,13 @@ struct AddView: View {
|
|||||||
if !store.watchlist.contains(hit.code) {
|
if !store.watchlist.contains(hit.code) {
|
||||||
store.watchlist.add(hit)
|
store.watchlist.add(hit)
|
||||||
Task { await store.refreshNow() }
|
Task { await store.refreshNow() }
|
||||||
|
// Operation feedback so the user knows the
|
||||||
|
// tap landed — without this the only signal
|
||||||
|
// is the row's checkmark state flipping,
|
||||||
|
// which is easy to miss when scrolling.
|
||||||
|
ToastController.shared.success("已添加「\(hit.name)」到自选")
|
||||||
|
} else {
|
||||||
|
ToastController.shared.info("「\(hit.name)」已在自选中")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,7 +136,11 @@ struct AddView: View {
|
|||||||
do {
|
do {
|
||||||
hits = try await FundClient.shared.search(q)
|
hits = try await FundClient.shared.search(q)
|
||||||
} catch {
|
} catch {
|
||||||
|
// P0 fix (2026-05-19 review): silent swallow used to make
|
||||||
|
// a 15s network timeout look like "no funds matched". Surface
|
||||||
|
// the actual failure so users know it's network, not empty.
|
||||||
hits = []
|
hits = []
|
||||||
|
ToastController.shared.error("搜索失败,请检查网络后重试")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
178
Sources/ui/ConfirmDialog.swift
Normal file
178
Sources/ui/ConfirmDialog.swift
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
//
|
||||||
|
// ConfirmDialog.swift
|
||||||
|
// 盯基金 plugin v0.3
|
||||||
|
//
|
||||||
|
// Programmatic confirm modal — `await ConfirmController.shared.ask(...)`
|
||||||
|
// returns true / false. Modeled after the web's `window.confirm` so
|
||||||
|
// callers don't have to thread modal state through their views.
|
||||||
|
//
|
||||||
|
// Implementation: single in-flight confirm at a time (rare enough that
|
||||||
|
// serializing is fine). The async result is delivered via a
|
||||||
|
// CheckedContinuation captured in the request.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConfirmRequest: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let message: String?
|
||||||
|
let confirmLabel: String
|
||||||
|
let cancelLabel: String
|
||||||
|
let danger: Bool
|
||||||
|
let continuation: CheckedContinuation<Bool, Never>
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConfirmController: ObservableObject {
|
||||||
|
static let shared = ConfirmController()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@Published var pending: ConfirmRequest? = nil
|
||||||
|
|
||||||
|
/// Returns once the user picks. Resolves false if dismissed by
|
||||||
|
/// tapping the backdrop or pressing Esc-equivalent (cancel button).
|
||||||
|
func ask(
|
||||||
|
title: String,
|
||||||
|
message: String? = nil,
|
||||||
|
confirmLabel: String = "确认",
|
||||||
|
cancelLabel: String = "取消",
|
||||||
|
danger: Bool = false
|
||||||
|
) async -> Bool {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
|
let req = ConfirmRequest(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
confirmLabel: confirmLabel,
|
||||||
|
cancelLabel: cancelLabel,
|
||||||
|
danger: danger,
|
||||||
|
continuation: cont
|
||||||
|
)
|
||||||
|
// If a confirm is already showing (shouldn't normally happen),
|
||||||
|
// resolve the previous one as cancelled and replace it. This
|
||||||
|
// prevents the continuation from leaking.
|
||||||
|
if let old = pending {
|
||||||
|
old.continuation.resume(returning: false)
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
|
||||||
|
pending = req
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(_ value: Bool) {
|
||||||
|
guard let req = pending else { return }
|
||||||
|
req.continuation.resume(returning: value)
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
pending = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overlay view
|
||||||
|
|
||||||
|
struct ConfirmOverlay: View {
|
||||||
|
@ObservedObject var controller = ConfirmController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let req = controller.pending {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture { controller.respond(false) }
|
||||||
|
ConfirmCard(req: req)
|
||||||
|
.transition(.scale(scale: 0.94).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConfirmCard: View {
|
||||||
|
let req: ConfirmRequest
|
||||||
|
@State private var hoveredCancel = false
|
||||||
|
@State private var hoveredConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill((req.danger ? Color.red : FundTheme.lime).opacity(0.18))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: req.danger ? "exclamationmark.triangle.fill" : "questionmark.circle.fill")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(req.danger ? Color.red : FundTheme.lime)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(req.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
if let msg = req.message {
|
||||||
|
Text(msg)
|
||||||
|
.font(.system(size: 11.5))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Spacer()
|
||||||
|
Button(action: { ConfirmController.shared.respond(false) }) {
|
||||||
|
Text(req.cancelLabel)
|
||||||
|
.font(.system(size: 11.5, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(hoveredCancel ? FundTheme.overlay06 : FundTheme.overlay04)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onHover { hoveredCancel = $0 }
|
||||||
|
|
||||||
|
Button(action: { ConfirmController.shared.respond(true) }) {
|
||||||
|
Text(req.confirmLabel)
|
||||||
|
.font(.system(size: 11.5, weight: .semibold))
|
||||||
|
.foregroundColor(req.danger ? .white : .black)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(
|
||||||
|
req.danger
|
||||||
|
? Color(red: 0.92, green: 0.30, blue: 0.30).opacity(hoveredConfirm ? 1.0 : 0.88)
|
||||||
|
: FundTheme.lime.opacity(hoveredConfirm ? 1.0 : 0.92)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onHover { hoveredConfirm = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color(red: 0.07, green: 0.07, blue: 0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color.white.opacity(0.06), lineWidth: 0.8)
|
||||||
|
)
|
||||||
|
.shadow(color: Color.black.opacity(0.6), radius: 16, x: 0, y: 8)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View modifier
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Attach the confirm dialog layer. Call once at the root of the
|
||||||
|
/// plugin's view tree (ExpandedView).
|
||||||
|
func confirmOverlay() -> some View {
|
||||||
|
self.overlay(ConfirmOverlay())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,6 +54,12 @@ struct ExpandedView: View {
|
|||||||
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
|
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// Mount the toast + confirm layers at the plugin root so any
|
||||||
|
// view in the tree can call ToastController.shared.success(...)
|
||||||
|
// or `await ConfirmController.shared.ask(...)` without threading
|
||||||
|
// state through props.
|
||||||
|
.toastOverlay()
|
||||||
|
.confirmOverlay()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
store.start()
|
store.start()
|
||||||
Task { await store.refreshNow() }
|
Task { await store.refreshNow() }
|
||||||
|
|||||||
@ -74,8 +74,25 @@ struct HoldingsView: View {
|
|||||||
editingCode = (editingCode == f.code) ? nil : f.code
|
editingCode = (editingCode == f.code) ? nil : f.code
|
||||||
},
|
},
|
||||||
onRemove: {
|
onRemove: {
|
||||||
if editingCode == f.code { editingCode = nil }
|
Task { @MainActor in
|
||||||
store.watchlist.remove(code: f.code)
|
// Danger confirm before destructive remove.
|
||||||
|
// The user's only "trash" affordance is the
|
||||||
|
// hover-only ✕ button so a wrong click is
|
||||||
|
// possible — surface a clear ack-or-bail.
|
||||||
|
let ok = await ConfirmController.shared.ask(
|
||||||
|
title: "移除自选基金?",
|
||||||
|
message: "「\(f.name)」(\(f.code)) 将从自选中移除,已填写的持仓份额与成本也会一并删除。",
|
||||||
|
confirmLabel: "移除",
|
||||||
|
cancelLabel: "取消",
|
||||||
|
danger: true
|
||||||
|
)
|
||||||
|
guard ok else { return }
|
||||||
|
if editingCode == f.code { editingCode = nil }
|
||||||
|
withAnimation(.spring(response: 0.38, dampingFraction: 0.85)) {
|
||||||
|
store.watchlist.remove(code: f.code)
|
||||||
|
}
|
||||||
|
ToastController.shared.success("已移除「\(f.name)」")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if editingCode == f.code {
|
if editingCode == f.code {
|
||||||
@ -86,16 +103,33 @@ struct HoldingsView: View {
|
|||||||
code: f.code, shares: shares, costNav: cost
|
code: f.code, shares: shares, costNav: cost
|
||||||
)
|
)
|
||||||
editingCode = nil
|
editingCode = nil
|
||||||
|
// Bias the message toward what the user
|
||||||
|
// just did — saving with values is "已保存
|
||||||
|
// 持仓",saving with both cleared is "已清
|
||||||
|
// 除持仓"。Either path lands here so we
|
||||||
|
// disambiguate by inputs.
|
||||||
|
if shares == nil && cost == nil {
|
||||||
|
ToastController.shared.info("已清除「\(f.name)」的持仓")
|
||||||
|
} else {
|
||||||
|
ToastController.shared.success("已保存「\(f.name)」的持仓")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCancel: { editingCode = nil }
|
onCancel: { editingCode = nil }
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale(scale: 0.96).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .leading).combined(with: .opacity)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
.animation(.spring(response: 0.38, dampingFraction: 0.85), value: store.watchlist.funds.map(\.code))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: editingCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
Sources/ui/Toast.swift
Normal file
151
Sources/ui/Toast.swift
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
//
|
||||||
|
// Toast.swift
|
||||||
|
// 盯基金 plugin v0.3
|
||||||
|
//
|
||||||
|
// Lightweight in-panel toast for operation feedback. Sits as an
|
||||||
|
// overlay on top of ExpandedView so it floats above the active tab.
|
||||||
|
// Auto-dismisses after ~2.6s, manually dismissible by click.
|
||||||
|
//
|
||||||
|
// Design: matches the rest of the plugin — lime accent for success,
|
||||||
|
// red for error, muted yellow for info. SF Symbol leading icon.
|
||||||
|
// Slides in from the top with a spring, fades out on exit.
|
||||||
|
//
|
||||||
|
// Usage from anywhere inside the plugin tree:
|
||||||
|
// ToastController.shared.success("已添加 易方达蓝筹")
|
||||||
|
// ToastController.shared.error("加载失败,请重试")
|
||||||
|
// ToastController.shared.info("数据已刷新")
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ToastKind {
|
||||||
|
case success
|
||||||
|
case error
|
||||||
|
case info
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .error: return "exclamationmark.triangle.fill"
|
||||||
|
case .info: return "info.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .success: return FundTheme.lime
|
||||||
|
case .error: return Color(red: 1.0, green: 0.34, blue: 0.34)
|
||||||
|
case .info: return Color(red: 1.0, green: 0.72, blue: 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToastItem: Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
let kind: ToastKind
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ToastController: ObservableObject {
|
||||||
|
static let shared = ToastController()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@Published var toasts: [ToastItem] = []
|
||||||
|
|
||||||
|
private let durationSeconds: Double = 2.6
|
||||||
|
|
||||||
|
func show(_ kind: ToastKind, _ message: String) {
|
||||||
|
let item = ToastItem(id: UUID(), kind: kind, message: message)
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.78)) {
|
||||||
|
toasts.append(item)
|
||||||
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
// BUG fix (2026-05-19 review): `UInt64(2.6)` truncates to 2,
|
||||||
|
// so the previous `UInt64(dur) * 1_000_000_000` slept 2.0s
|
||||||
|
// instead of 2.6s — every toast vanished 0.6s early. Do the
|
||||||
|
// multiply first, then cast.
|
||||||
|
let dur = self?.durationSeconds ?? 2.6
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(dur * 1_000_000_000))
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.dismiss(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func success(_ message: String) { show(.success, message) }
|
||||||
|
func error(_ message: String) { show(.error, message) }
|
||||||
|
func info(_ message: String) { show(.info, message) }
|
||||||
|
|
||||||
|
func dismiss(_ id: UUID) {
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
toasts.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overlay view
|
||||||
|
|
||||||
|
struct ToastOverlay: View {
|
||||||
|
@ObservedObject var controller = ToastController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(controller.toasts) { toast in
|
||||||
|
ToastRow(item: toast, onDismiss: { controller.dismiss(toast.id) })
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .top).combined(with: .opacity).combined(with: .scale(scale: 0.96)),
|
||||||
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 48)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.allowsHitTesting(!controller.toasts.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToastRow: View {
|
||||||
|
let item: ToastItem
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: item.kind.symbol)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(item.kind.tint)
|
||||||
|
Text(item.message)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.black.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(item.kind.tint.opacity(0.32), lineWidth: 0.8)
|
||||||
|
)
|
||||||
|
.shadow(color: item.kind.tint.opacity(0.18), radius: 8, x: 0, y: 4)
|
||||||
|
)
|
||||||
|
.onTapGesture { onDismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View modifier
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Attach the toast layer to a container. Call once at the root of
|
||||||
|
/// the plugin's view tree (ExpandedView) — `ToastController.shared`
|
||||||
|
/// is a singleton so any view in the tree can push toasts without
|
||||||
|
/// further wiring.
|
||||||
|
func toastOverlay() -> some View {
|
||||||
|
self.overlay(ToastOverlay())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user