浏览代码

LotteryTracker v1.0

afan 1周前
父节点
当前提交
75ab7a317b
共有 33 个文件被更改,包括 3730 次插入173 次删除
  1. 3
    3
      LotteryTracker.xcodeproj/project.pbxproj
  2. 5
    0
      LotteryTracker.xcodeproj/project.xcworkspace/xcuserdata/aaa.xcuserdatad/IDEFindNavigatorScopes.plist
  3. 68
    0
      LotteryTracker/App/AppState.swift
  4. 87
    0
      LotteryTracker/App/LotteryTrackerApp.swift
  5. 71
    0
      LotteryTracker/App/PersistenceController.swift
  6. 0
    86
      LotteryTracker/ContentView.swift
  7. 34
    0
      LotteryTracker/Extensions/Color+Extensions.swift
  8. 7
    0
      LotteryTracker/Extensions/Date+Extensions.swift
  9. 45
    0
      LotteryTracker/Extensions/View+Extensions.swift
  10. 11
    6
      LotteryTracker/LotteryTracker.xcdatamodeld/LotteryTracker.xcdatamodel/contents
  11. 0
    21
      LotteryTracker/LotteryTrackerApp.swift
  12. 96
    0
      LotteryTracker/Models/LotteryTicket+Extensions.swift
  13. 0
    57
      LotteryTracker/Persistence.swift
  14. 119
    0
      LotteryTracker/Services/DemoDataGenerator.swift
  15. 196
    0
      LotteryTracker/Services/LotteryChecker.swift
  16. 328
    0
      LotteryTracker/Services/StatisticsService.swift
  17. 48
    0
      LotteryTracker/Utils/DataCleaner.swift
  18. 45
    0
      LotteryTracker/Utils/Formatters.swift
  19. 81
    0
      LotteryTracker/Utils/PerformanceMonitor.swift
  20. 194
    0
      LotteryTracker/ViewModels/AddTicketViewModel.swift
  21. 77
    0
      LotteryTracker/ViewModels/HomeViewModel.swift
  22. 348
    0
      LotteryTracker/Views/AddTicket/AddTicketView.swift
  23. 212
    0
      LotteryTracker/Views/AddTicket/AmountInputView.swift
  24. 149
    0
      LotteryTracker/Views/AddTicket/LotteryTypePicker.swift
  25. 61
    0
      LotteryTracker/Views/Common/LoadingView.swift
  26. 167
    0
      LotteryTracker/Views/Common/OnboardingView.swift
  27. 85
    0
      LotteryTracker/Views/Home/EmptyStateView.swift
  28. 159
    0
      LotteryTracker/Views/Home/HomeView.swift
  29. 134
    0
      LotteryTracker/Views/Home/StatsCard.swift
  30. 168
    0
      LotteryTracker/Views/Home/TicketRow.swift
  31. 51
    0
      LotteryTracker/Views/My/MyView.swift
  32. 279
    0
      LotteryTracker/Views/My/SettingsView.swift
  33. 402
    0
      LotteryTracker/Views/My/StatisticsView.swift

+ 3
- 3
LotteryTracker.xcodeproj/project.pbxproj 查看文件

@@ -11,7 +11,7 @@
11 11
 /* End PBXFileReference section */
12 12
 
13 13
 /* Begin PBXFileSystemSynchronizedRootGroup section */
14
-		00CDA5D82F212364003E59B4 /* LotteryTracker */ = {
14
+		00CDA6172F212DA5003E59B4 /* LotteryTracker */ = {
15 15
 			isa = PBXFileSystemSynchronizedRootGroup;
16 16
 			path = LotteryTracker;
17 17
 			sourceTree = "<group>";
@@ -32,8 +32,8 @@
32 32
 		00CDA5CD2F212364003E59B4 = {
33 33
 			isa = PBXGroup;
34 34
 			children = (
35
-				00CDA5D82F212364003E59B4 /* LotteryTracker */,
36 35
 				00CDA5D72F212364003E59B4 /* Products */,
36
+				00CDA6172F212DA5003E59B4 /* LotteryTracker */,
37 37
 			);
38 38
 			sourceTree = "<group>";
39 39
 		};
@@ -61,7 +61,7 @@
61 61
 			dependencies = (
62 62
 			);
63 63
 			fileSystemSynchronizedGroups = (
64
-				00CDA5D82F212364003E59B4 /* LotteryTracker */,
64
+				00CDA6172F212DA5003E59B4 /* LotteryTracker */,
65 65
 			);
66 66
 			name = LotteryTracker;
67 67
 			packageProductDependencies = (

+ 5
- 0
LotteryTracker.xcodeproj/project.xcworkspace/xcuserdata/aaa.xcuserdatad/IDEFindNavigatorScopes.plist 查看文件

@@ -0,0 +1,5 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<array/>
5
+</plist>

+ 68
- 0
LotteryTracker/App/AppState.swift 查看文件

@@ -0,0 +1,68 @@
1
+//
2
+//  AppState.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+import SwiftUI
10
+import Combine
11
+
12
+class AppState: ObservableObject {
13
+    // 应用状态
14
+    @Published var isFirstLaunch: Bool
15
+    @Published var selectedTab: Tab = .home
16
+    @Published var showingOnboarding: Bool = false
17
+    
18
+    // 用户设置
19
+    @AppStorage("enableNotifications") var enableNotifications: Bool = true
20
+    @AppStorage("autoCheckDraw") var autoCheckDraw: Bool = true
21
+    @AppStorage("currencySymbol") var currencySymbol: String = "¥"
22
+    @AppStorage("themeColor") var themeColor: String = "blue"
23
+    
24
+    init() {
25
+        // 检查是否是第一次启动
26
+        let hasLaunchedBefore = UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
27
+        let firstLaunch = !hasLaunchedBefore
28
+        
29
+        // 初始化存储属性
30
+        self.isFirstLaunch = firstLaunch
31
+        
32
+        // 设置引导页显示状态
33
+        if firstLaunch {
34
+            UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
35
+            self.showingOnboarding = true
36
+        } else {
37
+            self.showingOnboarding = false
38
+        }
39
+        
40
+        print("🚀 AppState 初始化完成")
41
+    }
42
+    
43
+    // 重置应用状态
44
+    func resetApp() {
45
+        if let bundleID = Bundle.main.bundleIdentifier {
46
+            UserDefaults.standard.removePersistentDomain(forName: bundleID)
47
+        }
48
+        
49
+        isFirstLaunch = true
50
+        showingOnboarding = true
51
+        selectedTab = .home
52
+        
53
+        print("🔄 应用状态已重置")
54
+    }
55
+}
56
+
57
+// Tab枚举
58
+enum Tab: Int {
59
+    case home = 0
60
+    case my = 1
61
+    
62
+    var title: String {
63
+        switch self {
64
+        case .home: return "首页"
65
+        case .my: return "我的"
66
+        }
67
+    }
68
+}

+ 87
- 0
LotteryTracker/App/LotteryTrackerApp.swift 查看文件

@@ -0,0 +1,87 @@
1
+import SwiftUI
2
+internal import CoreData
3
+
4
+@main
5
+struct LotteryTrackerApp: App {
6
+    @StateObject private var persistenceController = PersistenceController.shared
7
+    @StateObject private var appState = AppState()
8
+    
9
+    // 应用生命周期
10
+    @Environment(\.scenePhase) private var scenePhase
11
+    
12
+    var body: some Scene {
13
+        WindowGroup {
14
+            ContentView()
15
+                .environment(\.managedObjectContext, persistenceController.container.viewContext)
16
+                .environmentObject(appState)
17
+                .onAppear {
18
+                    setupAppearance()
19
+                    // App启动时检查一次
20
+                    if appState.autoCheckDraw {
21
+                        checkDraws()
22
+                    }
23
+                }
24
+                .onChange(of: scenePhase) { oldPhase, newPhase in
25
+                    // 只记录,不修改任何状态
26
+                    switch newPhase {
27
+                    case .active:
28
+                        print("App变为活跃状态")
29
+                        if appState.autoCheckDraw {
30
+                            checkDraws()
31
+                        }
32
+                    case .inactive:
33
+                        print("App变为不活跃状态")
34
+                    case .background:
35
+                        print("App进入后台")
36
+                    @unknown default:
37
+                        break
38
+                    }
39
+                }
40
+                .sheet(isPresented: $appState.showingOnboarding) {
41
+                    OnboardingView()
42
+                }
43
+        }
44
+    }
45
+    
46
+    // 设置外观
47
+    private func setupAppearance() {
48
+        let tabBarAppearance = UITabBarAppearance()
49
+        tabBarAppearance.configureWithOpaqueBackground()
50
+        UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance
51
+        UITabBar.appearance().standardAppearance = tabBarAppearance
52
+        
53
+        let navigationBarAppearance = UINavigationBarAppearance()
54
+        navigationBarAppearance.configureWithOpaqueBackground()
55
+        UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
56
+        UINavigationBar.appearance().standardAppearance = navigationBarAppearance
57
+    }
58
+    
59
+    // 检查开奖
60
+    private func checkDraws() {
61
+        print("🔍 检查开奖...")
62
+        let checker = LotteryChecker(context: persistenceController.container.viewContext)
63
+        checker.checkDrawResults()
64
+    }
65
+}
66
+
67
+// 主内容视图
68
+struct ContentView: View {
69
+    @EnvironmentObject private var appState: AppState
70
+    
71
+    var body: some View {
72
+        TabView(selection: $appState.selectedTab) {
73
+            HomeView()
74
+                .tabItem {
75
+                    Label("首页", systemImage: "house.fill")
76
+                }
77
+                .tag(Tab.home)
78
+            
79
+            MyView()
80
+                .tabItem {
81
+                    Label("我的", systemImage: "person.fill")
82
+                }
83
+                .tag(Tab.my)
84
+        }
85
+        .accentColor(.blue)
86
+    }
87
+}

+ 71
- 0
LotteryTracker/App/PersistenceController.swift 查看文件

@@ -0,0 +1,71 @@
1
+import Combine
2
+internal import CoreData
3
+import Foundation
4
+
5
+class PersistenceController: ObservableObject {
6
+    // 单例模式
7
+    static let shared = PersistenceController()
8
+    
9
+    // 预览实例
10
+    static var preview: PersistenceController = {
11
+        let result = PersistenceController(inMemory: true)
12
+        let viewContext = result.container.viewContext
13
+        
14
+        // 添加预览数据
15
+        for i in 0..<10 {
16
+            let newTicket = LotteryTicket(context: viewContext)
17
+            newTicket.id = UUID()
18
+            newTicket.type = i % 2 == 0 ? "双色球" : "大乐透"
19
+            newTicket.numbers = "预览号码 \(i)"
20
+            newTicket.betCount = Int16(i % 3 + 1)
21
+            newTicket.amount = Double(newTicket.betCount) * 2.0
22
+            newTicket.date = Date().addingTimeInterval(Double(-i) * 86400)
23
+            newTicket.drawDate = Date().addingTimeInterval(Double(i) * 86400)
24
+            newTicket.status = i % 3 == 0 ? "待开奖" : (i % 3 == 1 ? "已中奖" : "未中奖")
25
+            newTicket.prizeAmount = newTicket.status == "已中奖" ? newTicket.amount * 5 : 0
26
+        }
27
+        
28
+        do {
29
+            try viewContext.save()
30
+        } catch {
31
+            let nsError = error as NSError
32
+            fatalError("预览数据保存失败 \(nsError), \(nsError.userInfo)")
33
+        }
34
+        
35
+        return result
36
+    }()
37
+    
38
+    // Core Data 容器
39
+    let container: NSPersistentContainer
40
+    
41
+    // 初始化
42
+    init(inMemory: Bool = false) {
43
+        container = NSPersistentContainer(name: "LotteryTracker")
44
+        
45
+        if inMemory {
46
+            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
47
+        }
48
+        
49
+        container.loadPersistentStores { _, error in
50
+            if let error = error {
51
+                print("❌ Core Data 加载失败: \(error)")
52
+            }
53
+        }
54
+        
55
+        container.viewContext.automaticallyMergesChangesFromParent = true
56
+    }
57
+    
58
+    // 保存上下文
59
+    func save() {
60
+        let context = container.viewContext
61
+        
62
+        if context.hasChanges {
63
+            do {
64
+                try context.save()
65
+                print("✅ 数据保存成功")
66
+            } catch {
67
+                print("❌ 数据保存失败: \(error)")
68
+            }
69
+        }
70
+    }
71
+}

+ 0
- 86
LotteryTracker/ContentView.swift 查看文件

@@ -1,86 +0,0 @@
1
-//
2
-//  ContentView.swift
3
-//  LotteryTracker
4
-//
5
-//  Created by aaa on 2026/1/21.
6
-//
7
-
8
-import SwiftUI
9
-import CoreData
10
-
11
-struct ContentView: View {
12
-    @Environment(\.managedObjectContext) private var viewContext
13
-
14
-    @FetchRequest(
15
-        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
16
-        animation: .default)
17
-    private var items: FetchedResults<Item>
18
-
19
-    var body: some View {
20
-        NavigationView {
21
-            List {
22
-                ForEach(items) { item in
23
-                    NavigationLink {
24
-                        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
25
-                    } label: {
26
-                        Text(item.timestamp!, formatter: itemFormatter)
27
-                    }
28
-                }
29
-                .onDelete(perform: deleteItems)
30
-            }
31
-            .toolbar {
32
-                ToolbarItem(placement: .navigationBarTrailing) {
33
-                    EditButton()
34
-                }
35
-                ToolbarItem {
36
-                    Button(action: addItem) {
37
-                        Label("Add Item", systemImage: "plus")
38
-                    }
39
-                }
40
-            }
41
-            Text("Select an item")
42
-        }
43
-    }
44
-
45
-    private func addItem() {
46
-        withAnimation {
47
-            let newItem = Item(context: viewContext)
48
-            newItem.timestamp = Date()
49
-
50
-            do {
51
-                try viewContext.save()
52
-            } catch {
53
-                // Replace this implementation with code to handle the error appropriately.
54
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
55
-                let nsError = error as NSError
56
-                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
57
-            }
58
-        }
59
-    }
60
-
61
-    private func deleteItems(offsets: IndexSet) {
62
-        withAnimation {
63
-            offsets.map { items[$0] }.forEach(viewContext.delete)
64
-
65
-            do {
66
-                try viewContext.save()
67
-            } catch {
68
-                // Replace this implementation with code to handle the error appropriately.
69
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
70
-                let nsError = error as NSError
71
-                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
72
-            }
73
-        }
74
-    }
75
-}
76
-
77
-private let itemFormatter: DateFormatter = {
78
-    let formatter = DateFormatter()
79
-    formatter.dateStyle = .short
80
-    formatter.timeStyle = .medium
81
-    return formatter
82
-}()
83
-
84
-#Preview {
85
-    ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
86
-}

+ 34
- 0
LotteryTracker/Extensions/Color+Extensions.swift 查看文件

@@ -0,0 +1,34 @@
1
+//
2
+//  Color+Extensions.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+extension Color {
11
+    // 应用主题色
12
+    static let theme = ColorTheme()
13
+    
14
+    // 彩票类型颜色
15
+    static func forLotteryType(_ type: LotteryType) -> Color {
16
+        switch type {
17
+        case .doubleColorBall: return .red
18
+        case .superLotto: return .blue
19
+        case .welfare3D: return .green
20
+        case .sevenStar: return .purple
21
+        case .other: return .gray
22
+        }
23
+    }
24
+}
25
+
26
+struct ColorTheme {
27
+    let primary = Color.blue
28
+    let secondary = Color.gray
29
+    let success = Color.green
30
+    let warning = Color.orange
31
+    let danger = Color.red
32
+    let background = Color(.systemBackground)
33
+    let cardBackground = Color(.secondarySystemBackground)
34
+}

+ 7
- 0
LotteryTracker/Extensions/Date+Extensions.swift 查看文件

@@ -0,0 +1,7 @@
1
+//
2
+//  Date+Extensions.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+

+ 45
- 0
LotteryTracker/Extensions/View+Extensions.swift 查看文件

@@ -0,0 +1,45 @@
1
+//
2
+//  View+Extensions.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+extension View {
11
+    // 卡片样式
12
+    func cardStyle() -> some View {
13
+        self
14
+            .padding()
15
+            .background(Color(.systemBackground))
16
+            .cornerRadius(12)
17
+            .shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
18
+    }
19
+    
20
+    // 限制为单行,超出显示...
21
+    func singleLine() -> some View {
22
+        self
23
+            .lineLimit(1)
24
+            .truncationMode(.tail)
25
+    }
26
+    
27
+    // 隐藏列表行分隔符
28
+    func hideRowSeparator() -> some View {
29
+        self
30
+            .listRowSeparator(.hidden)
31
+            .listRowInsets(EdgeInsets())
32
+    }
33
+    
34
+    // 加载中状态
35
+    func loading(_ isLoading: Bool) -> some View {
36
+        self
37
+            .overlay {
38
+                if isLoading {
39
+                    ProgressView()
40
+                        .frame(maxWidth: .infinity, maxHeight: .infinity)
41
+                        .background(Color.black.opacity(0.1))
42
+                }
43
+            }
44
+    }
45
+}

+ 11
- 6
LotteryTracker/LotteryTracker.xcdatamodeld/LotteryTracker.xcdatamodel/contents 查看文件

@@ -1,9 +1,14 @@
1 1
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
3
-    <entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
4
-        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="24G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
3
+    <entity name="LotteryTicket" representedClassName="LotteryTicket" syncable="YES" codeGenerationType="class">
4
+        <attribute name="amount" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
5
+        <attribute name="betCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
6
+        <attribute name="date" attributeType="Date" defaultDateTimeInterval="790702140" usesScalarValueType="NO"/>
7
+        <attribute name="drawDate" attributeType="Date" defaultDateTimeInterval="790702200" usesScalarValueType="NO"/>
8
+        <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
9
+        <attribute name="numbers" optional="YES" attributeType="String"/>
10
+        <attribute name="prizeAmount" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
11
+        <attribute name="status" attributeType="String" defaultValueString="pending"/>
12
+        <attribute name="type" attributeType="String"/>
5 13
     </entity>
6
-    <elements>
7
-        <element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
8
-    </elements>
9 14
 </model>

+ 0
- 21
LotteryTracker/LotteryTrackerApp.swift 查看文件

@@ -1,21 +0,0 @@
1
-//
2
-//  LotteryTrackerApp.swift
3
-//  LotteryTracker
4
-//
5
-//  Created by aaa on 2026/1/21.
6
-//
7
-
8
-import SwiftUI
9
-import CoreData
10
-
11
-@main
12
-struct LotteryTrackerApp: App {
13
-    let persistenceController = PersistenceController.shared
14
-
15
-    var body: some Scene {
16
-        WindowGroup {
17
-            ContentView()
18
-                .environment(\.managedObjectContext, persistenceController.container.viewContext)
19
-        }
20
-    }
21
-}

+ 96
- 0
LotteryTracker/Models/LotteryTicket+Extensions.swift 查看文件

@@ -0,0 +1,96 @@
1
+import Foundation
2
+internal import CoreData
3
+
4
+// 添加通知名称
5
+extension Notification.Name {
6
+    static let ticketsUpdated = Notification.Name("ticketsUpdated")
7
+    static let appReset = Notification.Name("appReset")
8
+}
9
+
10
+// 彩票类型枚举
11
+enum LotteryType: String, CaseIterable, Identifiable {
12
+    case doubleColorBall = "双色球"
13
+    case superLotto = "大乐透"
14
+    case welfare3D = "福彩3D"
15
+    case sevenStar = "七星彩"
16
+    case other = "其他"
17
+    
18
+    var id: String { rawValue }
19
+}
20
+
21
+// 票务状态枚举
22
+enum TicketStatus: String {
23
+    case pending = "待开奖"
24
+    case won = "已中奖"
25
+    case lost = "未中奖"
26
+}
27
+
28
+// 扩展 LotteryTicket 类
29
+extension LotteryTicket {
30
+    // 计算属性:获取彩票类型
31
+    var lotteryType: LotteryType {
32
+        get {
33
+            LotteryType(rawValue: type ?? "") ?? .other
34
+        }
35
+        set {
36
+            type = newValue.rawValue
37
+        }
38
+    }
39
+    
40
+    // 计算属性:获取票务状态
41
+    var ticketStatus: TicketStatus {
42
+        get {
43
+            TicketStatus(rawValue: status ?? "") ?? .pending
44
+        }
45
+        set {
46
+            status = newValue.rawValue
47
+        }
48
+    }
49
+    
50
+    // 计算属性:计算盈亏
51
+    var profit: Double {
52
+        return prizeAmount - amount
53
+    }
54
+    
55
+    // 计算属性:格式化日期
56
+    var formattedDate: String {
57
+        let formatter = DateFormatter()
58
+        formatter.dateFormat = "yyyy-MM-dd HH:mm"
59
+        return formatter.string(from: date ?? Date())
60
+    }
61
+    
62
+    // 计算属性:格式化开奖日期
63
+    var formattedDrawDate: String {
64
+        let formatter = DateFormatter()
65
+        formatter.dateFormat = "yyyy-MM-dd"
66
+        return formatter.string(from: drawDate ?? Date())
67
+    }
68
+}
69
+
70
+// 扩展 FetchRequest
71
+extension LotteryTicket {
72
+    // 获取所有记录的请求
73
+    static func fetchAllTickets() -> NSFetchRequest<LotteryTicket> {
74
+        let request: NSFetchRequest<LotteryTicket> = LotteryTicket.fetchRequest()
75
+        request.sortDescriptors = [
76
+            NSSortDescriptor(keyPath: \LotteryTicket.date, ascending: false)
77
+        ]
78
+        return request
79
+    }
80
+    
81
+    // 获取待开奖记录的请求
82
+    static func fetchPendingTickets() -> NSFetchRequest<LotteryTicket> {
83
+        let request: NSFetchRequest<LotteryTicket> = LotteryTicket.fetchRequest()
84
+        request.predicate = NSPredicate(format: "status == %@", TicketStatus.pending.rawValue)
85
+        request.sortDescriptors = [
86
+            NSSortDescriptor(keyPath: \LotteryTicket.drawDate, ascending: true)
87
+        ]
88
+        return request
89
+    }
90
+}//
91
+//  LotteryTicket+Extensions.swift
92
+//  LotteryTracker
93
+//
94
+//  Created by aaa on 2026/1/21.
95
+//
96
+

+ 0
- 57
LotteryTracker/Persistence.swift 查看文件

@@ -1,57 +0,0 @@
1
-//
2
-//  Persistence.swift
3
-//  LotteryTracker
4
-//
5
-//  Created by aaa on 2026/1/21.
6
-//
7
-
8
-import CoreData
9
-
10
-struct PersistenceController {
11
-    static let shared = PersistenceController()
12
-
13
-    @MainActor
14
-    static let preview: PersistenceController = {
15
-        let result = PersistenceController(inMemory: true)
16
-        let viewContext = result.container.viewContext
17
-        for _ in 0..<10 {
18
-            let newItem = Item(context: viewContext)
19
-            newItem.timestamp = Date()
20
-        }
21
-        do {
22
-            try viewContext.save()
23
-        } catch {
24
-            // Replace this implementation with code to handle the error appropriately.
25
-            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
26
-            let nsError = error as NSError
27
-            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
28
-        }
29
-        return result
30
-    }()
31
-
32
-    let container: NSPersistentContainer
33
-
34
-    init(inMemory: Bool = false) {
35
-        container = NSPersistentContainer(name: "LotteryTracker")
36
-        if inMemory {
37
-            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
38
-        }
39
-        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
40
-            if let error = error as NSError? {
41
-                // Replace this implementation with code to handle the error appropriately.
42
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
43
-
44
-                /*
45
-                 Typical reasons for an error here include:
46
-                 * The parent directory does not exist, cannot be created, or disallows writing.
47
-                 * The persistent store is not accessible, due to permissions or data protection when the device is locked.
48
-                 * The device is out of space.
49
-                 * The store could not be migrated to the current model version.
50
-                 Check the error message to determine what the actual problem was.
51
-                 */
52
-                fatalError("Unresolved error \(error), \(error.userInfo)")
53
-            }
54
-        })
55
-        container.viewContext.automaticallyMergesChangesFromParent = true
56
-    }
57
-}

+ 119
- 0
LotteryTracker/Services/DemoDataGenerator.swift 查看文件

@@ -0,0 +1,119 @@
1
+//
2
+//  DemoDataGenerator.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+internal import CoreData
10
+
11
+class DemoDataGenerator {
12
+    private let context: NSManagedObjectContext
13
+    
14
+    init(context: NSManagedObjectContext) {
15
+        self.context = context
16
+    }
17
+    
18
+    // 生成演示数据
19
+    func generateDemoData(count: Int = 50) {
20
+        clearAllData()
21
+        
22
+        let types: [LotteryType] = [.doubleColorBall, .superLotto, .welfare3D, .sevenStar]
23
+        let calendar = Calendar.current
24
+        let now = Date()
25
+        
26
+        for i in 1...count {
27
+            let ticket = LotteryTicket(context: context)
28
+            ticket.id = UUID()
29
+            
30
+            // 随机选择类型
31
+            let type = types.randomElement() ?? .doubleColorBall
32
+            ticket.lotteryType = type
33
+            
34
+            // 生成号码
35
+            ticket.numbers = generateNumbers(for: type)
36
+            
37
+            // 随机注数(1-10)
38
+            ticket.betCount = Int16.random(in: 1...10)
39
+            
40
+            // 随机金额(每注2-20元)
41
+            let amountPerBet = Double.random(in: 2...20)
42
+            ticket.amount = Double(ticket.betCount) * amountPerBet
43
+            
44
+            // 随机购买日期(过去60天内)
45
+            let daysAgo = Int.random(in: 0...60)
46
+            let purchaseDate = calendar.date(byAdding: .day, value: -daysAgo, to: now)!
47
+            ticket.date = purchaseDate
48
+            
49
+            // 随机开奖日期(购买后1-30天)
50
+            let drawDays = Int.random(in: 1...30)
51
+            let drawDate = calendar.date(byAdding: .day, value: drawDays, to: purchaseDate)!
52
+            ticket.drawDate = drawDate
53
+            
54
+            // 随机状态(30%中奖,30%未中,40%待开奖)
55
+            let status = Int.random(in: 0...100)
56
+            if status < 30 {
57
+                ticket.ticketStatus = .won
58
+                ticket.prizeAmount = ticket.amount * Double.random(in: 1...50)
59
+            } else if status < 60 {
60
+                ticket.ticketStatus = .lost
61
+                ticket.prizeAmount = 0
62
+            } else {
63
+                ticket.ticketStatus = .pending
64
+                ticket.prizeAmount = 0
65
+            }
66
+            
67
+            print("🎫 生成记录 \(i)/\(count): \(type.rawValue), ¥\(ticket.amount, default: "%.2f")")
68
+        }
69
+        
70
+        do {
71
+            try context.save()
72
+            print("✅ 成功生成 \(count) 条演示数据")
73
+            
74
+            // 发送数据更新通知
75
+            NotificationCenter.default.post(name: .ticketsUpdated, object: nil)
76
+        } catch {
77
+            print("❌ 保存演示数据失败: \(error)")
78
+        }
79
+    }
80
+    
81
+    // 生成号码
82
+    private func generateNumbers(for type: LotteryType) -> String {
83
+        switch type {
84
+        case .doubleColorBall:
85
+            let redBalls = (1...33).shuffled().prefix(6).map { String(format: "%02d", $0) }
86
+            let blueBall = String(format: "%02d", Int.random(in: 1...16))
87
+            return "\(redBalls.joined(separator: " ")) + \(blueBall)"
88
+            
89
+        case .superLotto:
90
+            let frontBalls = (1...35).shuffled().prefix(5).map { String(format: "%02d", $0) }
91
+            let backBalls = (1...12).shuffled().prefix(2).map { String(format: "%02d", $0) }
92
+            return "\(frontBalls.joined(separator: " ")) + \(backBalls.joined(separator: " "))"
93
+            
94
+        case .welfare3D:
95
+            let numbers = (0...9).shuffled().prefix(3).map { String($0) }
96
+            return numbers.joined(separator: " ")
97
+            
98
+        case .sevenStar:
99
+            let numbers = (0...9).shuffled().prefix(7).map { String($0) }
100
+            return numbers.joined(separator: " ")
101
+            
102
+        case .other:
103
+            return "随机号码"
104
+        }
105
+    }
106
+    
107
+    // 清空所有数据
108
+    private func clearAllData() {
109
+        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = LotteryTicket.fetchRequest()
110
+        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
111
+        
112
+        do {
113
+            try context.execute(deleteRequest)
114
+            print("✅ 清空现有数据")
115
+        } catch {
116
+            print("❌ 清空数据失败: \(error)")
117
+        }
118
+    }
119
+}

+ 196
- 0
LotteryTracker/Services/LotteryChecker.swift 查看文件

@@ -0,0 +1,196 @@
1
+//
2
+//  LotteryChecker.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+internal import CoreData
10
+import UserNotifications
11
+
12
+class LotteryChecker {
13
+    private let context: NSManagedObjectContext
14
+    private let notificationService = NotificationService()
15
+    
16
+    init(context: NSManagedObjectContext) {
17
+        self.context = context
18
+    }
19
+    
20
+    // 检查开奖结果(模拟)
21
+    func checkDrawResults() {
22
+        print("🔍 开始检查开奖结果...")
23
+        
24
+        let request = LotteryTicket.fetchRequest()
25
+        let predicate = NSPredicate(format: "status == %@", TicketStatus.pending.rawValue)
26
+        request.predicate = predicate
27
+        
28
+        guard let pendingTickets = try? context.fetch(request) else {
29
+            print("❌ 获取待开奖记录失败")
30
+            return
31
+        }
32
+        
33
+        let now = Date()
34
+        var updatedCount = 0
35
+        
36
+        for ticket in pendingTickets {
37
+            guard let drawDate = ticket.drawDate else { continue }
38
+            
39
+            // 如果开奖日期已过
40
+            if drawDate <= now {
41
+                // 模拟开奖结果(实际应用中应调用真实API)
42
+                let isWin = Bool.random()
43
+                let winMultiplier = Double.random(in: 1...100)
44
+                
45
+                ticket.ticketStatus = isWin ? .won : .lost
46
+                ticket.prizeAmount = isWin ? ticket.amount * winMultiplier : 0
47
+                
48
+                updatedCount += 1
49
+                
50
+                print("🎯 票券 \(ticket.id?.uuidString.prefix(8) ?? "未知") 开奖结果: \(ticket.ticketStatus.rawValue)")
51
+                
52
+                // 发送通知
53
+                if isWin {
54
+                    notificationService.sendWinNotification(
55
+                        amount: ticket.prizeAmount,
56
+                        ticketType: ticket.lotteryType.rawValue
57
+                    )
58
+                }
59
+            }
60
+        }
61
+        
62
+        if updatedCount > 0 {
63
+            do {
64
+                try context.save()
65
+                print("✅ 更新了 \(updatedCount) 条开奖结果")
66
+                
67
+                // 发送更新通知
68
+                NotificationCenter.default.post(name: .ticketsUpdated, object: nil)
69
+            } catch {
70
+                print("❌ 保存开奖结果失败: \(error)")
71
+            }
72
+        } else {
73
+            print("📭 暂无需要开奖的记录")
74
+        }
75
+    }
76
+    
77
+    // 检查即将开奖的记录
78
+    func checkUpcomingDraws() {
79
+        let request = LotteryTicket.fetchRequest()
80
+        let predicate = NSPredicate(format: "status == %@", TicketStatus.pending.rawValue)
81
+        request.predicate = predicate
82
+        
83
+        guard let pendingTickets = try? context.fetch(request) else { return }
84
+        
85
+        let calendar = Calendar.current
86
+        let now = Date()
87
+        
88
+        for ticket in pendingTickets {
89
+            guard let drawDate = ticket.drawDate else { continue }
90
+            
91
+            // 计算距离开奖的天数
92
+            let components = calendar.dateComponents([.day, .hour], from: now, to: drawDate)
93
+            
94
+            // 如果24小时内开奖,发送提醒
95
+            if let days = components.day, days == 0, let hours = components.hour, hours <= 24 {
96
+                notificationService.sendUpcomingDrawNotification(
97
+                    ticketType: ticket.lotteryType.rawValue,
98
+                    drawDate: drawDate
99
+                )
100
+            }
101
+        }
102
+    }
103
+    
104
+    // 手动开奖(测试用)
105
+    func manualDraw(for ticketId: UUID) -> Bool {
106
+        let request = LotteryTicket.fetchRequest()
107
+        request.predicate = NSPredicate(format: "id == %@", ticketId as CVarArg)
108
+        
109
+        guard let tickets = try? context.fetch(request),
110
+              let ticket = tickets.first,
111
+              ticket.ticketStatus == .pending else {
112
+            return false
113
+        }
114
+        
115
+        // 模拟开奖
116
+        let isWin = Bool.random()
117
+        let winMultiplier = Double.random(in: 1...50)
118
+        
119
+        ticket.ticketStatus = isWin ? .won : .lost
120
+        ticket.prizeAmount = isWin ? ticket.amount * winMultiplier : 0
121
+        
122
+        do {
123
+            try context.save()
124
+            
125
+            // 发送更新通知
126
+            NotificationCenter.default.post(name: .ticketsUpdated, object: nil)
127
+            
128
+            return true
129
+        } catch {
130
+            print("❌ 手动开奖失败: \(error)")
131
+            return false
132
+        }
133
+    }
134
+}
135
+
136
+// 通知服务
137
+class NotificationService {
138
+    private let center = UNUserNotificationCenter.current()
139
+    
140
+    init() {
141
+        requestAuthorization()
142
+    }
143
+    
144
+    private func requestAuthorization() {
145
+        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
146
+            if granted {
147
+                print("✅ 通知权限已获取")
148
+            } else if let error = error {
149
+                print("❌ 通知权限获取失败: \(error)")
150
+            }
151
+        }
152
+    }
153
+    
154
+    func sendWinNotification(amount: Double, ticketType: String) {
155
+        let content = UNMutableNotificationContent()
156
+        content.title = "🎉 恭喜中奖!"
157
+        content.body = "您的\(ticketType)中奖了!奖金:¥\(String(format: "%.2f", amount))"
158
+        content.sound = .default
159
+        
160
+        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
161
+        let request = UNNotificationRequest(
162
+            identifier: UUID().uuidString,
163
+            content: content,
164
+            trigger: trigger
165
+        )
166
+        
167
+        center.add(request) { error in
168
+            if let error = error {
169
+                print("❌ 发送中奖通知失败: \(error)")
170
+            }
171
+        }
172
+    }
173
+    
174
+    func sendUpcomingDrawNotification(ticketType: String, drawDate: Date) {
175
+        let formatter = DateFormatter()
176
+        formatter.dateFormat = "MM月dd日 HH:mm"
177
+        
178
+        let content = UNMutableNotificationContent()
179
+        content.title = "⏰ 开奖提醒"
180
+        content.body = "您的\(ticketType)将于\(formatter.string(from: drawDate))开奖"
181
+        content.sound = .default
182
+        
183
+        // 提前1小时提醒
184
+        let triggerDate = Calendar.current.date(byAdding: .hour, value: -1, to: drawDate)!
185
+        let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: triggerDate)
186
+        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
187
+        
188
+        let request = UNNotificationRequest(
189
+            identifier: "upcoming_draw_\(ticketType)",
190
+            content: content,
191
+            trigger: trigger
192
+        )
193
+        
194
+        center.add(request)
195
+    }
196
+}

+ 328
- 0
LotteryTracker/Services/StatisticsService.swift 查看文件

@@ -0,0 +1,328 @@
1
+//
2
+//  StatisticsService.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+internal import CoreData
10
+import Combine
11
+
12
+class StatisticsService: ObservableObject {
13
+    private let context: NSManagedObjectContext
14
+    
15
+    // 添加 Combine 发布者管理
16
+    private var cancellables = Set<AnyCancellable>()
17
+    
18
+    // 添加缓存
19
+    private var cachedMonthlyStats: MonthlyStats?
20
+    private var cachedTypeDistribution: [TypeDistribution]?
21
+    private var cachedProfitTrend: [DailyProfit]?
22
+    private var lastRefreshTime: Date?
23
+    private let cacheDuration: TimeInterval = 60 // 缓存60秒
24
+    
25
+    // 发布统计数据
26
+    @Published var monthlyStats: MonthlyStats = MonthlyStats()
27
+    @Published var typeDistribution: [TypeDistribution] = []
28
+    @Published var profitTrend: [DailyProfit] = []
29
+    @Published var isLoading: Bool = false
30
+    
31
+    init(context: NSManagedObjectContext) {
32
+        self.context = context
33
+        setupNotificationListeners()  // 添加通知监听
34
+    }
35
+    
36
+    // 设置通知监听器
37
+    private func setupNotificationListeners() {
38
+        // 监听数据更新通知
39
+        NotificationCenter.default.publisher(for: .ticketsUpdated)
40
+            .sink { [weak self] notification in
41
+                guard let self = self else { return }
42
+                
43
+                // 检查是否是清空操作(通过 userInfo 判断)
44
+                if let userInfo = notification.userInfo,
45
+                   let isClearOperation = userInfo["isClearOperation"] as? Bool,
46
+                   isClearOperation {
47
+                    print("📢 收到清空数据通知,清空统计缓存")
48
+                    self.clearCacheImmediately()
49
+                } else {
50
+                    print("📢 收到数据更新通知,刷新统计")
51
+                    // 异步刷新数据
52
+                    Task {
53
+                        await self.forceRefresh()
54
+                    }
55
+                }
56
+            }
57
+            .store(in: &cancellables)
58
+        
59
+        // 监听应用重置通知(如果有的话)
60
+        NotificationCenter.default.publisher(for: .appReset)
61
+            .sink { [weak self] _ in
62
+                print("📢 收到应用重置通知,清空统计缓存")
63
+                self?.clearCacheImmediately()
64
+            }
65
+            .store(in: &cancellables)
66
+    }
67
+    
68
+    // 清空所有缓存数据(立即执行,在主线程)
69
+    private func clearCacheImmediately() {
70
+        DispatchQueue.main.async {
71
+            self.cachedMonthlyStats = nil
72
+            self.cachedTypeDistribution = nil
73
+            self.cachedProfitTrend = nil
74
+            self.lastRefreshTime = nil
75
+            
76
+            // 重置发布的数据
77
+            self.monthlyStats = MonthlyStats()
78
+            self.typeDistribution = []
79
+            self.profitTrend = []
80
+            
81
+            print("🗑️ 统计缓存已立即清空")
82
+        }
83
+    }
84
+    
85
+    // 清空缓存(公开方法)
86
+    func clearCache() {
87
+        clearCacheImmediately()
88
+    }
89
+    
90
+    // 强制刷新数据(忽略缓存)
91
+    func forceRefresh() async {
92
+        // 清空缓存
93
+        clearCache()
94
+        
95
+        // 重新加载数据
96
+        await loadAllStatistics()
97
+    }
98
+    
99
+    // 异步加载所有统计数据
100
+    func loadAllStatistics() async {
101
+        await MainActor.run {
102
+            isLoading = true
103
+        }
104
+        
105
+        // 并行加载不同类型的数据
106
+        async let monthly = loadMonthlyStatsAsync()
107
+        async let distribution = loadTypeDistributionAsync()
108
+        async let trend = loadProfitTrendAsync(days: 30)
109
+        
110
+        // 等待所有数据加载完成
111
+        let (monthlyResult, distributionResult, trendResult) = await (monthly, distribution, trend)
112
+        
113
+        await MainActor.run {
114
+            self.monthlyStats = monthlyResult
115
+            self.typeDistribution = distributionResult
116
+            self.profitTrend = trendResult
117
+            self.isLoading = false
118
+            
119
+            // 更新缓存
120
+            self.cachedMonthlyStats = monthlyResult
121
+            self.cachedTypeDistribution = distributionResult
122
+            self.cachedProfitTrend = trendResult
123
+            self.lastRefreshTime = Date()
124
+            
125
+            print("📊 统计数据加载完成")
126
+        }
127
+    }
128
+    
129
+    // 异步加载月度统计
130
+    private func loadMonthlyStatsAsync() async -> MonthlyStats {
131
+        // 检查缓存
132
+        if let cached = cachedMonthlyStats,
133
+           let lastRefresh = lastRefreshTime,
134
+           Date().timeIntervalSince(lastRefresh) < cacheDuration {
135
+            return cached
136
+        }
137
+        
138
+        return await withCheckedContinuation { continuation in
139
+            DispatchQueue.global(qos: .userInitiated).async {
140
+                let request = LotteryTicket.fetchRequest()
141
+                
142
+                guard let tickets = try? self.context.fetch(request) else {
143
+                    continuation.resume(returning: MonthlyStats())
144
+                    return
145
+                }
146
+                
147
+                let calendar = Calendar.current
148
+                let now = Date()
149
+                
150
+                // 获取本月数据
151
+                let currentMonthTickets = tickets.filter { ticket in
152
+                    guard let date = ticket.date else { return false }
153
+                    return calendar.isDate(date, equalTo: now, toGranularity: .month)
154
+                }
155
+                
156
+                // 获取上月数据
157
+                let lastMonth = calendar.date(byAdding: .month, value: -1, to: now)!
158
+                let lastMonthTickets = tickets.filter { ticket in
159
+                    guard let date = ticket.date else { return false }
160
+                    return calendar.isDate(date, equalTo: lastMonth, toGranularity: .month)
161
+                }
162
+                
163
+                let stats = MonthlyStats(
164
+                    currentMonth: self.calculateStats(for: currentMonthTickets),
165
+                    lastMonth: self.calculateStats(for: lastMonthTickets)
166
+                )
167
+                
168
+                continuation.resume(returning: stats)
169
+            }
170
+        }
171
+    }
172
+    
173
+    // 异步加载类型分布
174
+    private func loadTypeDistributionAsync() async -> [TypeDistribution] {
175
+        // 检查缓存
176
+        if let cached = cachedTypeDistribution,
177
+           let lastRefresh = lastRefreshTime,
178
+           Date().timeIntervalSince(lastRefresh) < cacheDuration {
179
+            return cached
180
+        }
181
+        
182
+        return await withCheckedContinuation { continuation in
183
+            DispatchQueue.global(qos: .userInitiated).async {
184
+                let request = LotteryTicket.fetchRequest()
185
+                
186
+                guard let tickets = try? self.context.fetch(request) else {
187
+                    continuation.resume(returning: [])
188
+                    return
189
+                }
190
+                
191
+                var distribution: [LotteryType: Double] = [:]
192
+                let totalAmount = tickets.reduce(0.0) { $0 + $1.amount }
193
+                
194
+                for ticket in tickets {
195
+                    distribution[ticket.lotteryType, default: 0] += ticket.amount
196
+                }
197
+                
198
+                let result = distribution.map { type, amount in
199
+                    TypeDistribution(
200
+                        type: type,
201
+                        amount: amount,
202
+                        percentage: totalAmount > 0 ? (amount / totalAmount) * 100 : 0
203
+                    )
204
+                }.sorted { $0.amount > $1.amount }
205
+                
206
+                continuation.resume(returning: result)
207
+            }
208
+        }
209
+    }
210
+    
211
+    // 异步加载盈亏趋势
212
+    private func loadProfitTrendAsync(days: Int) async -> [DailyProfit] {
213
+        // 检查缓存
214
+        if let cached = cachedProfitTrend,
215
+           let lastRefresh = lastRefreshTime,
216
+           Date().timeIntervalSince(lastRefresh) < cacheDuration {
217
+            return cached
218
+        }
219
+        
220
+        return await withCheckedContinuation { continuation in
221
+            DispatchQueue.global(qos: .userInitiated).async {
222
+                let request = LotteryTicket.fetchRequest()
223
+                
224
+                guard let tickets = try? self.context.fetch(request) else {
225
+                    continuation.resume(returning: [])
226
+                    return
227
+                }
228
+                
229
+                let calendar = Calendar.current
230
+                let endDate = Date()
231
+                let startDate = calendar.date(byAdding: .day, value: -days, to: endDate)!
232
+                
233
+                var dailyProfits: [Date: Double] = [:]
234
+                
235
+                for ticket in tickets {
236
+                    guard let date = ticket.date else { continue }
237
+                    let dayStart = calendar.startOfDay(for: date)
238
+                    
239
+                    if dayStart >= startDate && dayStart <= endDate {
240
+                        dailyProfits[dayStart, default: 0] += ticket.profit
241
+                    }
242
+                }
243
+                
244
+                // 填充缺失的日期
245
+                var result: [DailyProfit] = []
246
+                var currentDate = startDate
247
+                
248
+                while currentDate <= endDate {
249
+                    let profit = dailyProfits[currentDate] ?? 0
250
+                    result.append(DailyProfit(date: currentDate, profit: profit))
251
+                    currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
252
+                }
253
+                
254
+                continuation.resume(returning: result)
255
+            }
256
+        }
257
+    }
258
+    
259
+    // 计算统计数据
260
+    private func calculateStats(for tickets: [LotteryTicket]) -> MonthStats {
261
+        let totalSpent = tickets.reduce(0.0) { $0 + $1.amount }
262
+        let totalPrize = tickets.reduce(0.0) { $0 + $1.prizeAmount }
263
+        let profit = totalPrize - totalSpent
264
+        
265
+        let winningTickets = tickets.filter { $0.ticketStatus == .won }.count
266
+        // 修复:winningTickets 已经是 Int,不需要 .count
267
+        let winRate = tickets.isEmpty ? 0 : Double(winningTickets) / Double(tickets.count) * 100
268
+        
269
+        return MonthStats(
270
+            ticketCount: tickets.count,
271
+            totalSpent: totalSpent,
272
+            totalProfit: profit,
273
+            winRate: winRate,
274
+            averageBet: tickets.isEmpty ? 0 : totalSpent / Double(tickets.count)
275
+        )
276
+    }
277
+}
278
+
279
+// 数据模型
280
+struct MonthlyStats {
281
+    let currentMonth: MonthStats
282
+    let lastMonth: MonthStats
283
+    
284
+    init(currentMonth: MonthStats = MonthStats(), lastMonth: MonthStats = MonthStats()) {
285
+        self.currentMonth = currentMonth
286
+        self.lastMonth = lastMonth
287
+    }
288
+    
289
+    var profitChange: Double {
290
+        guard lastMonth.totalSpent > 0 else { return 0 }
291
+        return ((currentMonth.totalProfit - lastMonth.totalProfit) / lastMonth.totalProfit) * 100
292
+    }
293
+}
294
+
295
+struct MonthStats {
296
+    let ticketCount: Int
297
+    let totalSpent: Double
298
+    let totalProfit: Double
299
+    let winRate: Double
300
+    let averageBet: Double
301
+    
302
+    init(
303
+        ticketCount: Int = 0,
304
+        totalSpent: Double = 0,
305
+        totalProfit: Double = 0,
306
+        winRate: Double = 0,
307
+        averageBet: Double = 0
308
+    ) {
309
+        self.ticketCount = ticketCount
310
+        self.totalSpent = totalSpent
311
+        self.totalProfit = totalProfit
312
+        self.winRate = winRate
313
+        self.averageBet = averageBet
314
+    }
315
+}
316
+
317
+struct TypeDistribution: Identifiable {
318
+    let id = UUID()
319
+    let type: LotteryType
320
+    let amount: Double
321
+    let percentage: Double
322
+}
323
+
324
+struct DailyProfit: Identifiable {
325
+    let id = UUID()
326
+    let date: Date
327
+    let profit: Double
328
+}

+ 48
- 0
LotteryTracker/Utils/DataCleaner.swift 查看文件

@@ -0,0 +1,48 @@
1
+//
2
+//  DataCleaner.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+internal import CoreData
10
+
11
+class DataCleaner {
12
+    static func clearAllTickets(context: NSManagedObjectContext, completion: @escaping (Bool, String?) -> Void) {
13
+        DispatchQueue.global(qos: .userInitiated).async {
14
+            let fetchRequest: NSFetchRequest<NSFetchRequestResult> = LotteryTicket.fetchRequest()
15
+            
16
+            do {
17
+                // 获取所有票券
18
+                let tickets = try context.fetch(fetchRequest) as? [LotteryTicket] ?? []
19
+                print("找到 \(tickets.count) 条记录需要删除")
20
+                
21
+                // 逐一删除
22
+                for ticket in tickets {
23
+                    context.delete(ticket)
24
+                }
25
+                
26
+                // 保存更改
27
+                if context.hasChanges {
28
+                    try context.save()
29
+                    print("✅ 保存删除操作")
30
+                }
31
+                
32
+                // 重置上下文
33
+                context.reset()
34
+                
35
+                DispatchQueue.main.async {
36
+                    completion(true, nil)
37
+                    print("✅ 数据清空完成")
38
+                }
39
+                
40
+            } catch {
41
+                DispatchQueue.main.async {
42
+                    completion(false, "删除失败: \(error.localizedDescription)")
43
+                    print("❌ 删除失败: \(error)")
44
+                }
45
+            }
46
+        }
47
+    }
48
+}

+ 45
- 0
LotteryTracker/Utils/Formatters.swift 查看文件

@@ -0,0 +1,45 @@
1
+//
2
+//  Untitled.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+
10
+class Formatters {
11
+    // 日期格式化器
12
+    static let dateFormatter: DateFormatter = {
13
+        let formatter = DateFormatter()
14
+        formatter.dateStyle = .medium
15
+        formatter.timeStyle = .none
16
+        formatter.locale = Locale(identifier: "zh_CN")
17
+        return formatter
18
+    }()
19
+    
20
+    // 货币格式化器
21
+    static let currencyFormatter: NumberFormatter = {
22
+        let formatter = NumberFormatter()
23
+        formatter.numberStyle = .currency
24
+        formatter.locale = Locale(identifier: "zh_CN")
25
+        formatter.currencySymbol = "¥"
26
+        return formatter
27
+    }()
28
+    
29
+    // 格式化金额
30
+    static func formatCurrency(_ amount: Double) -> String {
31
+        currencyFormatter.string(from: NSNumber(value: amount)) ?? "¥0.00"
32
+    }
33
+    
34
+    // 格式化日期
35
+    static func formatDate(_ date: Date) -> String {
36
+        dateFormatter.string(from: date)
37
+    }
38
+    
39
+    // 计算天数差
40
+    static func daysBetween(_ from: Date, _ to: Date) -> Int {
41
+        let calendar = Calendar.current
42
+        let components = calendar.dateComponents([.day], from: from, to: to)
43
+        return components.day ?? 0
44
+    }
45
+}

+ 81
- 0
LotteryTracker/Utils/PerformanceMonitor.swift 查看文件

@@ -0,0 +1,81 @@
1
+//
2
+//  PerformanceMonitor.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+
10
+class PerformanceMonitor {
11
+    static let shared = PerformanceMonitor()
12
+    
13
+    private var timers: [String: TimeInterval] = [:]
14
+    private var memoryUsage: [String: UInt64] = [:]
15
+    
16
+    private init() {}
17
+    
18
+    // 开始计时
19
+    func startTimer(_ name: String) {
20
+        timers[name] = Date().timeIntervalSince1970
21
+    }
22
+    
23
+    // 结束计时并返回耗时
24
+    @discardableResult
25
+    func stopTimer(_ name: String) -> TimeInterval {
26
+        guard let startTime = timers[name] else { return 0 }
27
+        let endTime = Date().timeIntervalSince1970
28
+        let duration = endTime - startTime
29
+        timers.removeValue(forKey: name)
30
+        
31
+        print("⏱️ [\(name)] 耗时: \(String(format: "%.3f", duration))秒")
32
+        return duration
33
+    }
34
+    
35
+    // 记录内存使用
36
+    func recordMemoryUsage(_ name: String) {
37
+        var info = mach_task_basic_info()
38
+        var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
39
+        
40
+        let kerr = withUnsafeMutablePointer(to: &info) {
41
+            $0.withMemoryRebound(to: integer_t.self, capacity: 1) {
42
+                task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
43
+            }
44
+        }
45
+        
46
+        if kerr == KERN_SUCCESS {
47
+            let memoryUsage = info.resident_size
48
+            self.memoryUsage[name] = memoryUsage
49
+            print("💾 [\(name)] 内存使用: \(memoryUsage / 1024 / 1024) MB")
50
+        }
51
+    }
52
+    
53
+    // 性能测试
54
+    func runPerformanceTest(operation: () -> Void, name: String = "测试") {
55
+        startTimer(name)
56
+        recordMemoryUsage("\(name)_开始")
57
+        
58
+        operation()
59
+        
60
+        stopTimer(name)
61
+        recordMemoryUsage("\(name)_结束")
62
+    }
63
+}
64
+
65
+// 调试日志
66
+func debugLog(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
67
+    #if DEBUG
68
+    let fileName = (file as NSString).lastPathComponent
69
+    print("🐛 [\(fileName):\(line)] \(function) - \(message)")
70
+    #endif
71
+}
72
+
73
+// 安全执行
74
+func safeExecute<T>(_ operation: () throws -> T, errorMessage: String = "操作失败") -> T? {
75
+    do {
76
+        return try operation()
77
+    } catch {
78
+        debugLog("\(errorMessage): \(error)")
79
+        return nil
80
+    }
81
+}

+ 194
- 0
LotteryTracker/ViewModels/AddTicketViewModel.swift 查看文件

@@ -0,0 +1,194 @@
1
+//
2
+//  AddTicketViewModel.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import Foundation
9
+import Combine
10
+internal import CoreData
11
+
12
+class AddTicketViewModel: ObservableObject {
13
+    // 输入字段
14
+    @Published var selectedType: LotteryType = .doubleColorBall
15
+    @Published var numbers: String = ""
16
+    @Published var betCount: Int = 1
17
+    @Published var amountPerBet: String = "2.00"
18
+    
19
+    // 修改:改为购买日期(默认今天)
20
+    @Published var purchaseDate: Date = Date()
21
+    
22
+    // 修改:开奖日期根据彩票类型自动计算
23
+    var drawDate: Date {
24
+        calculateDrawDate()
25
+    }
26
+    
27
+    // 验证状态
28
+    @Published var isValid: Bool = false
29
+    @Published var errorMessage: String = ""
30
+    
31
+    // 计算属性
32
+    var totalAmount: Double {
33
+        let amount = Double(amountPerBet) ?? 0.0
34
+        return amount * Double(betCount)
35
+    }
36
+    
37
+    var formattedPurchaseDate: String {
38
+        let formatter = DateFormatter()
39
+        formatter.dateFormat = "yyyy年MM月dd日"
40
+        return formatter.string(from: purchaseDate)
41
+    }
42
+    
43
+    var formattedDrawDate: String {
44
+        let formatter = DateFormatter()
45
+        formatter.dateFormat = "yyyy年MM月dd日"
46
+        return formatter.string(from: drawDate)
47
+    }
48
+    
49
+    private var cancellables = Set<AnyCancellable>()
50
+    
51
+    init() {
52
+        setupValidation()
53
+    }
54
+    
55
+    // 计算开奖日期(根据彩票类型)
56
+    private func calculateDrawDate() -> Date {
57
+        let calendar = Calendar.current
58
+        
59
+        switch selectedType {
60
+        case .doubleColorBall:
61
+            // 双色球:每周二、四、日开奖
62
+            return nextDrawDate(for: [2, 4, 7], from: purchaseDate)
63
+            
64
+        case .superLotto:
65
+            // 大乐透:每周一、三、六开奖
66
+            return nextDrawDate(for: [1, 3, 6], from: purchaseDate)
67
+            
68
+        case .welfare3D:
69
+            // 福彩3D:每天开奖
70
+            return calendar.date(byAdding: .day, value: 1, to: purchaseDate) ?? purchaseDate
71
+            
72
+        case .sevenStar:
73
+            // 七星彩:每周二、五、日开奖
74
+            return nextDrawDate(for: [2, 5, 7], from: purchaseDate)
75
+            
76
+        case .other:
77
+            // 其他:默认3天后
78
+            return calendar.date(byAdding: .day, value: 3, to: purchaseDate) ?? purchaseDate
79
+        }
80
+    }
81
+    
82
+    // 计算下一个开奖日期
83
+    private func nextDrawDate(for weekdays: [Int], from date: Date) -> Date {
84
+        let calendar = Calendar.current
85
+        let currentWeekday = calendar.component(.weekday, from: date)
86
+        
87
+        // 查找下一个开奖日
88
+        for weekday in weekdays.sorted() {
89
+            if weekday >= currentWeekday {
90
+                let daysToAdd = weekday - currentWeekday
91
+                return calendar.date(byAdding: .day, value: daysToAdd, to: date) ?? date
92
+            }
93
+        }
94
+        
95
+        // 如果本周没有,取下一周的第一个开奖日
96
+        let daysToAdd = (7 - currentWeekday) + weekdays.min()!
97
+        return calendar.date(byAdding: .day, value: daysToAdd, to: date) ?? date
98
+    }
99
+    
100
+    // 设置表单验证
101
+    private func setupValidation() {
102
+        Publishers.CombineLatest($amountPerBet, $numbers)
103
+            .map { amount, numbers in
104
+                // 金额验证
105
+                guard let amountValue = Double(amount), amountValue > 0 else {
106
+                    return false
107
+                }
108
+                
109
+                // 注数验证
110
+                guard self.betCount > 0 && self.betCount <= 100 else {
111
+                    return false
112
+                }
113
+                
114
+                return true
115
+            }
116
+            .assign(to: \.isValid, on: self)
117
+            .store(in: &cancellables)
118
+    }
119
+    
120
+    // 重置表单
121
+    func resetForm() {
122
+        selectedType = .doubleColorBall
123
+        numbers = ""
124
+        betCount = 1
125
+        amountPerBet = "2.00"
126
+        purchaseDate = Date()
127
+        errorMessage = ""
128
+    }
129
+    
130
+    // 保存彩票记录
131
+    func saveTicket(context: NSManagedObjectContext) -> Bool {
132
+        guard isValid else {
133
+            errorMessage = "请填写完整信息"
134
+            return false
135
+        }
136
+        
137
+        guard let amountValue = Double(amountPerBet), amountValue > 0 else {
138
+            errorMessage = "金额无效"
139
+            return false
140
+        }
141
+        
142
+        let ticket = LotteryTicket(context: context)
143
+        ticket.id = UUID()
144
+        ticket.lotteryType = selectedType
145
+        ticket.numbers = numbers.isEmpty ? "随机" : numbers
146
+        ticket.betCount = Int16(betCount)
147
+        ticket.amount = totalAmount
148
+        ticket.date = purchaseDate  // 使用用户选择的购买日期
149
+        ticket.drawDate = drawDate  // 自动计算的开奖日期
150
+        ticket.ticketStatus = .pending
151
+        ticket.prizeAmount = 0.0
152
+        
153
+        do {
154
+            try context.save()
155
+            print("✅ 彩票记录保存成功")
156
+            print("   类型: \(selectedType.rawValue)")
157
+            print("   注数: \(betCount)")
158
+            print("   金额: ¥\(totalAmount)")
159
+            print("   购买: \(formattedPurchaseDate)")
160
+            print("   开奖: \(formattedDrawDate)")
161
+            return true
162
+        } catch {
163
+            errorMessage = "保存失败: \(error.localizedDescription)"
164
+            print("❌ 保存失败: \(error)")
165
+            context.delete(ticket)
166
+            return false
167
+        }
168
+    }
169
+    
170
+    // 快速填充测试数据
171
+    func fillSampleData() {
172
+        selectedType = LotteryType.allCases.randomElement() ?? .doubleColorBall
173
+        numbers = generateSampleNumbers(for: selectedType)
174
+        betCount = Int.random(in: 1...5)
175
+        amountPerBet = String(format: "%.2f", Double.random(in: 2.0...10.0))
176
+        purchaseDate = Date().addingTimeInterval(Double.random(in: -30...0) * 24 * 3600)
177
+    }
178
+    
179
+    // 生成示例号码
180
+    private func generateSampleNumbers(for type: LotteryType) -> String {
181
+        switch type {
182
+        case .doubleColorBall:
183
+            return "01 05 12 23 28 33 + 09"
184
+        case .superLotto:
185
+            return "03 07 15 20 28 + 02 09"
186
+        case .welfare3D:
187
+            return "1 2 3"
188
+        case .sevenStar:
189
+            return "1 3 5 7 9 2 4"
190
+        case .other:
191
+            return "随机号码"
192
+        }
193
+    }
194
+}

+ 77
- 0
LotteryTracker/ViewModels/HomeViewModel.swift 查看文件

@@ -0,0 +1,77 @@
1
+import Foundation
2
+internal import CoreData
3
+import SwiftUI
4
+import Combine
5
+
6
+class HomeViewModel: ObservableObject {
7
+    // 发布统计数据
8
+    @Published var totalSpent: Double = 0.0
9
+    @Published var totalProfit: Double = 0.0
10
+    @Published var totalTickets: Int = 0
11
+    @Published var winningTickets: Int = 0
12
+    @Published var pendingTickets: Int = 0
13
+    @Published var lostTickets: Int = 0
14
+    @Published var groupedTickets: [Date: [LotteryTicket]] = [:]
15
+    
16
+    private var viewContext: NSManagedObjectContext
17
+    
18
+    init(context: NSManagedObjectContext) {
19
+        self.viewContext = context
20
+        refreshData()
21
+    }
22
+    
23
+    // 刷新所有统计数据
24
+    func refreshData() {
25
+        let request = LotteryTicket.fetchRequest()
26
+        
27
+        do {
28
+            let tickets = try viewContext.fetch(request)
29
+            
30
+            // 计算统计数据
31
+            totalTickets = tickets.count
32
+            totalSpent = tickets.reduce(0) { $0 + $1.amount }
33
+            
34
+            let totalPrize = tickets.reduce(0) { $0 + $1.prizeAmount }
35
+            totalProfit = totalPrize - totalSpent
36
+            
37
+            winningTickets = tickets.filter { $0.ticketStatus == .won }.count
38
+            pendingTickets = tickets.filter { $0.ticketStatus == .pending }.count
39
+            lostTickets = tickets.filter { $0.ticketStatus == .lost }.count
40
+            
41
+            // 按日期分组
42
+            let calendar = Calendar.current
43
+            groupedTickets = Dictionary(grouping: tickets) { ticket in
44
+                calendar.startOfDay(for: ticket.date ?? Date())
45
+            }
46
+            
47
+        } catch {
48
+            print("❌ 获取数据失败: \(error)")
49
+        }
50
+    }
51
+    
52
+    // 获取日期分组后的排序键(按日期倒序)
53
+    var sortedDates: [Date] {
54
+        groupedTickets.keys.sorted(by: >)
55
+    }
56
+    
57
+    // 获取特定日期的记录
58
+    func ticketsForDate(_ date: Date) -> [LotteryTicket] {
59
+        groupedTickets[date] ?? []
60
+    }
61
+    
62
+    // 修改:将 formattedDate 改为一个返回闭包的计算属性
63
+    var formattedDate: (Date) -> String {
64
+        return { date in
65
+            let formatter = DateFormatter()
66
+            
67
+            if Calendar.current.isDateInToday(date) {
68
+                return "今天"
69
+            } else if Calendar.current.isDateInYesterday(date) {
70
+                return "昨天"
71
+            } else {
72
+                formatter.dateFormat = "MM月dd日"
73
+                return formatter.string(from: date)
74
+            }
75
+        }
76
+    }
77
+}

+ 348
- 0
LotteryTracker/Views/AddTicket/AddTicketView.swift 查看文件

@@ -0,0 +1,348 @@
1
+//
2
+//  Untitled.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+internal import CoreData
10
+
11
+struct AddTicketView: View {
12
+    @Environment(\.dismiss) private var dismiss
13
+    @Environment(\.managedObjectContext) private var viewContext
14
+    @Environment(\.colorScheme) private var colorScheme
15
+    
16
+    @StateObject private var viewModel = AddTicketViewModel()
17
+    @FocusState private var focusedField: Field?
18
+    
19
+    enum Field {
20
+        case numbers
21
+        case amount
22
+    }
23
+    
24
+    var body: some View {
25
+        NavigationView {
26
+            ZStack {
27
+                // 背景
28
+                backgroundColor
29
+                    .ignoresSafeArea()
30
+                
31
+                ScrollView {
32
+                    VStack(spacing: 24) {
33
+                        // 彩票类型选择
34
+                        LotteryTypePicker(selectedType: $viewModel.selectedType)
35
+                            .padding(.horizontal)
36
+                        
37
+                        // 投注号码输入
38
+                        numbersInputSection
39
+                            .padding(.horizontal)
40
+                        
41
+                        // 金额输入
42
+                        AmountInputView(
43
+                            betCount: $viewModel.betCount,
44
+                            amountPerBet: $viewModel.amountPerBet,
45
+                            totalAmount: viewModel.totalAmount
46
+                        )
47
+                        .padding(.horizontal)
48
+                        
49
+                        // 购买日期选择(替换开奖日期)
50
+                        purchaseDateSection
51
+                            .padding(.horizontal)
52
+                        
53
+                        // 错误提示
54
+                        if !viewModel.errorMessage.isEmpty {
55
+                            errorMessageView
56
+                                .padding(.horizontal)
57
+                        }
58
+                        
59
+                        // 操作按钮
60
+                        actionButtons
61
+                            .padding(.horizontal)
62
+                            .padding(.top, 20)
63
+                    }
64
+                    .padding(.vertical)
65
+                }
66
+            }
67
+            .navigationTitle("添加彩票记录")
68
+            .navigationBarTitleDisplayMode(.inline)
69
+            .toolbar {
70
+                ToolbarItem(placement: .navigationBarLeading) {
71
+                    Button("取消") {
72
+                        dismiss()
73
+                    }
74
+                }
75
+                
76
+                ToolbarItemGroup(placement: .keyboard) {
77
+                    Spacer()
78
+                    
79
+                    Button("完成") {
80
+                        focusedField = nil
81
+                    }
82
+                }
83
+            }
84
+            .onTapGesture {
85
+                // 点击空白处隐藏键盘
86
+                focusedField = nil
87
+            }
88
+        }
89
+    }
90
+    
91
+    // MARK: - 组件
92
+    
93
+    // 背景颜色
94
+    private var backgroundColor: Color {
95
+        colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.3)
96
+    }
97
+    
98
+    // 投注号码输入
99
+    private var numbersInputSection: some View {
100
+        VStack(alignment: .leading, spacing: 12) {
101
+            HStack {
102
+                Text("投注号码")
103
+                    .font(.headline)
104
+                
105
+                Spacer()
106
+                
107
+                Button("随机生成") {
108
+                    viewModel.numbers = generateSampleNumbers(for: viewModel.selectedType)
109
+                }
110
+                .font(.caption)
111
+                .foregroundColor(.blue)
112
+            }
113
+            
114
+            VStack(alignment: .leading, spacing: 8) {
115
+                Text("请输入号码,用空格或逗号分隔")
116
+                    .font(.caption)
117
+                    .foregroundColor(.secondary)
118
+                
119
+                TextEditor(text: $viewModel.numbers)
120
+                    .font(.system(.body, design: .monospaced))
121
+                    .frame(minHeight: 80)
122
+                    .padding(8)
123
+                    .background(Color(.systemBackground))
124
+                    .cornerRadius(8)
125
+                    .overlay(
126
+                        RoundedRectangle(cornerRadius: 8)
127
+                            .stroke(focusedField == .numbers ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1)
128
+                    )
129
+                    .focused($focusedField, equals: .numbers)
130
+                
131
+                Text("例如:01 05 12 23 28 33 + 09")
132
+                    .font(.caption2)
133
+                    .foregroundColor(.secondary)
134
+            }
135
+        }
136
+        .padding()
137
+        .background(Color(.systemBackground))
138
+        .cornerRadius(12)
139
+        .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2)
140
+    }
141
+    
142
+    // 购买日期选择
143
+    private var purchaseDateSection: some View {
144
+        VStack(alignment: .leading, spacing: 12) {
145
+            HStack {
146
+                Text("购买日期")
147
+                    .font(.headline)
148
+                
149
+                Spacer()
150
+                
151
+                Button("今天") {
152
+                    viewModel.purchaseDate = Date()
153
+                }
154
+                .font(.caption)
155
+                .foregroundColor(.blue)
156
+                
157
+                Button("昨天") {
158
+                    viewModel.purchaseDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
159
+                }
160
+                .font(.caption)
161
+                .foregroundColor(.blue)
162
+            }
163
+            
164
+            DatePicker(
165
+                "选择购买日期",
166
+                selection: $viewModel.purchaseDate,
167
+                in: Date().addingTimeInterval(-365 * 24 * 3600)...Date(), // 过去一年到现在
168
+                displayedComponents: .date
169
+            )
170
+            .datePickerStyle(.graphical)
171
+            .padding()
172
+            .background(Color(.systemBackground))
173
+            .cornerRadius(12)
174
+            .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2)
175
+            
176
+            // 开奖信息显示(只读)
177
+            HStack {
178
+                Image(systemName: "calendar.badge.clock")
179
+                    .foregroundColor(.blue)
180
+                
181
+                VStack(alignment: .leading, spacing: 4) {
182
+                    Text("购买时间: \(viewModel.formattedPurchaseDate)")
183
+                        .font(.subheadline)
184
+                        .foregroundColor(.secondary)
185
+                    
186
+                    Text("预计开奖: \(viewModel.formattedDrawDate)")
187
+                        .font(.subheadline)
188
+                        .foregroundColor(.green)
189
+                }
190
+                
191
+                Spacer()
192
+                
193
+                Text("\(daysUntilDraw)天后")
194
+                    .font(.caption)
195
+                    .padding(.horizontal, 8)
196
+                    .padding(.vertical, 4)
197
+                    .background(Color.blue.opacity(0.1))
198
+                    .foregroundColor(.blue)
199
+                    .cornerRadius(6)
200
+            }
201
+            .padding(.horizontal, 4)
202
+        }
203
+        .padding()
204
+        .background(Color(.systemBackground))
205
+        .cornerRadius(12)
206
+        .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2)
207
+    }
208
+    
209
+    // 错误提示
210
+    private var errorMessageView: some View {
211
+        HStack {
212
+            Image(systemName: "exclamationmark.triangle.fill")
213
+                .foregroundColor(.red)
214
+            
215
+            Text(viewModel.errorMessage)
216
+                .font(.subheadline)
217
+                .foregroundColor(.red)
218
+            
219
+            Spacer()
220
+        }
221
+        .padding()
222
+        .background(Color.red.opacity(0.1))
223
+        .cornerRadius(8)
224
+    }
225
+    
226
+    // 操作按钮
227
+    private var actionButtons: some View {
228
+        VStack(spacing: 12) {
229
+            // 保存按钮
230
+            Button(action: saveTicket) {
231
+                HStack {
232
+                    Spacer()
233
+                    
234
+                    Image(systemName: "checkmark.circle.fill")
235
+                    
236
+                    Text("保存记录")
237
+                        .font(.headline)
238
+                    
239
+                    Spacer()
240
+                }
241
+                .padding()
242
+                .background(viewModel.isValid ? Color.blue : Color.gray)
243
+                .foregroundColor(.white)
244
+                .cornerRadius(12)
245
+            }
246
+            .disabled(!viewModel.isValid)
247
+            
248
+            // 测试数据按钮
249
+            Button(action: {
250
+                viewModel.fillSampleData()
251
+            }) {
252
+                HStack {
253
+                    Spacer()
254
+                    
255
+                    Image(systemName: "wand.and.stars")
256
+                    
257
+                    Text("填充测试数据")
258
+                        .font(.subheadline)
259
+                    
260
+                    Spacer()
261
+                }
262
+                .padding()
263
+                .background(Color.green.opacity(0.1))
264
+                .foregroundColor(.green)
265
+                .cornerRadius(12)
266
+            }
267
+            
268
+            // 重置按钮
269
+            Button(action: {
270
+                viewModel.resetForm()
271
+            }) {
272
+                HStack {
273
+                    Spacer()
274
+                    
275
+                    Image(systemName: "arrow.counterclockwise")
276
+                    
277
+                    Text("重置表单")
278
+                        .font(.subheadline)
279
+                    
280
+                    Spacer()
281
+                }
282
+                .padding()
283
+                .background(Color.orange.opacity(0.1))
284
+                .foregroundColor(.orange)
285
+                .cornerRadius(12)
286
+            }
287
+        }
288
+    }
289
+    
290
+    // MARK: - 计算属性
291
+    
292
+    // 距离开奖天数
293
+    private var daysUntilDraw: Int {
294
+        let calendar = Calendar.current
295
+        let components = calendar.dateComponents([.day], from: viewModel.purchaseDate, to: viewModel.drawDate)
296
+        return max(0, components.day ?? 0)
297
+    }
298
+    
299
+    // MARK: - 方法
300
+    
301
+    // 保存彩票记录
302
+    private func saveTicket() {
303
+        if viewModel.saveTicket(context: viewContext) {
304
+            print("✅ 保存成功,关闭添加页面")
305
+            
306
+            // 发送数据更新通知
307
+            NotificationCenter.default.post(name: .ticketsUpdated, object: nil)
308
+            
309
+            dismiss()
310
+        } else {
311
+            print("❌ 保存失败")
312
+        }
313
+    }
314
+    
315
+    // 生成示例号码
316
+    private func generateSampleNumbers(for type: LotteryType) -> String {
317
+        switch type {
318
+        case .doubleColorBall:
319
+            let redBalls = (1...33).shuffled().prefix(6).map { String(format: "%02d", $0) }
320
+            let blueBall = String(format: "%02d", Int.random(in: 1...16))
321
+            return "\(redBalls.joined(separator: " ")) + \(blueBall)"
322
+            
323
+        case .superLotto:
324
+            let frontBalls = (1...35).shuffled().prefix(5).map { String(format: "%02d", $0) }
325
+            let backBalls = (1...12).shuffled().prefix(2).map { String(format: "%02d", $0) }
326
+            return "\(frontBalls.joined(separator: " ")) + \(backBalls.joined(separator: " "))"
327
+            
328
+        case .welfare3D:
329
+            let numbers = (0...9).shuffled().prefix(3).map { String($0) }
330
+            return numbers.joined(separator: " ")
331
+            
332
+        case .sevenStar:
333
+            let numbers = (0...9).shuffled().prefix(7).map { String($0) }
334
+            return numbers.joined(separator: " ")
335
+            
336
+        case .other:
337
+            return "随机号码"
338
+        }
339
+    }
340
+}
341
+
342
+// 预览
343
+struct AddTicketView_Previews: PreviewProvider {
344
+    static var previews: some View {
345
+        AddTicketView()
346
+            .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
347
+    }
348
+}

+ 212
- 0
LotteryTracker/Views/AddTicket/AmountInputView.swift 查看文件

@@ -0,0 +1,212 @@
1
+//
2
+//  AmountInputView.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct AmountInputView: View {
11
+    @Binding var betCount: Int
12
+    @Binding var amountPerBet: String
13
+    @FocusState private var isAmountFocused: Bool
14
+    
15
+    let totalAmount: Double
16
+    
17
+    // 常用注数选项
18
+    private let betCountOptions = [1, 3, 5, 10, 20]
19
+    
20
+    // 常用金额选项
21
+    private let amountOptions = [2.0, 5.0, 10.0, 20.0, 50.0]
22
+    
23
+    var body: some View {
24
+        VStack(alignment: .leading, spacing: 20) {
25
+            // 注数选择
26
+            VStack(alignment: .leading, spacing: 12) {
27
+                Text("注数")
28
+                    .font(.headline)
29
+                
30
+                // 注数选择器
31
+                HStack {
32
+                    Stepper(value: $betCount, in: 1...100) {
33
+                        HStack {
34
+                            Text("\(betCount) 注")
35
+                                .font(.body)
36
+                                .foregroundColor(.primary)
37
+                            
38
+                            Spacer()
39
+                            
40
+                            Text("¥\(totalAmount, specifier: "%.2f")")
41
+                                .font(.body.bold())
42
+                                .foregroundColor(.blue)
43
+                        }
44
+                    }
45
+                }
46
+                
47
+                // 快速选择按钮
48
+                ScrollView(.horizontal, showsIndicators: false) {
49
+                    HStack(spacing: 10) {
50
+                        ForEach(betCountOptions, id: \.self) { count in
51
+                            Button(action: {
52
+                                withAnimation {
53
+                                    betCount = count
54
+                                }
55
+                            }) {
56
+                                Text("\(count)注")
57
+                                    .font(.system(size: 14))
58
+                                    .padding(.horizontal, 12)
59
+                                    .padding(.vertical, 6)
60
+                                    .background(betCount == count ? Color.blue : Color.gray.opacity(0.1))
61
+                                    .foregroundColor(betCount == count ? .white : .primary)
62
+                                    .cornerRadius(8)
63
+                            }
64
+                        }
65
+                    }
66
+                    .padding(.horizontal, 2)
67
+                }
68
+            }
69
+            
70
+            Divider()
71
+            
72
+            // 每注金额
73
+            VStack(alignment: .leading, spacing: 12) {
74
+                Text("每注金额")
75
+                    .font(.headline)
76
+                
77
+                // 金额输入
78
+                HStack {
79
+                    Text("¥")
80
+                        .font(.title3)
81
+                        .foregroundColor(.secondary)
82
+                    
83
+                    TextField("0.00", text: $amountPerBet)
84
+                        .font(.title3)
85
+                        .keyboardType(.decimalPad)
86
+                        .focused($isAmountFocused)
87
+                        .padding(.vertical, 8)
88
+                        .padding(.horizontal, 12)
89
+                        .background(Color(.systemGray6))
90
+                        .cornerRadius(8)
91
+                        .overlay(
92
+                            RoundedRectangle(cornerRadius: 8)
93
+                                .stroke(isAmountFocused ? Color.blue : Color.clear, lineWidth: 1)
94
+                        )
95
+                        .onChange(of: amountPerBet) { oldValue, newValue in
96
+                            // iOS 17 新API:接收 oldValue 和 newValue 两个参数
97
+                            
98
+                            // 限制输入为数字和小数点
99
+                            let filtered = newValue.filter { "0123456789.".contains($0) }
100
+                            if filtered != newValue {
101
+                                amountPerBet = filtered
102
+                            }
103
+                            
104
+                            // 限制小数点后最多两位
105
+                            if let dotIndex = filtered.firstIndex(of: ".") {
106
+                                let decimalPart = filtered[filtered.index(after: dotIndex)...]
107
+                                if decimalPart.count > 2 {
108
+                                    amountPerBet = String(filtered.prefix(upTo: filtered.index(dotIndex, offsetBy: 3)))
109
+                                }
110
+                            }
111
+                        }
112
+                }
113
+                
114
+                // 快速金额按钮
115
+                ScrollView(.horizontal, showsIndicators: false) {
116
+                    HStack(spacing: 10) {
117
+                        ForEach(amountOptions, id: \.self) { amount in
118
+                            Button(action: {
119
+                                withAnimation {
120
+                                    amountPerBet = String(format: "%.2f", amount)
121
+                                }
122
+                            }) {
123
+                                Text("¥\(amount, specifier: "%.0f")")
124
+                                    .font(.system(size: 14))
125
+                                    .padding(.horizontal, 12)
126
+                                    .padding(.vertical, 6)
127
+                                    .background(amountPerBet == String(format: "%.2f", amount) ? Color.green : Color.gray.opacity(0.1))
128
+                                    .foregroundColor(amountPerBet == String(format: "%.2f", amount) ? .white : .primary)
129
+                                    .cornerRadius(8)
130
+                            }
131
+                        }
132
+                    }
133
+                    .padding(.horizontal, 2)
134
+                }
135
+            }
136
+            
137
+            Divider()
138
+            
139
+            // 总计显示
140
+            VStack(alignment: .leading, spacing: 8) {
141
+                Text("总计")
142
+                    .font(.headline)
143
+                
144
+                HStack {
145
+                    VStack(alignment: .leading, spacing: 4) {
146
+                        Text("\(betCount) 注 × ¥\(Double(amountPerBet) ?? 0, specifier: "%.2f")/注")
147
+                            .font(.subheadline)
148
+                            .foregroundColor(.secondary)
149
+                        
150
+                        Text("¥\(totalAmount, specifier: "%.2f")")
151
+                            .font(.system(size: 32, weight: .bold))
152
+                            .foregroundColor(.blue)
153
+                    }
154
+                    
155
+                    Spacer()
156
+                    
157
+                    Image(systemName: "creditcard.fill")
158
+                        .font(.system(size: 36))
159
+                        .foregroundColor(.green.opacity(0.7))
160
+                }
161
+                .padding()
162
+                .background(
163
+                    RoundedRectangle(cornerRadius: 12)
164
+                        .fill(Color.blue.opacity(0.05))
165
+                        .overlay(
166
+                            RoundedRectangle(cornerRadius: 12)
167
+                                .stroke(Color.blue.opacity(0.2), lineWidth: 1)
168
+                        )
169
+                )
170
+            }
171
+        }
172
+        .padding(.vertical, 8)
173
+    }
174
+}
175
+
176
+// 预览
177
+struct AmountInputView_Previews: PreviewProvider {
178
+    static var previews: some View {
179
+        // 方法1:使用简单的 State 包装
180
+        Group {
181
+            // 预览1:默认状态
182
+            AmountInputViewPreviewWrapper()
183
+            
184
+            // 预览2:大额状态
185
+            AmountInputViewPreviewWrapper(betCount: 10, amountPerBet: "50.00")
186
+            
187
+            // 预览3:小数金额
188
+            AmountInputViewPreviewWrapper(betCount: 3, amountPerBet: "2.50")
189
+        }
190
+    }
191
+}
192
+
193
+// 预览包装器
194
+struct AmountInputViewPreviewWrapper: View {
195
+    @State private var betCount: Int
196
+    @State private var amountPerBet: String
197
+    
198
+    init(betCount: Int = 3, amountPerBet: String = "2.00") {
199
+        self._betCount = State(initialValue: betCount)
200
+        self._amountPerBet = State(initialValue: amountPerBet)
201
+    }
202
+    
203
+    var body: some View {
204
+        AmountInputView(
205
+            betCount: $betCount,
206
+            amountPerBet: $amountPerBet,
207
+            totalAmount: (Double(amountPerBet) ?? 0) * Double(betCount)
208
+        )
209
+        .padding()
210
+        .previewLayout(.sizeThatFits)
211
+    }
212
+}

+ 149
- 0
LotteryTracker/Views/AddTicket/LotteryTypePicker.swift 查看文件

@@ -0,0 +1,149 @@
1
+//
2
+//  LotteryTypePicker.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct LotteryTypePicker: View {
11
+    @Binding var selectedType: LotteryType
12
+    
13
+    // 彩票类型描述
14
+    private let typeDescriptions: [LotteryType: String] = [
15
+        .doubleColorBall: "红球6个 + 篮球1个",
16
+        .superLotto: "前区5个 + 后区2个",
17
+        .welfare3D: "3位数字,000-999",
18
+        .sevenStar: "7位数字,每位0-9",
19
+        .other: "其他彩票类型"
20
+    ]
21
+    
22
+    var body: some View {
23
+        VStack(alignment: .leading, spacing: 12) {
24
+            Text("彩票类型")
25
+                .font(.headline)
26
+                .foregroundColor(.primary)
27
+            
28
+            ScrollView(.horizontal, showsIndicators: false) {
29
+                HStack(spacing: 12) {
30
+                    ForEach(LotteryType.allCases) { type in
31
+                        LotteryTypeCard(
32
+                            type: type,
33
+                            description: typeDescriptions[type] ?? "",
34
+                            isSelected: selectedType == type
35
+                        )
36
+                        .onTapGesture {
37
+                            withAnimation(.spring()) {
38
+                                selectedType = type
39
+                            }
40
+                        }
41
+                    }
42
+                }
43
+                .padding(.horizontal, 4)
44
+            }
45
+        }
46
+    }
47
+}
48
+
49
+// 单个彩票类型卡片
50
+struct LotteryTypeCard: View {
51
+    let type: LotteryType
52
+    let description: String
53
+    let isSelected: Bool
54
+    
55
+    var body: some View {
56
+        VStack(spacing: 8) {
57
+            // 图标
58
+            ZStack {
59
+                Circle()
60
+                    .fill(backgroundColor)
61
+                    .frame(width: 60, height: 60)
62
+                
63
+                Image(systemName: iconName)
64
+                    .font(.system(size: 24))
65
+                    .foregroundColor(iconColor)
66
+            }
67
+            
68
+            // 类型名称
69
+            Text(type.rawValue)
70
+                .font(.system(size: 14, weight: .semibold))
71
+                .foregroundColor(isSelected ? .primary : .secondary)
72
+                .lineLimit(1)
73
+                .minimumScaleFactor(0.8)
74
+            
75
+            // 描述
76
+            Text(description)
77
+                .font(.system(size: 10))
78
+                .foregroundColor(.secondary)
79
+                .multilineTextAlignment(.center)
80
+                .lineLimit(2)
81
+                .frame(width: 80)
82
+        }
83
+        .padding(.vertical, 12)
84
+        .padding(.horizontal, 8)
85
+        .background(
86
+            RoundedRectangle(cornerRadius: 12)
87
+                .fill(isSelected ? Color.blue.opacity(0.05) : Color(.systemBackground))
88
+                .overlay(
89
+                    RoundedRectangle(cornerRadius: 12)
90
+                        .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
91
+                )
92
+        )
93
+        .scaleEffect(isSelected ? 1.05 : 1.0)
94
+    }
95
+    
96
+    private var iconName: String {
97
+        switch type {
98
+        case .doubleColorBall: return "circle.grid.2x2.fill"
99
+        case .superLotto: return "8.circle.fill"
100
+        case .welfare3D: return "3.circle.fill"
101
+        case .sevenStar: return "star.fill"
102
+        case .other: return "ticket.fill"
103
+        }
104
+    }
105
+    
106
+    private var iconColor: Color {
107
+        switch type {
108
+        case .doubleColorBall: return .red
109
+        case .superLotto: return .blue
110
+        case .welfare3D: return .green
111
+        case .sevenStar: return .purple
112
+        case .other: return .gray
113
+        }
114
+    }
115
+    
116
+    private var backgroundColor: Color {
117
+        isSelected ? iconColor.opacity(0.1) : Color(.systemGray6)
118
+    }
119
+}
120
+
121
+// 预览
122
+struct LotteryTypePicker_Previews: PreviewProvider {
123
+    static var previews: some View {
124
+        StatefulPreviewWrapper(LotteryType.doubleColorBall) { binding in
125
+            VStack {
126
+                LotteryTypePicker(selectedType: binding)
127
+                    .padding()
128
+                
129
+                Text("当前选择: \(binding.wrappedValue.rawValue)")
130
+                    .padding()
131
+            }
132
+        }
133
+    }
134
+}
135
+
136
+// 用于预览的状态包装器
137
+struct StatefulPreviewWrapper<T, Content: View>: View where Content: View {
138
+    @State var value: T
139
+    let content: (Binding<T>) -> Content
140
+    
141
+    var body: some View {
142
+        content($value)
143
+    }
144
+    
145
+    init(_ value: T, @ViewBuilder content: @escaping (Binding<T>) -> Content) {
146
+        self._value = State(initialValue: value)
147
+        self.content = content
148
+    }
149
+}

+ 61
- 0
LotteryTracker/Views/Common/LoadingView.swift 查看文件

@@ -0,0 +1,61 @@
1
+//
2
+//  LoadingView.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct LoadingView: View {
11
+    var title: String = "加载中..."
12
+    var subtitle: String? = nil
13
+    
14
+    var body: some View {
15
+        VStack(spacing: 16) {
16
+            ProgressView()
17
+                .scaleEffect(1.2)
18
+                .tint(.blue)
19
+            
20
+            Text(title)
21
+                .font(.headline)
22
+                .foregroundColor(.primary)
23
+            
24
+            if let subtitle = subtitle {
25
+                Text(subtitle)
26
+                    .font(.subheadline)
27
+                    .foregroundColor(.secondary)
28
+                    .multilineTextAlignment(.center)
29
+            }
30
+        }
31
+        .padding(40)
32
+        .background(Color(.systemBackground))
33
+        .cornerRadius(16)
34
+        .shadow(color: .black.opacity(0.1), radius: 10)
35
+    }
36
+}
37
+
38
+// 全屏加载视图
39
+struct FullScreenLoadingView: View {
40
+    var body: some View {
41
+        ZStack {
42
+            Color(.systemBackground)
43
+                .ignoresSafeArea()
44
+            
45
+            LoadingView()
46
+        }
47
+    }
48
+}
49
+
50
+// 预览
51
+struct LoadingView_Previews: PreviewProvider {
52
+    static var previews: some View {
53
+        Group {
54
+            LoadingView()
55
+                .previewLayout(.sizeThatFits)
56
+            
57
+            FullScreenLoadingView()
58
+                .previewLayout(.sizeThatFits)
59
+        }
60
+    }
61
+}

+ 167
- 0
LotteryTracker/Views/Common/OnboardingView.swift 查看文件

@@ -0,0 +1,167 @@
1
+//
2
+//  OnboardingView.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct OnboardingView: View {
11
+    @Environment(\.dismiss) var dismiss
12
+    @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
13
+    
14
+    @State private var currentPage = 0
15
+    
16
+    let pages = [
17
+        OnboardingPage(
18
+            title: "欢迎使用彩票记录",
19
+            description: "轻松记录您的彩票购买,追踪中奖情况",
20
+            image: "ticket.fill",
21
+            color: .blue
22
+        ),
23
+        OnboardingPage(
24
+            title: "智能统计",
25
+            description: "详细的数据分析和图表展示,了解您的投注习惯",
26
+            image: "chart.bar.fill",
27
+            color: .green
28
+        ),
29
+        OnboardingPage(
30
+            title: "开奖提醒",
31
+            description: "及时获取开奖通知,不错过任何中奖机会",
32
+            image: "bell.badge.fill",
33
+            color: .orange
34
+        ),
35
+        OnboardingPage(
36
+            title: "安全本地存储",
37
+            description: "所有数据仅保存在您的设备中,保护您的隐私",
38
+            image: "lock.shield.fill",
39
+            color: .purple
40
+        )
41
+    ]
42
+    
43
+    var body: some View {
44
+        ZStack {
45
+            // 背景渐变
46
+            LinearGradient(
47
+                colors: [Color.blue.opacity(0.1), Color.purple.opacity(0.1)],
48
+                startPoint: .topLeading,
49
+                endPoint: .bottomTrailing
50
+            )
51
+            .ignoresSafeArea()
52
+            
53
+            VStack {
54
+                // 跳过按钮
55
+                HStack {
56
+                    Spacer()
57
+                    Button("跳过") {
58
+                        completeOnboarding()
59
+                    }
60
+                    .font(.subheadline)
61
+                    .foregroundColor(.secondary)
62
+                    .padding(.trailing)
63
+                }
64
+                
65
+                // 页面内容
66
+                TabView(selection: $currentPage) {
67
+                    ForEach(0..<pages.count, id: \.self) { index in
68
+                        OnboardingPageView(page: pages[index])
69
+                            .tag(index)
70
+                    }
71
+                }
72
+                .tabViewStyle(.page(indexDisplayMode: .always))
73
+                .indexViewStyle(.page(backgroundDisplayMode: .always))
74
+                
75
+                // 导航按钮
76
+                HStack {
77
+                    if currentPage > 0 {
78
+                        Button("上一步") {
79
+                            withAnimation {
80
+                                currentPage -= 1
81
+                            }
82
+                        }
83
+                        .buttonStyle(.bordered)
84
+                    }
85
+                    
86
+                    Spacer()
87
+                    
88
+                    if currentPage < pages.count - 1 {
89
+                        Button("下一步") {
90
+                            withAnimation {
91
+                                currentPage += 1
92
+                            }
93
+                        }
94
+                        .buttonStyle(.borderedProminent)
95
+                    } else {
96
+                        Button("开始使用") {
97
+                            completeOnboarding()
98
+                        }
99
+                        .buttonStyle(.borderedProminent)
100
+                        .font(.headline)
101
+                    }
102
+                }
103
+                .padding(.horizontal, 40)
104
+                .padding(.bottom, 40)
105
+            }
106
+        }
107
+    }
108
+    
109
+    private func completeOnboarding() {
110
+        hasCompletedOnboarding = true
111
+        dismiss()
112
+    }
113
+}
114
+
115
+// 引导页面模型
116
+struct OnboardingPage {
117
+    let title: String
118
+    let description: String
119
+    let image: String
120
+    let color: Color
121
+}
122
+
123
+// 引导页面视图
124
+struct OnboardingPageView: View {
125
+    let page: OnboardingPage
126
+    
127
+    var body: some View {
128
+        VStack(spacing: 30) {
129
+            Spacer()
130
+            
131
+            // 图标
132
+            Image(systemName: page.image)
133
+                .font(.system(size: 80))
134
+                .foregroundColor(page.color)
135
+                .padding()
136
+                .background(
137
+                    Circle()
138
+                        .fill(page.color.opacity(0.1))
139
+                        .frame(width: 160, height: 160)
140
+                )
141
+            
142
+            // 标题
143
+            Text(page.title)
144
+                .font(.largeTitle)
145
+                .fontWeight(.bold)
146
+                .multilineTextAlignment(.center)
147
+                .padding(.horizontal, 40)
148
+            
149
+            // 描述
150
+            Text(page.description)
151
+                .font(.body)
152
+                .foregroundColor(.secondary)
153
+                .multilineTextAlignment(.center)
154
+                .padding(.horizontal, 40)
155
+            
156
+            Spacer()
157
+            Spacer()
158
+        }
159
+    }
160
+}
161
+
162
+// 预览
163
+struct OnboardingView_Previews: PreviewProvider {
164
+    static var previews: some View {
165
+        OnboardingView()
166
+    }
167
+}

+ 85
- 0
LotteryTracker/Views/Home/EmptyStateView.swift 查看文件

@@ -0,0 +1,85 @@
1
+//
2
+//  EmptyStateView.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct EmptyStateView: View {
11
+    var onAddTapped: (() -> Void)? = nil
12
+    
13
+    var body: some View {
14
+        VStack(spacing: 20) {
15
+            // 图标
16
+            Image(systemName: "ticket")
17
+                .font(.system(size: 70))
18
+                .foregroundColor(.gray.opacity(0.5))
19
+            
20
+            // 标题
21
+            Text("暂无购买记录")
22
+                .font(.title2)
23
+                .fontWeight(.semibold)
24
+                .foregroundColor(.primary)
25
+            
26
+            // 描述
27
+            Text("开始记录您的第一张彩票吧")
28
+                .font(.body)
29
+                .foregroundColor(.secondary)
30
+                .multilineTextAlignment(.center)
31
+            
32
+            // 点击添加按钮
33
+            if let onAddTapped = onAddTapped {
34
+                Button(action: onAddTapped) {
35
+                    VStack(spacing: 8) {
36
+                        Image(systemName: "plus.circle.fill")
37
+                            .font(.title2)
38
+                            .foregroundColor(.blue)
39
+                        
40
+                        Text("点击这里添加")
41
+                            .font(.caption)
42
+                            .foregroundColor(.blue)
43
+                    }
44
+                    .padding()
45
+                    .background(Color.blue.opacity(0.1))
46
+                    .cornerRadius(8)
47
+                }
48
+                .padding(.top, 20)
49
+            } else {
50
+                // 如果没有回调,显示静态提示
51
+                VStack(spacing: 8) {
52
+                    Image(systemName: "plus.circle.fill")
53
+                        .font(.title2)
54
+                        .foregroundColor(.blue.opacity(0.5))
55
+                    
56
+                    Text("点击右上角+按钮添加")
57
+                        .font(.caption)
58
+                        .foregroundColor(.blue.opacity(0.5))
59
+                }
60
+                .padding(.top, 20)
61
+            }
62
+        }
63
+        .padding(.horizontal, 40)
64
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
65
+    }
66
+}
67
+
68
+// 预览
69
+struct EmptyStateView_Previews: PreviewProvider {
70
+    static var previews: some View {
71
+        Group {
72
+            // 预览带按钮的版本
73
+            EmptyStateView(onAddTapped: {
74
+                print("添加按钮被点击")
75
+            })
76
+            .previewLayout(.sizeThatFits)
77
+            .previewDisplayName("带按钮")
78
+            
79
+            // 预览不带按钮的版本
80
+            EmptyStateView()
81
+                .previewLayout(.sizeThatFits)
82
+                .previewDisplayName("不带按钮")
83
+        }
84
+    }
85
+}

+ 159
- 0
LotteryTracker/Views/Home/HomeView.swift 查看文件

@@ -0,0 +1,159 @@
1
+import SwiftUI
2
+internal import CoreData
3
+
4
+struct HomeView: View {
5
+    @Environment(\.managedObjectContext) private var viewContext
6
+    @StateObject private var viewModel: HomeViewModel
7
+    @State private var showingAddTicket = false
8
+    
9
+    private var ticketsUpdatedPublisher: NotificationCenter.Publisher {
10
+        NotificationCenter.default.publisher(for: .ticketsUpdated)
11
+    }
12
+    
13
+    init() {
14
+        // 创建 HomeViewModel 实例
15
+        _viewModel = StateObject(
16
+            wrappedValue: HomeViewModel(context: PersistenceController.shared.container.viewContext)
17
+        )
18
+    }
19
+    
20
+    var body: some View {
21
+        NavigationView {
22
+            VStack(spacing: 0) {
23
+                // 顶部统计卡片
24
+                StatsCard(
25
+                    totalSpent: viewModel.totalSpent,
26
+                    totalProfit: viewModel.totalProfit,
27
+                    totalTickets: viewModel.totalTickets,
28
+                    winningTickets: viewModel.winningTickets,
29
+                    pendingTickets: viewModel.pendingTickets
30
+                )
31
+                .padding(.top)
32
+                
33
+                // 记录列表或空状态
34
+                if viewModel.totalTickets > 0 {
35
+                    ticketList
36
+                } else {
37
+                    EmptyStateView(onAddTapped: {
38
+                        // 触发添加按钮动作
39
+                        showingAddTicket = true
40
+                    })
41
+                    .padding(.top, 80)
42
+                }
43
+                
44
+                Spacer()
45
+            }
46
+            .navigationTitle("彩票记录")
47
+            .navigationBarTitleDisplayMode(.inline)
48
+            .toolbar {
49
+                ToolbarItem(placement: .navigationBarTrailing) {
50
+                    Button(action: { showingAddTicket = true }) {
51
+                        Image(systemName: "plus.circle.fill")
52
+                            .font(.system(size: 22))
53
+                            .foregroundColor(.blue)
54
+                    }
55
+                }
56
+                
57
+                ToolbarItem(placement: .navigationBarLeading) {
58
+                    Button(action: { viewModel.refreshData() }) {
59
+                        Image(systemName: "arrow.clockwise")
60
+                            .foregroundColor(.blue)
61
+                    }
62
+                }
63
+            }
64
+            .sheet(isPresented: $showingAddTicket) {
65
+                AddTicketView()
66
+                    .environment(\.managedObjectContext, viewContext)
67
+            }
68
+            .onAppear {
69
+                viewModel.refreshData()
70
+            }
71
+            // 监听通知,自动刷新
72
+            .onReceive(ticketsUpdatedPublisher) { notification in
73
+                // 检查是否是清空操作
74
+                if let userInfo = notification.userInfo,
75
+                   let isClearOperation = userInfo["isClearOperation"] as? Bool,
76
+                   isClearOperation {
77
+                    print("🔄 收到清空数据通知,强制刷新首页")
78
+                    viewModel.refreshData()
79
+                    
80
+                    // 如果是清空操作,还可以添加额外处理
81
+                    // 比如:显示提示、动画等
82
+                } else {
83
+                    print("🔄 收到数据更新通知,刷新首页")
84
+                    viewModel.refreshData()
85
+                }
86
+            }
87
+        }
88
+    }
89
+    
90
+    // 记录列表
91
+    private var ticketList: some View {
92
+        List {
93
+            ForEach(viewModel.sortedDates, id: \.self) { date in
94
+                Section {
95
+                    ForEach(viewModel.ticketsForDate(date), id: \.id) { ticket in
96
+                        TicketRow(ticket: ticket)
97
+                            .swipeActions(edge: .trailing) {
98
+                                Button(role: .destructive) {
99
+                                    deleteTicket(ticket)
100
+                                } label: {
101
+                                    Label("删除", systemImage: "trash")
102
+                                }
103
+                            }
104
+                    }
105
+                } header: {
106
+                    Text(viewModel.formattedDate(date))
107
+                        .font(.subheadline)
108
+                        .fontWeight(.semibold)
109
+                        .foregroundColor(.secondary)
110
+                        .textCase(nil)
111
+                }
112
+            }
113
+        }
114
+        .listStyle(.plain)
115
+        .refreshable {
116
+            viewModel.refreshData()
117
+        }
118
+    }
119
+    
120
+    // 删除记录
121
+    private func deleteTicket(_ ticket: LotteryTicket) {
122
+        viewContext.delete(ticket)
123
+        
124
+        do {
125
+            try viewContext.save()
126
+            viewModel.refreshData()
127
+            print("✅ 删除成功")
128
+            
129
+            // 发送数据更新通知
130
+            NotificationCenter.default.post(name: .ticketsUpdated, object: nil)
131
+        } catch {
132
+            print("❌ 删除失败: \(error)")
133
+        }
134
+    }
135
+}
136
+
137
+// 预览
138
+struct HomeView_Previews: PreviewProvider {
139
+    static var previews: some View {
140
+        let context = PersistenceController.preview.container.viewContext
141
+        
142
+        // 添加预览数据
143
+        for i in 1...8 {
144
+            let ticket = LotteryTicket(context: context)
145
+            ticket.id = UUID()
146
+            ticket.type = i % 2 == 0 ? "双色球" : "大乐透"
147
+            ticket.numbers = "测试号码 \(i)"
148
+            ticket.betCount = Int16(i % 3 + 1)
149
+            ticket.amount = Double(ticket.betCount) * 2.0
150
+            ticket.date = Date().addingTimeInterval(Double(-i) * 86400)
151
+            ticket.drawDate = Date().addingTimeInterval(Double(i) * 86400)
152
+            ticket.status = i % 3 == 0 ? "待开奖" : (i % 3 == 1 ? "已中奖" : "未中奖")
153
+            ticket.prizeAmount = ticket.status == "已中奖" ? ticket.amount * 5 : 0
154
+        }
155
+        
156
+        return HomeView()
157
+            .environment(\.managedObjectContext, context)
158
+    }
159
+}

+ 134
- 0
LotteryTracker/Views/Home/StatsCard.swift 查看文件

@@ -0,0 +1,134 @@
1
+//
2
+//  StatsCard.swift.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct StatsCard: View {
11
+    let totalSpent: Double
12
+    let totalProfit: Double
13
+    let totalTickets: Int
14
+    let winningTickets: Int
15
+    let pendingTickets: Int
16
+    
17
+    var body: some View {
18
+        VStack(alignment: .leading, spacing: 16) {
19
+            // 顶部统计行
20
+            HStack {
21
+                VStack(alignment: .leading, spacing: 6) {
22
+                    Text("总投入")
23
+                        .font(.caption)
24
+                        .foregroundColor(.secondary)
25
+                    Text("¥\(totalSpent, specifier: "%.2f")")
26
+                        .font(.title2.bold())
27
+                        .foregroundColor(.primary)
28
+                }
29
+                
30
+                Spacer()
31
+                
32
+                VStack(alignment: .trailing, spacing: 6) {
33
+                    Text("总盈亏")
34
+                        .font(.caption)
35
+                        .foregroundColor(.secondary)
36
+                    Text("¥\(totalProfit, specifier: "%.2f")")
37
+                        .font(.title2.bold())
38
+                        .foregroundColor(profitColor)
39
+                }
40
+            }
41
+            
42
+            Divider()
43
+                .background(Color.gray.opacity(0.3))
44
+            
45
+            // 底部统计行
46
+            HStack(spacing: 20) {
47
+                StatItemView(
48
+                    title: "总票数",
49
+                    value: "\(totalTickets)",
50
+                    icon: "ticket.fill",
51
+                    color: .blue
52
+                )
53
+                
54
+                StatItemView(
55
+                    title: "中奖",
56
+                    value: "\(winningTickets)",
57
+                    icon: "checkmark.circle.fill",
58
+                    color: .green
59
+                )
60
+                
61
+                StatItemView(
62
+                    title: "待开奖",
63
+                    value: "\(pendingTickets)",
64
+                    icon: "clock.fill",
65
+                    color: .orange
66
+                )
67
+            }
68
+        }
69
+        .padding(20)
70
+        .background(
71
+            RoundedRectangle(cornerRadius: 16)
72
+                .fill(Color(.systemBackground))
73
+                .shadow(
74
+                    color: Color.black.opacity(0.1),
75
+                    radius: 8,
76
+                    x: 0,
77
+                    y: 4
78
+                )
79
+        )
80
+        .padding(.horizontal)
81
+    }
82
+    
83
+    private var profitColor: Color {
84
+        totalProfit >= 0 ? .green : .red
85
+    }
86
+}
87
+
88
+// 单个统计项目组件
89
+struct StatItemView: View {
90
+    let title: String
91
+    let value: String
92
+    let icon: String
93
+    let color: Color
94
+    
95
+    var body: some View {
96
+        VStack(spacing: 8) {
97
+            // 图标
98
+            ZStack {
99
+                Circle()
100
+                    .fill(color.opacity(0.1))
101
+                    .frame(width: 40, height: 40)
102
+                
103
+                Image(systemName: icon)
104
+                    .font(.system(size: 18))
105
+                    .foregroundColor(color)
106
+            }
107
+            
108
+            // 数值
109
+            Text(value)
110
+                .font(.headline)
111
+            
112
+            // 标题
113
+            Text(title)
114
+                .font(.caption2)
115
+                .foregroundColor(.secondary)
116
+        }
117
+        .frame(maxWidth: .infinity)
118
+    }
119
+}
120
+
121
+// 预览
122
+struct StatsCard_Previews: PreviewProvider {
123
+    static var previews: some View {
124
+        StatsCard(
125
+            totalSpent: 256.0,
126
+            totalProfit: 42.5,
127
+            totalTickets: 12,
128
+            winningTickets: 3,
129
+            pendingTickets: 5
130
+        )
131
+        .padding()
132
+        .previewLayout(.sizeThatFits)
133
+    }
134
+}

+ 168
- 0
LotteryTracker/Views/Home/TicketRow.swift 查看文件

@@ -0,0 +1,168 @@
1
+//
2
+//  TicketRow.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+internal import CoreData
10
+
11
+struct TicketRow: View {
12
+    let ticket: LotteryTicket
13
+    
14
+    var body: some View {
15
+        HStack(spacing: 16) {
16
+            // 左侧图标
17
+            lotteryIcon
18
+            
19
+            // 中间信息
20
+            VStack(alignment: .leading, spacing: 6) {
21
+                HStack {
22
+                    Text(ticket.lotteryType.rawValue)
23
+                        .font(.headline)
24
+                        .foregroundColor(.primary)
25
+                    
26
+                    Spacer()
27
+                    
28
+                    Text("¥\(ticket.amount, specifier: "%.2f")")
29
+                        .font(.headline)
30
+                        .foregroundColor(.primary)
31
+                }
32
+                
33
+                HStack(spacing: 12) {
34
+                    Text("\(ticket.betCount)注")
35
+                        .font(.caption)
36
+                        .foregroundColor(.secondary)
37
+                    
38
+                    // 修复这里:正确检查 numbers 是否为空
39
+                    if let numbers = ticket.numbers, !numbers.isEmpty {
40
+                        Text("·")
41
+                            .foregroundColor(.secondary)
42
+                        
43
+                        Text(numbers)
44
+                            .font(.caption)
45
+                            .foregroundColor(.secondary)
46
+                            .lineLimit(1)
47
+                    }
48
+                    
49
+                    Spacer()
50
+                    
51
+                    statusBadge
52
+                }
53
+            }
54
+        }
55
+        .padding(.vertical, 10)
56
+        .padding(.horizontal, 4)
57
+    }
58
+    
59
+    // 彩票类型图标
60
+    private var lotteryIcon: some View {
61
+        ZStack {
62
+            Circle()
63
+                .fill(iconColor.opacity(0.1))
64
+                .frame(width: 50, height: 50)
65
+            
66
+            Image(systemName: iconName)
67
+                .font(.system(size: 22))
68
+                .foregroundColor(iconColor)
69
+        }
70
+    }
71
+    
72
+    // 状态徽章
73
+    private var statusBadge: some View {
74
+        Text(ticket.ticketStatus.rawValue)
75
+            .font(.system(size: 12, weight: .medium))
76
+            .padding(.horizontal, 10)
77
+            .padding(.vertical, 4)
78
+            .background(statusColor.opacity(0.1))
79
+            .foregroundColor(statusColor)
80
+            .clipShape(Capsule())
81
+    }
82
+    
83
+    // 根据彩票类型选择图标和颜色
84
+    private var iconName: String {
85
+        switch ticket.lotteryType {
86
+        case .doubleColorBall:
87
+            return "circle.grid.2x2.fill"
88
+        case .superLotto:
89
+            return "8.circle.fill"
90
+        case .welfare3D:
91
+            return "3.circle.fill"
92
+        case .sevenStar:
93
+            return "star.fill"
94
+        case .other:
95
+            return "ticket.fill"
96
+        }
97
+    }
98
+    
99
+    private var iconColor: Color {
100
+        switch ticket.lotteryType {
101
+        case .doubleColorBall:
102
+            return .red
103
+        case .superLotto:
104
+            return .blue
105
+        case .welfare3D:
106
+            return .green
107
+        case .sevenStar:
108
+            return .purple
109
+        case .other:
110
+            return .gray
111
+        }
112
+    }
113
+    
114
+    // 根据状态选择颜色
115
+    private var statusColor: Color {
116
+        switch ticket.ticketStatus {
117
+        case .pending:
118
+            return .orange
119
+        case .won:
120
+            return .green
121
+        case .lost:
122
+            return .gray
123
+        }
124
+    }
125
+}
126
+
127
+// 预览
128
+struct TicketRow_Previews: PreviewProvider {
129
+    static var previews: some View {
130
+        let context = PersistenceController.preview.container.viewContext
131
+        
132
+        // 创建预览票券
133
+        let ticket = LotteryTicket(context: context)
134
+        ticket.id = UUID()
135
+        ticket.type = LotteryType.doubleColorBall.rawValue
136
+        ticket.numbers = "01 02 03 04 05 06 07"  // 有号码的情况
137
+        ticket.betCount = 2
138
+        ticket.amount = 4.0
139
+        ticket.date = Date()
140
+        ticket.drawDate = Date().addingTimeInterval(86400)
141
+        ticket.status = TicketStatus.pending.rawValue
142
+        ticket.prizeAmount = 0.0
143
+        
144
+        // 测试无号码的情况
145
+        let ticket2 = LotteryTicket(context: context)
146
+        ticket2.id = UUID()
147
+        ticket2.type = LotteryType.superLotto.rawValue
148
+        ticket2.numbers = ""  // 空字符串
149
+        ticket2.betCount = 1
150
+        ticket2.amount = 2.0
151
+        ticket2.date = Date()
152
+        ticket2.drawDate = Date().addingTimeInterval(86400)
153
+        ticket2.status = TicketStatus.won.rawValue
154
+        ticket2.prizeAmount = 10.0
155
+        
156
+        return Group {
157
+            TicketRow(ticket: ticket)
158
+                .padding()
159
+                .previewLayout(.sizeThatFits)
160
+                .previewDisplayName("有号码")
161
+            
162
+            TicketRow(ticket: ticket2)
163
+                .padding()
164
+                .previewLayout(.sizeThatFits)
165
+                .previewDisplayName("无号码")
166
+        }
167
+    }
168
+}

+ 51
- 0
LotteryTracker/Views/My/MyView.swift 查看文件

@@ -0,0 +1,51 @@
1
+//
2
+//  MyView.swift
3
+//  LotteryTracker
4
+//
5
+//  Created by aaa on 2026/1/22.
6
+//
7
+
8
+import SwiftUI
9
+internal import CoreData
10
+
11
+struct MyView: View {
12
+    @State private var selectedTab = 0
13
+    
14
+    var body: some View {
15
+        NavigationView {
16
+            TabView(selection: $selectedTab) {
17
+                // 统计页面
18
+                StatisticsView()
19
+                    .tabItem {
20
+                        Label("统计", systemImage: "chart.bar.fill")
21
+                    }
22
+                    .tag(0)
23
+                
24
+                // 设置页面
25
+                SettingsView()
26
+                    .tabItem {
27
+                        Label("设置", systemImage: "gearshape.fill")
28
+                    }
29
+                    .tag(1)
30
+            }
31
+            .navigationTitle(tabTitle)
32
+            .navigationBarTitleDisplayMode(.inline)
33
+        }
34
+    }
35
+    
36
+    private var tabTitle: String {
37
+        switch selectedTab {
38
+        case 0: return "数据统计"
39
+        case 1: return "设置"
40
+        default: return "我的"
41
+        }
42
+    }
43
+}
44
+
45
+// 预览
46
+struct MyView_Previews: PreviewProvider {
47
+    static var previews: some View {
48
+        MyView()
49
+            .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
50
+    }
51
+}

+ 279
- 0
LotteryTracker/Views/My/SettingsView.swift 查看文件

@@ -0,0 +1,279 @@
1
+import SwiftUI
2
+internal import CoreData
3
+
4
+struct SettingsView: View {
5
+    @Environment(\.managedObjectContext) private var viewContext
6
+    @EnvironmentObject private var appState: AppState
7
+    
8
+    @AppStorage("enableNotifications") private var enableNotifications = true
9
+    @AppStorage("autoCheckDraw") private var autoCheckDraw = true
10
+    @AppStorage("currencySymbol") private var currencySymbol = "¥"
11
+    
12
+    @State private var showingClearAlert = false
13
+    @State private var showingClearSuccess = false
14
+    @State private var showingClearError = false
15
+    @State private var clearErrorMessage = ""
16
+    @State private var isClearing = false
17
+    
18
+    var body: some View {
19
+        List {
20
+            // 通知设置
21
+            Section("通知设置") {
22
+                Toggle("开启通知提醒", isOn: $enableNotifications)
23
+                
24
+                if enableNotifications {
25
+                    Toggle("启动时检查开奖", isOn: $autoCheckDraw)
26
+                    
27
+                    Button("测试通知") {
28
+                        testNotification()
29
+                    }
30
+                    .foregroundColor(.blue)
31
+                }
32
+            }
33
+            
34
+            // 显示设置
35
+            Section("显示设置") {
36
+                Picker("货币符号", selection: $currencySymbol) {
37
+                    Text("¥").tag("¥")
38
+                    Text("$").tag("$")
39
+                    Text("€").tag("€")
40
+                    Text("£").tag("£")
41
+                }
42
+            }
43
+            
44
+            // 数据管理
45
+            Section("数据管理") {
46
+                Button("导出数据") {
47
+                    exportData()
48
+                }
49
+                .foregroundColor(.blue)
50
+                
51
+                Button("备份数据") {
52
+                    backupData()
53
+                }
54
+                .foregroundColor(.blue)
55
+                
56
+                if isClearing {
57
+                    HStack {
58
+                        ProgressView()
59
+                            .scaleEffect(0.8)
60
+                        Text("正在清空数据...")
61
+                            .foregroundColor(.secondary)
62
+                    }
63
+                } else {
64
+                    Button("清空所有数据") {
65
+                        showingClearAlert = true
66
+                    }
67
+                    .foregroundColor(.red)
68
+                }
69
+            }
70
+            
71
+            // 关于
72
+            Section("关于") {
73
+                HStack {
74
+                    Text("版本")
75
+                    Spacer()
76
+                    Text("1.0.0")
77
+                        .foregroundColor(.secondary)
78
+                }
79
+                
80
+                Button("检查更新") {
81
+                    checkForUpdates()
82
+                }
83
+                .foregroundColor(.blue)
84
+                
85
+                Button("用户协议") {
86
+                    // 显示用户协议
87
+                }
88
+                .foregroundColor(.blue)
89
+                
90
+                Button("隐私政策") {
91
+                    // 显示隐私政策
92
+                }
93
+                .foregroundColor(.blue)
94
+            }
95
+            
96
+            // 开发者选项
97
+            Section {
98
+                NavigationLink("开发者选项") {
99
+                    DeveloperView()
100
+                }
101
+            }
102
+        }
103
+        .navigationTitle("设置")
104
+        .navigationBarTitleDisplayMode(.inline)
105
+        .alert("清空数据", isPresented: $showingClearAlert) {
106
+            Button("取消", role: .cancel) { }
107
+            Button("清空", role: .destructive) {
108
+                clearAllData()
109
+            }
110
+        } message: {
111
+            Text("这将删除所有彩票记录,此操作不可撤销。确定要继续吗?")
112
+        }
113
+        .alert("清空成功", isPresented: $showingClearSuccess) {
114
+            Button("确定", role: .cancel) { }
115
+        } message: {
116
+            Text("所有数据已成功清空")
117
+        }
118
+        .alert("清空失败", isPresented: $showingClearError) {
119
+            Button("确定", role: .cancel) { }
120
+        } message: {
121
+            Text(clearErrorMessage)
122
+        }
123
+    }
124
+    
125
+    // MARK: - 方法
126
+    
127
+    private func testNotification() {
128
+        print("测试通知功能")
129
+        // 这里可以添加测试通知的代码
130
+    }
131
+    
132
+    private func exportData() {
133
+        print("导出数据功能")
134
+    }
135
+    
136
+    private func backupData() {
137
+        print("数据备份功能")
138
+    }
139
+    
140
+    // 清空所有数据(可靠版本)
141
+    private func clearAllData() {
142
+        isClearing = true
143
+        
144
+        // 在后台线程执行删除操作
145
+        DispatchQueue.global(qos: .userInitiated).async {
146
+            let fetchRequest: NSFetchRequest<NSFetchRequestResult> = LotteryTicket.fetchRequest()
147
+            
148
+            do {
149
+                // 先获取所有记录
150
+                if let tickets = try? self.viewContext.fetch(fetchRequest) as? [LotteryTicket] {
151
+                    print("找到 \(tickets.count) 条记录需要删除")
152
+                    
153
+                    // 逐一删除
154
+                    for ticket in tickets {
155
+                        self.viewContext.delete(ticket)
156
+                    }
157
+                    
158
+                    // 保存更改
159
+                    if self.viewContext.hasChanges {
160
+                        try self.viewContext.save()
161
+                        print("✅ 保存删除操作")
162
+                    }
163
+                    
164
+                    // 重置上下文
165
+                    self.viewContext.reset()
166
+                }
167
+                
168
+                DispatchQueue.main.async {
169
+                    self.isClearing = false
170
+                    self.showingClearSuccess = true
171
+                    
172
+                    // 发送数据更新通知,标记为清空操作
173
+                    NotificationCenter.default.post(
174
+                        name: .ticketsUpdated,
175
+                        object: nil,
176
+                        userInfo: ["isClearOperation": true]  // 添加标识
177
+                    )
178
+                    
179
+                    // 可选:发送应用重置通知
180
+                    NotificationCenter.default.post(name: .appReset, object: nil)
181
+                    
182
+                    print("✅ 所有数据已成功清空,已发送清空通知")
183
+                }
184
+            } catch {
185
+                DispatchQueue.main.async {
186
+                    self.isClearing = false
187
+                    self.clearErrorMessage = "清空失败: \(error.localizedDescription)"
188
+                    self.showingClearError = true
189
+                    
190
+                    print("❌ 清空数据失败: \(error)")
191
+                }
192
+            }
193
+        }
194
+    }
195
+    
196
+    private func checkForUpdates() {
197
+        print("检查更新")
198
+    }
199
+}
200
+
201
+// 开发者视图
202
+struct DeveloperView: View {
203
+    @Environment(\.managedObjectContext) private var context
204
+    @State private var showingTestDataAlert = false
205
+    
206
+    var body: some View {
207
+        List {
208
+            Section("测试功能") {
209
+                Button("生成测试数据") {
210
+                    generateTestData()
211
+                }
212
+                
213
+                Button("检查所有开奖") {
214
+                    checkAllDraws()
215
+                }
216
+                
217
+                Button("重置所有设置") {
218
+                    resetSettings()
219
+                }
220
+            }
221
+            
222
+            Section("调试信息") {
223
+                HStack {
224
+                    Text("数据库路径")
225
+                    Spacer()
226
+                    Text(getDatabasePath())
227
+                        .font(.caption)
228
+                        .foregroundColor(.secondary)
229
+                        .lineLimit(1)
230
+                }
231
+                
232
+                Button("显示日志") {
233
+                    showLogs()
234
+                }
235
+            }
236
+        }
237
+        .navigationTitle("开发者选项")
238
+        .alert("测试数据", isPresented: $showingTestDataAlert) {
239
+            Button("确定", role: .cancel) { }
240
+        } message: {
241
+            Text("已生成50条测试数据")
242
+        }
243
+    }
244
+    
245
+    private func generateTestData() {
246
+        print("生成测试数据功能")
247
+        // 这里可以添加生成测试数据的代码
248
+    }
249
+    
250
+    private func checkAllDraws() {
251
+        print("检查所有开奖")
252
+        // 这里可以添加检查开奖的代码
253
+    }
254
+    
255
+    private func resetSettings() {
256
+        if let bundleID = Bundle.main.bundleIdentifier {
257
+            UserDefaults.standard.removePersistentDomain(forName: bundleID)
258
+        }
259
+        print("✅ 所有设置已重置")
260
+    }
261
+    
262
+    private func getDatabasePath() -> String {
263
+        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
264
+        return paths.first?.absoluteString ?? "未知"
265
+    }
266
+    
267
+    private func showLogs() {
268
+        print("显示日志...")
269
+    }
270
+}
271
+
272
+// 预览
273
+struct SettingsView_Previews: PreviewProvider {
274
+    static var previews: some View {
275
+        NavigationView {
276
+            SettingsView()
277
+        }
278
+    }
279
+}

+ 402
- 0
LotteryTracker/Views/My/StatisticsView.swift 查看文件

@@ -0,0 +1,402 @@
1
+import SwiftUI
2
+import Charts
3
+internal import CoreData
4
+
5
+struct StatisticsView: View {
6
+    @Environment(\.managedObjectContext) private var context
7
+    @StateObject private var statsService: StatisticsService
8
+    
9
+    @State private var selectedTimeRange = 30
10
+    @State private var showingTypeDetail = false
11
+    
12
+    private let timeRanges = [7, 30, 90, 365]
13
+    
14
+    init() {
15
+        _statsService = StateObject(
16
+            wrappedValue: StatisticsService(context: PersistenceController.shared.container.viewContext)
17
+        )
18
+    }
19
+    
20
+    var body: some View {
21
+        ScrollView {
22
+            if statsService.isLoading {
23
+                // 显示加载指示器
24
+                VStack {
25
+                    Spacer()
26
+                    ProgressView()
27
+                        .scaleEffect(1.2)
28
+                    Text("加载统计数据中...")
29
+                        .font(.headline)
30
+                        .foregroundColor(.secondary)
31
+                        .padding(.top)
32
+                    Spacer()
33
+                }
34
+                .frame(height: 300)
35
+            } else {
36
+                VStack(spacing: 20) {
37
+                    // 月度对比卡片
38
+                    monthlyComparisonCard
39
+                    
40
+                    // 类型分布
41
+                    typeDistributionCard
42
+                    
43
+                    // 盈亏趋势图
44
+                    profitTrendCard
45
+                    
46
+                    // 统计数据总结
47
+                    statsSummaryCard
48
+                }
49
+                .padding()
50
+            }
51
+        }
52
+        .navigationTitle("数据统计")
53
+        .navigationBarTitleDisplayMode(.inline)
54
+        .task {
55
+            // 使用 task 进行异步加载
56
+            await statsService.loadAllStatistics()
57
+        }
58
+        .refreshable {
59
+            // 下拉刷新
60
+            await statsService.loadAllStatistics()
61
+        }
62
+        // StatisticsService 内部已经处理通知,这里不需要重复处理
63
+        // 但如果需要 UI 响应,可以添加:
64
+        .onReceive(NotificationCenter.default.publisher(for: .ticketsUpdated)) { notification in
65
+            // 检查是否是清空操作
66
+            if let userInfo = notification.userInfo,
67
+               let isClearOperation = userInfo["isClearOperation"] as? Bool,
68
+               isClearOperation {
69
+                print("📊 统计页面:收到清空通知,显示空状态")
70
+                // 这里不需要做额外处理,StatisticsService 已经处理了
71
+            }
72
+        }
73
+    }
74
+    
75
+    // 月度对比卡片
76
+    private var monthlyComparisonCard: some View {
77
+        VStack(alignment: .leading, spacing: 16) {
78
+            HStack {
79
+                Text("月度对比")
80
+                    .font(.headline)
81
+                
82
+                Spacer()
83
+                
84
+                Text("本月 vs 上月")
85
+                    .font(.caption)
86
+                    .foregroundColor(.secondary)
87
+            }
88
+            
89
+            HStack(spacing: 20) {
90
+                StatItem(
91
+                    title: "本月投入",
92
+                    value: "¥\(statsService.monthlyStats.currentMonth.totalSpent, default: "%.2f")",
93
+                    color: .blue
94
+                )
95
+                
96
+                StatItem(
97
+                    title: "本月盈亏",
98
+                    value: "¥\(statsService.monthlyStats.currentMonth.totalProfit, default: "%.2f")",
99
+                    color: statsService.monthlyStats.currentMonth.totalProfit >= 0 ? .green : .red
100
+                )
101
+                
102
+                StatItem(
103
+                    title: "变化率",
104
+                    value: "\(statsService.monthlyStats.profitChange, default: "%.1f")%",
105
+                    color: statsService.monthlyStats.profitChange >= 0 ? .green : .red
106
+                )
107
+            }
108
+        }
109
+        .padding()
110
+        .background(Color(.systemBackground))
111
+        .cornerRadius(12)
112
+        .shadow(color: .black.opacity(0.05), radius: 5)
113
+    }
114
+    
115
+    // 类型分布卡片
116
+    private var typeDistributionCard: some View {
117
+        VStack(alignment: .leading, spacing: 16) {
118
+            HStack {
119
+                Text("类型分布")
120
+                    .font(.headline)
121
+                
122
+                Spacer()
123
+                
124
+                Button("详情") {
125
+                    showingTypeDetail = true
126
+                }
127
+                .font(.caption)
128
+                .foregroundColor(.blue)
129
+            }
130
+            
131
+            if statsService.typeDistribution.isEmpty {
132
+                emptyStateView(title: "暂无类型分布数据")
133
+            } else {
134
+                Chart(statsService.typeDistribution) { item in
135
+                    SectorMark(
136
+                        angle: .value("金额", item.amount),
137
+                        innerRadius: .ratio(0.5),
138
+                        angularInset: 1
139
+                    )
140
+                    .foregroundStyle(by: .value("类型", item.type.rawValue))
141
+                    .annotation(position: .overlay) {
142
+                        Text("\(item.percentage, specifier: "%.0f")%")
143
+                            .font(.caption2)
144
+                            .foregroundColor(.white)
145
+                    }
146
+                }
147
+                .frame(height: 200)
148
+            }
149
+        }
150
+        .padding()
151
+        .background(Color(.systemBackground))
152
+        .cornerRadius(12)
153
+        .shadow(color: .black.opacity(0.05), radius: 5)
154
+        .sheet(isPresented: $showingTypeDetail) {
155
+            TypeDistributionDetailView(distribution: statsService.typeDistribution)
156
+        }
157
+    }
158
+    
159
+    // 盈亏趋势卡片
160
+    private var profitTrendCard: some View {
161
+        VStack(alignment: .leading, spacing: 16) {
162
+            HStack {
163
+                Text("盈亏趋势")
164
+                    .font(.headline)
165
+                
166
+                Spacer()
167
+                
168
+                Picker("时间范围", selection: $selectedTimeRange) {
169
+                    ForEach(timeRanges, id: \.self) { days in
170
+                        Text("\(days)天").tag(days)
171
+                    }
172
+                }
173
+                .pickerStyle(.segmented)
174
+                .frame(width: 200)
175
+            }
176
+            
177
+            if statsService.profitTrend.isEmpty {
178
+                emptyStateView(title: "暂无趋势数据")
179
+            } else {
180
+                Chart(statsService.profitTrend) { item in
181
+                    LineMark(
182
+                        x: .value("日期", item.date, unit: .day),
183
+                        y: .value("盈亏", item.profit)
184
+                    )
185
+                    .foregroundStyle(.blue)
186
+                    
187
+                    AreaMark(
188
+                        x: .value("日期", item.date, unit: .day),
189
+                        y: .value("盈亏", item.profit)
190
+                    )
191
+                    .foregroundStyle(
192
+                        LinearGradient(
193
+                            colors: [.blue.opacity(0.3), .blue.opacity(0.1)],
194
+                            startPoint: .top,
195
+                            endPoint: .bottom
196
+                        )
197
+                    )
198
+                }
199
+                .frame(height: 200)
200
+                .chartXAxis {
201
+                    AxisMarks(values: .stride(by: .day, count: 7)) { value in
202
+                        AxisGridLine()
203
+                        AxisTick()
204
+                        AxisValueLabel {
205
+                            if let date = value.as(Date.self) {
206
+                                Text(date, format: .dateTime.month().day())
207
+                                    .font(.caption2)
208
+                            }
209
+                        }
210
+                    }
211
+                }
212
+            }
213
+        }
214
+        .padding()
215
+        .background(Color(.systemBackground))
216
+        .cornerRadius(12)
217
+        .shadow(color: .black.opacity(0.05), radius: 5)
218
+    }
219
+    
220
+    // 统计总结卡片
221
+    private var statsSummaryCard: some View {
222
+        VStack(alignment: .leading, spacing: 16) {
223
+            Text("数据总结")
224
+                .font(.headline)
225
+            
226
+            Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 12) {
227
+                GridRow {
228
+                    StatSummaryItem(
229
+                        icon: "ticket.fill",
230
+                        title: "总票数",
231
+                        value: "\(statsService.monthlyStats.currentMonth.ticketCount)",
232
+                        color: .blue
233
+                    )
234
+                    
235
+                    StatSummaryItem(
236
+                        icon: "percent",
237
+                        title: "中奖率",
238
+                        value: "\(statsService.monthlyStats.currentMonth.winRate, default: "%.1f")%",
239
+                        color: .green
240
+                    )
241
+                }
242
+                
243
+                GridRow {
244
+                    StatSummaryItem(
245
+                        icon: "dollarsign.circle.fill",
246
+                        title: "平均投注",
247
+                        value: "¥\(statsService.monthlyStats.currentMonth.averageBet, default: "%.2f")",
248
+                        color: .orange
249
+                    )
250
+                    
251
+                    StatSummaryItem(
252
+                        icon: "chart.line.uptrend.xyaxis",
253
+                        title: "最佳类型",
254
+                        value: bestTypeName,
255
+                        color: .purple
256
+                    )
257
+                }
258
+            }
259
+        }
260
+        .padding()
261
+        .background(Color(.systemBackground))
262
+        .cornerRadius(12)
263
+        .shadow(color: .black.opacity(0.05), radius: 5)
264
+    }
265
+    
266
+    // 计算最佳类型
267
+    private var bestTypeName: String {
268
+        if let bestType = statsService.typeDistribution.first {
269
+            return bestType.type.rawValue
270
+        }
271
+        return "无数据"
272
+    }
273
+    
274
+    // 空状态视图
275
+    private func emptyStateView(title: String) -> some View {
276
+        VStack(spacing: 12) {
277
+            Image(systemName: "chart.bar.doc.horizontal")
278
+                .font(.system(size: 40))
279
+                .foregroundColor(.gray.opacity(0.5))
280
+            
281
+            Text(title)
282
+                .font(.subheadline)
283
+                .foregroundColor(.secondary)
284
+            
285
+            Text("添加更多彩票记录以查看统计")
286
+                .font(.caption)
287
+                .foregroundColor(.gray)
288
+        }
289
+        .frame(height: 150)
290
+        .frame(maxWidth: .infinity)
291
+    }
292
+}
293
+
294
+// 类型分布详情视图
295
+struct TypeDistributionDetailView: View {
296
+    let distribution: [TypeDistribution]
297
+    
298
+    var body: some View {
299
+        NavigationView {
300
+            List(distribution) { item in
301
+                HStack {
302
+                    // 颜色标识
303
+                    Circle()
304
+                        .fill(colorForType(item.type))
305
+                        .frame(width: 12, height: 12)
306
+                    
307
+                    Text(item.type.rawValue)
308
+                        .font(.body)
309
+                    
310
+                    Spacer()
311
+                    
312
+                    VStack(alignment: .trailing) {
313
+                        Text("¥\(item.amount, specifier: "%.2f")")
314
+                            .font(.headline)
315
+                        
316
+                        Text("\(item.percentage, specifier: "%.1f")%")
317
+                            .font(.caption)
318
+                            .foregroundColor(.secondary)
319
+                    }
320
+                }
321
+                .padding(.vertical, 4)
322
+            }
323
+            .navigationTitle("类型分布详情")
324
+            .navigationBarTitleDisplayMode(.inline)
325
+        }
326
+    }
327
+    
328
+    private func colorForType(_ type: LotteryType) -> Color {
329
+        switch type {
330
+        case .doubleColorBall: return .red
331
+        case .superLotto: return .blue
332
+        case .welfare3D: return .green
333
+        case .sevenStar: return .purple
334
+        case .other: return .gray
335
+        }
336
+    }
337
+}
338
+
339
+// 统计项目组件
340
+struct StatItem: View {
341
+    let title: String
342
+    let value: String
343
+    let color: Color
344
+    
345
+    var body: some View {
346
+        VStack(spacing: 6) {
347
+            Text(title)
348
+                .font(.caption)
349
+                .foregroundColor(.secondary)
350
+            
351
+            Text(value)
352
+                .font(.system(size: 16, weight: .semibold))
353
+                .foregroundColor(color)
354
+        }
355
+        .frame(maxWidth: .infinity)
356
+    }
357
+}
358
+
359
+// 统计总结项目
360
+struct StatSummaryItem: View {
361
+    let icon: String
362
+    let title: String
363
+    let value: String
364
+    let color: Color
365
+    
366
+    var body: some View {
367
+        HStack(spacing: 12) {
368
+            // 图标
369
+            ZStack {
370
+                Circle()
371
+                    .fill(color.opacity(0.1))
372
+                    .frame(width: 40, height: 40)
373
+                
374
+                Image(systemName: icon)
375
+                    .font(.system(size: 18))
376
+                    .foregroundColor(color)
377
+            }
378
+            
379
+            // 文本
380
+            VStack(alignment: .leading, spacing: 2) {
381
+                Text(title)
382
+                    .font(.caption)
383
+                    .foregroundColor(.secondary)
384
+                
385
+                Text(value)
386
+                    .font(.system(size: 16, weight: .semibold))
387
+                    .foregroundColor(.primary)
388
+            }
389
+        }
390
+        .frame(maxWidth: .infinity, alignment: .leading)
391
+    }
392
+}
393
+
394
+// 预览
395
+struct StatisticsView_Previews: PreviewProvider {
396
+    static var previews: some View {
397
+        NavigationView {
398
+            StatisticsView()
399
+                .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
400
+        }
401
+    }
402
+}