001project_wildgrowth/ios/WildGrowth/COMPLETION_迭代_完整代码.md

11 KiB
Raw Blame History

完成页迭代 — 完整代码(仅交付,禁止自动应用)

以下为审查修正后的完整代码。关键修正:底部「回到我的内容」改为 navStore.switchToGrowthTab(),不再使用 navigationPath = NavigationPath(),以保证从任意 Tab 进入都能回到技能页 Tab。


1. CompletionView.swift整文件替换

import SwiftUI

// MARK: - 🏆 课程完结页 (Gesture Flow Edition)
// 交互:左滑进入(上级控制),右滑返回(系统原生),底部点击回技能页 Tab
struct CompletionView: View {
    let courseId: String
    let courseTitle: String?
    let completedLessonCount: Int

    @EnvironmentObject private var navStore: NavigationStore
    @Environment(\.dismiss) private var dismiss

    @State private var isSealed = false
    @State private var breathingOpacity: Double = 0.3
    @State private var contentOpacity: Double = 0

    var body: some View {
        ZStack {
            Color.bgPaper.ignoresSafeArea()

            VStack(spacing: 0) {
                Spacer().frame(height: 60)

                Spacer()

                ZStack {
                    Circle()
                        .strokeBorder(
                            isSealed ?
                            AnyShapeStyle(
                                LinearGradient(
                                    colors: [Color.brandVital, Color.cyberIris],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                )
                            ) :
                            AnyShapeStyle(Color.inkSecondary.opacity(0.3)),
                            style: StrokeStyle(lineWidth: isSealed ? 4 : 1, dash: isSealed ? [] : [5, 5])
                        )
                        .frame(width: 220, height: 220)
                        .shadow(
                            color: isSealed ? Color.brandVital.opacity(0.4) : .clear,
                            radius: 20, x: 0, y: 0
                        )
                        .scaleEffect(isSealed ? 1.0 : 0.95)
                        .opacity(isSealed ? 1.0 : breathingOpacity)
                        .animation(.easeInOut(duration: 0.5), value: isSealed)

                    if isSealed {
                        VStack(spacing: 8) {
                            Text("已完成的")
                                .font(.system(size: 16, weight: .medium))
                                .foregroundColor(.inkSecondary)

                            Text("第 \(completedLessonCount) 节")
                                .font(.system(size: 36, weight: .bold, design: .serif))
                                .foregroundStyle(
                                    LinearGradient(
                                        colors: [Color.brandVital, Color.cyberIris],
                                        startPoint: .topLeading,
                                        endPoint: .bottomTrailing
                                    )
                                )
                        }
                        .transition(.scale(scale: 0.5).combined(with: .opacity))
                    } else {
                        Text("完")
                            .font(.system(size: 80, weight: .light, design: .serif))
                            .foregroundColor(.inkPrimary.opacity(0.2))
                            .opacity(breathingOpacity)
                    }
                }
                .contentShape(Circle())
                .onTapGesture {
                    triggerInteraction()
                }

                Spacer()

                Button {
                    handleReturnToRoot()
                } label: {
                    HStack(spacing: 4) {
                        Text("回到我的内容")
                            .font(.system(size: 15, weight: .medium))
                        Image(systemName: "arrow.right")
                            .font(.system(size: 12))
                    }
                    .foregroundColor(.brandVital)
                    .padding(.vertical, 12)
                    .padding(.horizontal, 32)
                    .background(Capsule().fill(Color.brandVital.opacity(0.05)))
                }
                .opacity(contentOpacity)
                .padding(.bottom, 80)
            }
        }
        .toolbar(.hidden, for: .navigationBar)
        .modifier(SwipeBackEnablerModifier())
        .onAppear {
            startBreathing()
        }
    }

    private func startBreathing() {
        withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
            breathingOpacity = 0.8
        }
    }

    private func triggerInteraction() {
        guard !isSealed else { return }
        let generator = UIImpactFeedbackGenerator(style: .heavy)
        generator.impactOccurred()
        withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
            isSealed = true
        }
        withAnimation(.easeOut.delay(0.5)) {
            contentOpacity = 1.0
        }
    }

    /// 回到技能页 Tab 根目录(与当前「继续学习」一致)
    private func handleReturnToRoot() {
        navStore.switchToGrowthTab()
    }
}

// MARK: - 侧滑返回:隐藏导航栏时仍可右滑 pop
struct SwipeBackEnablerModifier: ViewModifier {
    func body(content: Content) -> some View {
        content.background(SwipeBackEnabler())
    }
}

private struct SwipeBackEnabler: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController { UIViewController() }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        DispatchQueue.main.async {
            if let nc = uiViewController.navigationController {
                nc.interactivePopGestureRecognizer?.delegate = nil
                nc.interactivePopGestureRecognizer?.isEnabled = true
            }
        }
    }
}

2. VerticalScreenPlayerView.swift仅改动部分

2.1 保持现有 init 与属性

  • navigationPath: Binding<NavigationPath>? 保持可选NoteTreeView / NoteListView 不传时仍不 push 完成页。
  • isLastNode, courseTitle 等保持不变。

2.2 替换「加载成功」分支:去掉占位页,仅用左滑手势 push

原逻辑(约 146186 行):
TabViewForEach(allCourseNodes) + CompletionPlaceholderPage().tag("wg://completion"),外加 .onChange(of: currentNodeId)newId == "wg://completion" 时 append completion。

新逻辑

  • TabView ForEach(allCourseNodes),每页在最后一节上挂 LastPageSwipeModifier,左滑时调用 triggerCompletionNavigation
  • 删除 CompletionPlaceholderPage 及其 tag、删除 .onChange(of: currentNodeId) 中与 "wg://completion" 相关的逻辑。
  • 新增 @State private var isNavigatingToCompletion = false(防抖),以及 triggerCompletionNavigation()LastPageSwipeModifier

替换后的内容区代码(直接替换原 else if !allCourseNodes.isEmpty { ... } 整块):

} else if !allCourseNodes.isEmpty {
    TabView(selection: $currentNodeId) {
        ForEach(allCourseNodes, id: \.id) { node in
            let isFirst = isFirstNodeInChapter(nodeId: node.id)
            let chapterTitle = getChapterTitle(for: node.id)

            LessonPageView(
                courseId: courseId,
                nodeId: node.id,
                currentGlobalNodeId: $currentNodeId,
                initialScrollIndex: node.id == initialNodeId ? initialScrollIndex : nil,
                headerConfig: HeaderConfig(
                    showChapterTitle: isFirst,
                    chapterTitle: chapterTitle
                ),
                courseTitle: courseTitle,
                navigationPath: navigationPath
            )
            .tag(node.id)
            .modifier(LastPageSwipeModifier(
                isLastPage: node.id == allCourseNodes.last?.id,
                onSwipeLeft: triggerCompletionNavigation
            ))
        }
    }
    .tabViewStyle(.page(indexDisplayMode: .never))
    .ignoresSafeArea(.container, edges: .bottom)
}

2.3 新增状态与函数(放在 Logic Helpers 区域)

@State private var isNavigatingToCompletion = false

private func triggerCompletionNavigation() {
    guard !isNavigatingToCompletion, let path = navigationPath else { return }
    isNavigatingToCompletion = true

    let generator = UIImpactFeedbackGenerator(style: .medium)
    generator.impactOccurred()

    let count = UserManager.shared.studyStats.lessons
    path.wrappedValue.append(CourseNavigation.completion(
        courseId: courseId,
        courseTitle: courseTitle,
        completedLessonCount: count
    ))

    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        isNavigatingToCompletion = false
    }
}

2.4 删除 onAppear 中「从完成页返回切回最后一节」逻辑

删除

if currentNodeId == "wg://completion", let lastId = allCourseNodes.last?.id {
    currentNodeId = lastId
}

(去掉占位页后不再存在 "wg://completion" 选中的情况。)

2.5 删除 CompletionPlaceholderPage新增 LastPageSwipeModifier

删除 CompletionPlaceholderPage 结构体。

// MARK: - 📄 单页课程视图 之前 新增:

// MARK: - 最后一页左滑:仅在最后一节挂载,左滑 push 完成页
private struct LastPageSwipeModifier: ViewModifier {
    let isLastPage: Bool
    let onSwipeLeft: () -> Void

    func body(content: Content) -> some View {
        content
            .simultaneousGesture(
                DragGesture(minimumDistance: 20, coordinateSpace: .local)
                    .onEnded { value in
                        guard isLastPage else { return }
                        if value.translation.width < -60,
                           abs(value.translation.width) > abs(value.translation.height) {
                            onSwipeLeft()
                        }
                    }
            )
    }
}

3. 调用方GrowthView / ProfileView / DiscoveryView

无需改动。CompletionView 仍为三参:courseId, courseTitle, completedLessonCount,依赖 @EnvironmentObject navStore,底部用 switchToGrowthTab() 回到技能页 Tab。


4. 小结

  • CompletionView:无顶部返回、无打字机;赛博印章交互;右滑依赖 SwipeBackEnablerModifier;底部「回到我的内容」= navStore.switchToGrowthTab()
  • VerticalScreenPlayerView:去掉占位页与 onChange 完成页逻辑;最后一节左滑用 LastPageSwipeModifier + triggerCompletionNavigation push 完成页;保留可选 navigationPath,笔记流不变。
  • 不修改 GrowthView / ProfileView / DiscoveryView 的 CompletionView(...) 调用。