├── .gitignore
├── LICENSE
├── README.md
├── README_CN.md
├── V2EX.xcodeproj
├── project.pbxproj
└── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ ├── IDEWorkspaceChecks.plist
│ └── WorkspaceSettings.xcsettings
├── V2EX
├── AppState.swift
├── Assets.xcassets
│ ├── 256.imageset
│ │ ├── 256@2x.png
│ │ ├── 256@3x.png
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── 256.png
│ │ └── Contents.json
│ ├── ColorTheme
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── BackgroundColor.colorset
│ │ │ └── Contents.json
│ │ ├── BodyColor.colorset
│ │ │ └── Contents.json
│ │ ├── BorderColor.colorset
│ │ │ └── Contents.json
│ │ ├── CaptionColor.colorset
│ │ │ └── Contents.json
│ │ ├── Contents.json
│ │ ├── DangerColor.colorset
│ │ │ └── Contents.json
│ │ ├── DarkColor.colorset
│ │ │ └── Contents.json
│ │ ├── DisableColor.colorset
│ │ │ └── Contents.json
│ │ ├── PrimaryDarkColor.colorset
│ │ │ └── Contents.json
│ │ ├── SecondaryColor.colorset
│ │ │ └── Contents.json
│ │ ├── SkeletonColor.colorset
│ │ │ └── Contents.json
│ │ ├── SurfaceColor.colorset
│ │ │ └── Contents.json
│ │ ├── TabBarButtonSelectedColor.colorset
│ │ │ └── Contents.json
│ │ └── TitleColor.colorset
│ │ │ └── Contents.json
│ ├── Contents.json
│ ├── IconFont
│ │ └── Contents.json
│ ├── logo.github.imageset
│ │ ├── Contents.json
│ │ └── logo.github@@2x.png
│ └── logo.twitter.imageset
│ │ ├── Contents.json
│ │ └── logo.twitter@2x.png
├── Core
│ ├── Components
│ │ ├── IconImageView.swift
│ │ ├── MenuTabView.swift
│ │ ├── NodeImage
│ │ │ ├── NodeImageView.swift
│ │ │ └── NodeImageViewModel.swift
│ │ ├── SearchBarView.swift
│ │ ├── TopicListView.swift
│ │ ├── TopicRowView.swift
│ │ └── UserImage
│ │ │ ├── UserImageView.swift
│ │ │ └── UserImageViewModel.swift
│ ├── CoreData
│ │ ├── CoreDataManager.swift
│ │ ├── NodeEntity.swift
│ │ └── V2EXContainer.xcdatamodeld
│ │ │ └── V2EXContainer.xcdatamodel
│ │ │ └── contents
│ ├── Detail
│ │ ├── ViewModels
│ │ │ └── DetailViewModel.swift
│ │ └── Views
│ │ │ ├── DetailView.swift
│ │ │ └── ReplyRowView.swift
│ ├── Home
│ │ ├── ViewModels
│ │ │ ├── HomeViewModel.swift
│ │ │ └── SiteStatViewModel.swift
│ │ └── Views
│ │ │ ├── HomeView.swift
│ │ │ ├── SidebarView.swift
│ │ │ └── SiteStatView.swift
│ ├── Login
│ │ ├── ViewModels
│ │ │ └── LoginViewModel.swift
│ │ └── Views
│ │ │ └── LoginView.swift
│ ├── Models
│ │ ├── MemberModel.swift
│ │ ├── NodeModel.swift
│ │ ├── NotificationModel.swift
│ │ ├── ReplyModel.swift
│ │ ├── SiteStatModel.swift
│ │ └── TopicModel.swift
│ ├── My
│ │ ├── ViewModels
│ │ │ ├── MyProfileViewModel.swift
│ │ │ ├── MyTopicViewModel.swift
│ │ │ └── MyViewModel.swift
│ │ └── Views
│ │ │ ├── LicenseView.swift
│ │ │ ├── MyProfileView.swift
│ │ │ ├── MyTopicView.swift
│ │ │ ├── MyView.swift
│ │ │ ├── PrivacyView.swift
│ │ │ ├── ProfileRowView.swift
│ │ │ ├── SettingLanguageView.swift
│ │ │ ├── SettingThemeView.swift
│ │ │ └── TermsServiceView.swift
│ ├── Node
│ │ ├── ViewModels
│ │ │ ├── NodeDetailViewModel.swift
│ │ │ └── NodeViewModel.swift
│ │ └── Views
│ │ │ ├── NodeDetailView.swift
│ │ │ ├── NodeRowView.swift
│ │ │ ├── NodeSearchView.swift
│ │ │ ├── NodeSectionView.swift
│ │ │ └── NodeView.swift
│ └── Notification
│ │ ├── ViewModels
│ │ └── NotificationViewModel.swift
│ │ └── Views
│ │ ├── NotifcationRowView.swift
│ │ └── NotificationView.swift
├── Extension
│ ├── Color.swift
│ ├── Date.swift
│ ├── Double.swift
│ ├── PreviewProvider.swift
│ ├── String.swift
│ └── UIApplication.swift
├── Info.plist
├── Lib
│ └── SwiftUIFlow
│ │ ├── API
│ │ ├── Flow.swift
│ │ ├── HFlow.swift
│ │ └── VFlow.swift
│ │ └── Internal
│ │ └── FlowLayout.swift
├── Modifier
│ ├── EmptyViewModifier.swift
│ ├── NavigationBarModifiler.swift
│ └── ToastViewModifier.swift
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Representable
│ ├── MailView.swift
│ └── ShareSheet.swift
├── Services
│ ├── MemberDataService.swift
│ ├── NodeDataService.swift
│ ├── NodeImageDataService.swift
│ ├── NotifyDataService.swift
│ ├── ReplyDataService.swift
│ ├── StatDataService.swift
│ ├── TopicDataService.swift
│ └── UserImageDataService.swift
├── TabContentView.swift
├── Utilties
│ ├── LocalFileManager.swift
│ ├── NetworkingManager.swift
│ └── V2EXLocalizable.xcstrings
├── V2EX.entitlements
└── V2EXApp.swift
└── pics
├── dark_screenshot.jpeg
└── light_screenshot.jpeg
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## Obj-C/Swift specific
9 | *.hmap
10 |
11 | ## App packaging
12 | *.ipa
13 | *.dSYM.zip
14 | *.dSYM
15 |
16 | ## Playgrounds
17 | timeline.xctimeline
18 | playground.xcworkspace
19 |
20 | # Swift Package Manager
21 | #
22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
23 | # Packages/
24 | # Package.pins
25 | # Package.resolved
26 | # *.xcodeproj
27 | #
28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
29 | # hence it is not needed unless you have added a package configuration file to your project
30 | # .swiftpm
31 |
32 | .build/
33 |
34 | # CocoaPods
35 | #
36 | # We recommend against adding the Pods directory to your .gitignore. However
37 | # you should judge for yourself, the pros and cons are mentioned at:
38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
39 | #
40 | # Pods/
41 | #
42 | # Add this line if you want to avoid checking in source code from the Xcode workspace
43 | # *.xcworkspace
44 |
45 | # Carthage
46 | #
47 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
48 | # Carthage/Checkouts
49 |
50 | Carthage/Build/
51 |
52 | # fastlane
53 | #
54 | # It is recommended to not store the screenshots in the git repo.
55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
56 | # For more information about the recommended setup visit:
57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
58 |
59 | fastlane/report.xml
60 | fastlane/Preview.html
61 | fastlane/screenshots/**/*.png
62 | fastlane/test_output
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Aaron
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # V2EX App
2 |
3 | A modern **V2EX** client built with **SwiftUI**, leveraging **Combine** and the **MVVM** architectural pattern for a seamless and reactive user experience.
4 |
5 | ## Features
6 |
7 | - **SwiftUI** for a fully native and declarative UI design.
8 | - **Combine** to handle reactive programming and asynchronous data streams.
9 | - **MVVM** architecture for a clean separation of concerns and testable code.
10 | - Custom caching mechanism using local storage.
11 | - Multi-language support, including English and Simplified Chinese.
12 | - System-wide **dark mode** and **light mode** integration.
13 |
14 | ## Language
15 |
16 | - [中文文档](./README_CN.md)
17 |
18 |
19 | ## Screenshots
20 |
21 | 
22 |
23 | 
24 |
25 |
26 | ## Installation
27 |
28 | 1. Clone the repository:
29 | ```bash
30 | git clone https://github.com/Aaron0927/V2EX.git
31 | ```
32 | 2. Open the `.xcodeproj` file in Xcode.
33 | 3. Select your desired simulator or device.
34 | 4. Build and run the project.
35 |
36 | ## Technologies Used
37 |
38 | - **SwiftUI**: A modern declarative framework for building user interfaces across all Apple platforms.
39 | - **Combine**: A framework for handling asynchronous events by combining declarative Swift APIs.
40 | - **MVVM**: Clean and scalable architecture, enabling the clear separation of UI logic from business logic.
41 | - **Local Storage**: Custom file management system for caching images and user data efficiently.
42 | - **AppStorage & UserDefaults**: Persist lightweight app settings and preferences.
43 | - **Networking**: Fetch data from the V2EX API in a reactive and efficient manner.
44 |
45 | ## Project Structure
46 |
47 | The app is organized into the following directories:
48 |
49 | - `Models`: Contains the data models used throughout the app.
50 | - `ViewModels`: Contains the business logic and state management using Combine.
51 | - `Views`: Contains SwiftUI views that make up the app's user interface.
52 | - `Managers`: Utility classes like file management, networking, and caching.
53 |
54 | ## How It Works
55 |
56 | 1. **Data Binding**:
57 |
58 | - App state and user data are stored in ObservableObjects and synchronized via `@Published` properties.
59 | - SwiftUI views automatically react to state changes, eliminating the need for manual UI updates.
60 |
61 | 2. **Networking**:
62 |
63 | - Network requests are handled using URLSession with Combine's `Publisher` pipelines for asynchronous tasks.
64 |
65 | 3. **Caching**:
66 |
67 | - Images and other data are cached locally using a custom caching manager.
68 |
69 | 4. **Theme Support**:
70 |
71 | - Fully supports system theme settings (dark/light mode) and allows users to toggle manually within the app.
72 |
73 | ## Contribution
74 |
75 | We welcome contributions to improve the app. To contribute:
76 |
77 | 1. Fork the repository.
78 | 2. Create a feature branch:
79 | ```bash
80 | git checkout -b feature-name
81 | ```
82 | 3. Commit your changes and push to your fork:
83 | ```bash
84 | git push origin feature-name
85 | ```
86 | 4. Open a pull request to the `main` branch.
87 |
88 | ## License
89 |
90 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
91 |
92 | ## Acknowledgments
93 |
94 | - Thanks to the **V2EX** community for providing an amazing platform.
95 | - Apple for the incredible SwiftUI and Combine frameworks.
96 |
97 | ---
98 |
99 | Feel free to report issues or suggest features by opening an issue in the repository: [V2EX GitHub](https://github.com/Aaron0927/V2EX/).
100 |
101 |
--------------------------------------------------------------------------------
/README_CN.md:
--------------------------------------------------------------------------------
1 | # V2EX 应用
2 |
3 | 一个使用 **SwiftUI** 构建的现代化 **V2EX** 客户端,结合 **Combine** 和 **MVVM** 架构,为用户带来流畅的响应式体验。
4 |
5 | ## 功能特点
6 | - 使用 **SwiftUI** 构建完全原生的声明式 UI。
7 | - 基于 **Combine** 处理响应式编程和异步数据流。
8 | - **MVVM** 架构,清晰的分层设计和可测试代码。
9 | - 自定义缓存机制,支持本地存储。
10 | - 多语言支持,包括英语和简体中文。
11 | - 深色模式和浅色模式的无缝集成。
12 |
13 | ## 截图
14 |
15 | 
16 |
17 | 
18 |
19 | ## 安装方法
20 |
21 | 1. 克隆项目代码:
22 | ```bash
23 | git clone https://github.com/Aaron0927/V2EX.git
24 | ```
25 | 2. 在 Xcode 中打开 `.xcodeproj` 文件。
26 | 3. 选择目标模拟器或设备。
27 | 4. 构建并运行项目。
28 |
29 | ## 使用技术
30 |
31 | - **SwiftUI**: 用于构建跨 Apple 平台的现代声明式用户界面。
32 | - **Combine**: 处理异步事件的响应式框架。
33 | - **MVVM**: 干净且可扩展的架构,分离 UI 逻辑与业务逻辑。
34 | - **本地存储**: 使用自定义文件管理系统高效缓存图像和用户数据。
35 | - **AppStorage 和 UserDefaults**: 持久化轻量级的应用设置和偏好。
36 | - **网络请求**: 使用 V2EX API 高效地获取数据。
37 |
38 | ## 项目结构
39 |
40 | 应用按照以下目录组织:
41 |
42 | - `Models`:包含应用中的数据模型。
43 | - `ViewModels`:使用 Combine 管理业务逻辑和状态。
44 | - `Views`:包含构成应用用户界面的 SwiftUI 视图。
45 | - `Managers`:包含实用类,如文件管理、网络请求和缓存。
46 |
47 | ## 工作原理
48 |
49 | 1. **数据绑定**:
50 | - 应用状态和用户数据存储在 ObservableObject 中,通过 `@Published` 属性进行同步。
51 | - SwiftUI 视图自动响应状态变化,无需手动更新 UI。
52 |
53 | 2. **网络请求**:
54 | - 使用 URLSession 和 Combine 的 `Publisher` 管道处理异步任务。
55 |
56 | 3. **缓存**:
57 | - 使用自定义缓存管理器将图像和其他数据保存在本地。
58 |
59 | 4. **主题支持**:
60 | - 完全支持系统主题设置(深色/浅色模式),并允许用户在应用中手动切换。
61 |
62 | ## 贡献
63 |
64 | 欢迎为此项目做出贡献,贡献方法如下:
65 | 1. Fork 此仓库。
66 | 2. 创建一个新分支:
67 | ```bash
68 | git checkout -b feature-name
69 | ```
70 | 3. 提交更改并推送到您的 Fork:
71 | ```bash
72 | git push origin feature-name
73 | ```
74 | 4. 向 `main` 分支发起 Pull Request。
75 |
76 | ## 许可证
77 |
78 | 此项目使用 MIT 许可证授权。详情请参阅 [LICENSE](LICENSE) 文件。
79 |
80 | ## 鸣谢
81 |
82 | - 感谢 **V2EX** 社区提供了一个优秀的平台。
83 | - 感谢 Apple 提供出色的 SwiftUI 和 Combine 框架。
84 |
85 | ---
86 | 欢迎在仓库中通过 [V2EX GitHub](https://github.com/Aaron0927/V2EX/) 提交问题或提出功能建议。
87 |
88 |
--------------------------------------------------------------------------------
/V2EX.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/V2EX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/V2EX.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/V2EX/AppState.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppState.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/10.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | /// 全局数据模型
13 | class AppState: ObservableObject {
14 | @Published var member: MemberModel? = nil
15 |
16 | @AppStorage("user") var userInfo: String = ""
17 | @AppStorage("token") var token: String = ""
18 | @AppStorage("theme") var theme: Theme = .auto {
19 | didSet {
20 | switch theme {
21 | case .auto:
22 | UIApplication.window?.overrideUserInterfaceStyle = .unspecified
23 | case .light:
24 | UIApplication.window?.overrideUserInterfaceStyle = .light
25 | case .dark:
26 | UIApplication.window?.overrideUserInterfaceStyle = .dark
27 | }
28 | }
29 | }
30 | @AppStorage("language") var language: AppLanguage = .auto
31 |
32 | init() {
33 | self.member = getMember()
34 | }
35 |
36 | func getMember() -> MemberModel? {
37 | guard !userInfo.isEmpty,
38 | let data = userInfo.data(using: .utf8)
39 | else { return nil }
40 | do {
41 | let member = try JSONDecoder().decode(MemberModel.self, from: data)
42 | return member
43 | } catch {
44 | print("convert to member error \(error)")
45 | return nil
46 | }
47 | }
48 |
49 | func saveMember(member: MemberModel?) {
50 | self.member = member
51 | guard let member = member else { return }
52 | do {
53 | let data = try JSONEncoder().encode(member)
54 | let json = String(data: data, encoding: .utf8)
55 | self.userInfo = json ?? ""
56 | } catch {
57 | print("convert to json error \(error)")
58 | }
59 | }
60 |
61 | func clearMember() {
62 | self.member = nil
63 | self.userInfo = ""
64 | self.token = ""
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/256.imageset/256@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/V2EX/Assets.xcassets/256.imageset/256@2x.png
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/256.imageset/256@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/V2EX/Assets.xcassets/256.imageset/256@3x.png
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/256.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "256@2x.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "256@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/AppIcon.appiconset/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/V2EX/Assets.xcassets/AppIcon.appiconset/256.png
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "256.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.957",
9 | "green" : "0.957",
10 | "red" : "0.957"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.957",
27 | "green" : "0.957",
28 | "red" : "0.957"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/BackgroundColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.961",
9 | "green" : "0.961",
10 | "red" : "0.961"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.078",
27 | "green" : "0.067",
28 | "red" : "0.059"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/BodyColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.345",
9 | "green" : "0.329",
10 | "red" : "0.314"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.980",
27 | "green" : "0.980",
28 | "red" : "0.980"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/BorderColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.902",
9 | "green" : "0.902",
10 | "red" : "0.902"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.133",
27 | "green" : "0.133",
28 | "red" : "0.133"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/CaptionColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.604",
9 | "green" : "0.604",
10 | "red" : "0.604"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.612",
27 | "green" : "0.612",
28 | "red" : "0.612"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/DangerColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.247",
9 | "green" : "0.247",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.247",
27 | "green" : "0.247",
28 | "red" : "1.000"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/DarkColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.125",
9 | "green" : "0.122",
10 | "red" : "0.122"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.125",
27 | "green" : "0.122",
28 | "red" : "0.122"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/DisableColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.518",
9 | "green" : "0.518",
10 | "red" : "0.518"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.561",
27 | "green" : "0.561",
28 | "red" : "0.561"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/PrimaryDarkColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "1.000",
9 | "green" : "1.000",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.118",
27 | "green" : "0.094",
28 | "red" : "0.086"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/SecondaryColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.761",
9 | "green" : "0.325",
10 | "red" : "0.008"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "1.000",
27 | "green" : "0.424",
28 | "red" : "0.020"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/SkeletonColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.886",
9 | "green" : "0.886",
10 | "red" : "0.886"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.267",
27 | "green" : "0.267",
28 | "red" : "0.267"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/SurfaceColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "1.000",
9 | "green" : "1.000",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.118",
27 | "green" : "0.094",
28 | "red" : "0.086"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/TabBarButtonSelectedColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.502",
9 | "green" : "0.502",
10 | "red" : "0.494"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.502",
27 | "green" : "0.502",
28 | "red" : "0.494"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/ColorTheme/TitleColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.255",
9 | "green" : "0.243",
10 | "red" : "0.231"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "1.000",
27 | "green" : "1.000",
28 | "red" : "1.000"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/IconFont/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/logo.github.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "logo.github@@2x.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/logo.github.imageset/logo.github@@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/V2EX/Assets.xcassets/logo.github.imageset/logo.github@@2x.png
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/logo.twitter.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "logo.twitter@2x.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/V2EX/Assets.xcassets/logo.twitter.imageset/logo.twitter@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/V2EX/Assets.xcassets/logo.twitter.imageset/logo.twitter@2x.png
--------------------------------------------------------------------------------
/V2EX/Core/Components/IconImageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IconImageView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct IconImageView: View {
11 |
12 | var imageName: String
13 | var title: String
14 |
15 | var body: some View {
16 | HStack(spacing: 5) {
17 | Image(systemName: imageName)
18 | Text(title)
19 | }
20 | }
21 | }
22 |
23 | #Preview {
24 | IconImageView(imageName: "ellipsis.message", title: "100")
25 | }
26 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/MenuTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomTabView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/30.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MenuTabView: View {
11 |
12 | @Namespace var animationNamespace
13 | @Binding var currentSelected: Int
14 | @State private var scrollEnabled: Bool = false
15 |
16 | private let geometryID: String = "slider_rectangle"
17 | var titles: [String]
18 |
19 | var body: some View {
20 | GeometryReader { geometry in
21 | ScrollViewReader { proxy in
22 | ScrollView(.horizontal) {
23 | HStack(alignment: .top) {
24 | ForEach(titles.indices, id: \.self) { index in
25 | Button(action: {
26 | withAnimation(.easeInOut) {
27 | currentSelected = index
28 | proxy.scrollTo(index, anchor: .center)
29 | }
30 | }, label: {
31 | VStack {
32 | Text(titles[index])
33 | .foregroundStyle(currentSelected == index ? Color.theme.secondary : Color.theme.title)
34 | .font(.body)
35 | if currentSelected == index {
36 | Rectangle()
37 | .frame(width: 40, height: 3)
38 | .clipShape(.rect(cornerRadius: 10 ))
39 | .foregroundStyle(Color.theme.secondary)
40 | .matchedGeometryEffect(id: geometryID, in: animationNamespace)
41 | }
42 | }
43 | .frame(minWidth: 40)
44 | .padding(.horizontal, 5)
45 | })
46 | }
47 | }
48 | .padding(6)
49 | .background(
50 | GeometryReader { contentGeometry in
51 | Color.clear
52 | .onAppear {
53 | if contentGeometry.size.width <= geometry.size.width {
54 | scrollEnabled = false
55 | } else {
56 | scrollEnabled = true
57 | }
58 | }
59 | }
60 | )
61 | }
62 | .scrollIndicators(.hidden)
63 | .scrollDisabled(!scrollEnabled)
64 | }
65 |
66 | }
67 | .frame(height: 40)
68 | }
69 | }
70 |
71 | #Preview {
72 | MenuTabView(currentSelected: .constant(1), titles: ["讨论", "资金", "资讯", "公告"])
73 | }
74 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/NodeImage/NodeImageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeImageView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeImageView: View {
11 |
12 | @StateObject var vm: NodeImageViewModel
13 |
14 | init(node: NodeModel) {
15 | _vm = StateObject(wrappedValue: NodeImageViewModel(node: node))
16 | }
17 |
18 | var body: some View {
19 | ZStack {
20 | if let image = vm.nodeImage {
21 | Image(uiImage: image)
22 | .resizable()
23 | .scaledToFit()
24 | } else if vm.isLoading {
25 | ProgressView()
26 | } else {
27 | Image(systemName: "questionmark")
28 | }
29 | }
30 | }
31 | }
32 |
33 | #Preview {
34 | NodeImageView(node: DeveloperPreview.instance.nodeModel)
35 | }
36 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/NodeImage/NodeImageViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeImageViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class NodeImageViewModel: ObservableObject {
13 |
14 | @Published var nodeImage: UIImage? = nil
15 | @Published var isLoading: Bool = false
16 |
17 | private let dataService: NodeImageDataService
18 | private var cancelables = Set()
19 |
20 | init(node: NodeModel) {
21 | self.dataService = NodeImageDataService(node: node)
22 | addSubscribers()
23 | self.isLoading = true
24 | }
25 |
26 | private func addSubscribers() {
27 | dataService.$nodeImage
28 | .sink { [weak self] _ in
29 | self?.isLoading = false
30 | } receiveValue: { [weak self] returnedImage in
31 | self?.nodeImage = returnedImage
32 | }
33 | .store(in: &cancelables)
34 |
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/SearchBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchBarView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SearchBarView: View {
11 |
12 | @Binding var searchText: String
13 |
14 | var body: some View {
15 | HStack {
16 | Image(systemName: "magnifyingglass")
17 | .foregroundStyle(Color.theme.title)
18 |
19 | TextField("搜索节点".localized, text: $searchText)
20 | .foregroundStyle(Color.theme.title)
21 | .autocorrectionDisabled(false)
22 | .overlay(
23 | Image(systemName: "xmark.circle.fill")
24 | .foregroundStyle(Color.theme.title)
25 | .padding()
26 | .offset(x: 10)
27 | .opacity(searchText.isEmpty ? 0.0 : 1.0)
28 | .onTapGesture {
29 | UIApplication.shared.endEditing()
30 | searchText = ""
31 | }
32 | , alignment: .trailing
33 | )
34 | }
35 | .font(.headline)
36 | .padding()
37 | .background(
38 | RoundedRectangle(cornerRadius: 25.0)
39 | .fill(Color.theme.background)
40 | .shadow(color: Color.theme.accent.opacity(0.15), radius: 10)
41 | )
42 | .padding()
43 | }
44 | }
45 |
46 | #Preview {
47 | SearchBarView(searchText: .constant(""))
48 | }
49 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/TopicListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicListView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TopicListView: View {
11 |
12 | @Binding var selectedTopic: TopicModel?
13 | @Binding var showDetailView: Bool
14 |
15 | let topics: [TopicModel]
16 |
17 | var body: some View {
18 | ForEach(topics) { topic in
19 | TopicRowView(topic: topic)
20 | .onTapGesture {
21 | segue(topic: topic)
22 | }
23 | }
24 | .listRowInsets(EdgeInsets())
25 | .listRowSeparator(.hidden)
26 | }
27 |
28 | private func segue(topic: TopicModel) {
29 | selectedTopic = topic
30 | showDetailView = true
31 | }
32 | }
33 |
34 | #Preview {
35 | TopicListView(selectedTopic: .constant(nil), showDetailView: .constant(false), topics: [DeveloperPreview.instance.topicModel, DeveloperPreview.instance.topicModel])
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/TopicRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicRowView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TopicRowView: View {
11 |
12 | @State private var showNodeDetailView: Bool = false
13 | @State private var selectedNode: NodeModel?
14 |
15 | var topic: TopicModel
16 |
17 | init(topic: TopicModel) {
18 | self._selectedNode = State(wrappedValue: topic.node)
19 | self.topic = topic
20 | }
21 |
22 | var body: some View {
23 | VStack {
24 | HStack(alignment: .top, spacing: 15) {
25 | UserImageView(member: topic.member)
26 | .frame(width: 40, height: 40)
27 | .clipShape(.rect(cornerRadius: 5))
28 |
29 | VStack(alignment: .leading, spacing: 8) {
30 | topView
31 |
32 | centerView
33 |
34 | bottomView
35 | }
36 | }
37 | Divider()
38 | }
39 | .padding(.horizontal, 10)
40 | .padding(.top, 15)
41 | .navigationDestination(isPresented: $showNodeDetailView) {
42 | NodeDetailLoadingView(node: $selectedNode)
43 | }
44 | }
45 | }
46 |
47 | #Preview {
48 | TopicRowView(topic: DeveloperPreview.instance.topicModel)
49 | .previewLayout(.sizeThatFits)
50 | .preferredColorScheme(.light)
51 | }
52 |
53 | #Preview {
54 | TopicRowView(topic: DeveloperPreview.instance.topicModel)
55 | .previewLayout(.sizeThatFits)
56 | .preferredColorScheme(.dark)
57 | }
58 |
59 | extension TopicRowView {
60 | var topView: some View {
61 | HStack {
62 | Text(topic.member.username)
63 | .foregroundStyle(Color.theme.secondary)
64 | .font(.caption)
65 | Spacer()
66 | HStack (spacing: 5) {
67 | Text("发布于".localized)
68 | Text(Date(timeIntervalSince1970: topic.created).timeAgoDisplay())
69 | }
70 | .foregroundStyle(Color.theme.caption)
71 | .font(.caption)
72 | }
73 | }
74 |
75 | var centerView: some View {
76 | Text(topic.title)
77 | .lineLimit(2)
78 | .multilineTextAlignment(.leading)
79 | .foregroundStyle(Color.theme.body)
80 | .font(.body)
81 | }
82 |
83 | var bottomView: some View {
84 | HStack {
85 | HStack(spacing: 15) {
86 | IconImageView(imageName: "person.wave.2.fill", title: topic.lastReplyBy)
87 | .foregroundStyle(Color.theme.secondary)
88 |
89 | IconImageView(imageName: "ellipsis.message", title: "\(topic.replies)")
90 | .foregroundStyle(Color.theme.caption)
91 |
92 | IconImageView(imageName: "clock.arrow.circlepath", title: Date(timeIntervalSince1970: topic.lastTouched).timeAgoDisplay())
93 | .foregroundStyle(Color.theme.caption)
94 | }
95 | Spacer()
96 | IconImageView(imageName: "newspaper.fill", title: topic.node.name)
97 | .foregroundStyle(Color.theme.secondary)
98 | .onTapGesture {
99 | showNodeDetailView.toggle()
100 | }
101 | }
102 | .font(.caption)
103 | .lineLimit(1)
104 | .padding(.bottom, 5)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/UserImage/UserImageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserImageView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/21.
6 | //
7 |
8 | import SwiftUI
9 |
10 |
11 |
12 | struct UserImageView: View {
13 |
14 | @StateObject var vm: UserImageViewModel
15 | @State private var showUserProfileView: Bool = false
16 |
17 | private let member: MemberModel
18 |
19 | init(member: MemberModel) {
20 | self.member = member
21 | _vm = StateObject(wrappedValue: UserImageViewModel(member: member))
22 | }
23 |
24 | var body: some View {
25 | ZStack {
26 | if let image = vm.userImage {
27 | Image(uiImage: image)
28 | .resizable()
29 | .scaledToFit()
30 | .onTapGesture {
31 | showUserProfileView.toggle()
32 | }
33 | } else if vm.isLoading {
34 | ProgressView()
35 | } else {
36 | Image(systemName: "questionmark")
37 | }
38 | }
39 | .navigationDestination(isPresented: $showUserProfileView) {
40 | MyProfileView(isPresented: $showUserProfileView, member: member)
41 | }
42 | }
43 | }
44 |
45 | #Preview {
46 | UserImageView(member: DeveloperPreview.instance.memberModel)
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Core/Components/UserImage/UserImageViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserImageViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/21.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class UserImageViewModel: ObservableObject {
13 |
14 | @Published var userImage: UIImage? = nil
15 | @Published var isLoading: Bool = false
16 |
17 | private let dataService: UserImageDataService
18 | private var cancelables = Set()
19 |
20 | init(member: MemberModel) {
21 | self.dataService = UserImageDataService(member: member)
22 | addSubscribers()
23 | self.isLoading = true
24 | }
25 |
26 | private func addSubscribers() {
27 | dataService.$userImage
28 | .sink { [weak self] _ in
29 | self?.isLoading = false
30 | } receiveValue: { [weak self] returnedImage in
31 | self?.userImage = returnedImage
32 | }
33 | .store(in: &cancelables)
34 |
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Core/CoreData/CoreDataManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoreDataManager.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/27.
6 | //
7 |
8 | import Foundation
9 | import CoreData
10 |
11 | class CoreDataManager {
12 | static let shared = CoreDataManager()
13 |
14 | private let container: NSPersistentContainer
15 | private let containerName: String = "V2EXContainer"
16 | private let nodeEntityName: String = "NodeEntity"
17 |
18 |
19 | @Published var nodeEntities: [NodeEntity] = []
20 |
21 |
22 | private init() {
23 | container = NSPersistentContainer(name: containerName)
24 | container.loadPersistentStores { _, error in
25 | if let error = error {
26 | print("Error loading Core Data! \(error)")
27 | }
28 | }
29 |
30 | let storeURL = container.persistentStoreDescriptions.first!.url!
31 | }
32 |
33 | // Save context
34 | func saveContext() {
35 | if container.viewContext.hasChanges {
36 | do {
37 | try container.viewContext.save()
38 | } catch let error {
39 | print("Error saving Entity, \(error)")
40 | }
41 | }
42 | }
43 | }
44 |
45 | // MARK: - NodeEntity
46 | extension CoreDataManager {
47 |
48 | func updateNode(node: NodeModel, isFavorite: Bool) {
49 | if let entity = nodeEntities.first(where: { $0.nodeID == node.id }) {
50 | isFavorite ? update(entity: entity, isFavorite: isFavorite) : delete(entity: entity)
51 | } else {
52 | add(node: node, isFavorite: isFavorite)
53 | }
54 | }
55 |
56 | // 获取全部
57 | func getAllNodes() {
58 | let request = NSFetchRequest(entityName: nodeEntityName)
59 | do {
60 | self.nodeEntities = try container.viewContext.fetch(request)
61 | } catch let error {
62 | print("Error fetching All Node Entities. \(error)")
63 | self.nodeEntities = []
64 | }
65 | }
66 |
67 | // 添加数据
68 | private func add(node: NodeModel, isFavorite: Bool) {
69 | let entity = NodeEntity(context: container.viewContext)
70 | entity.nodeID = Int32(node.id)
71 | entity.isFavorite = isFavorite
72 | applyNodeEntityChanges()
73 | }
74 |
75 |
76 | // 删除数据
77 | private func delete(entity: NodeEntity) {
78 | container.viewContext.delete(entity)
79 | applyNodeEntityChanges()
80 | }
81 |
82 | // 更新数据
83 | private func update(entity: NodeEntity, isFavorite: Bool) {
84 | entity.isFavorite = isFavorite
85 | applyNodeEntityChanges()
86 | }
87 |
88 | private func applyNodeEntityChanges() {
89 | saveContext()
90 | getAllNodes()
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/V2EX/Core/CoreData/NodeEntity.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeEntity.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/30.
6 | //
7 |
8 | import Foundation
9 | import CoreData
10 |
11 | @objc(NodeEntity)
12 | public class NodeEntity: NSManagedObject {
13 | @NSManaged var nodeID: Int32
14 | @NSManaged var isFavorite: Bool
15 | }
16 |
--------------------------------------------------------------------------------
/V2EX/Core/CoreData/V2EXContainer.xcdatamodeld/V2EXContainer.xcdatamodel/contents:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/V2EX/Core/Detail/ViewModels/DetailViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReplyViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/12.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class DetailViewModel: ObservableObject {
12 |
13 | @Published var replies: [ReplyModel] = []
14 | @Published var isFavorite: Bool = false
15 | @Published var topic: TopicModel
16 |
17 | private let replyDataService: ReplyDataService
18 | private var cancelables = Set()
19 |
20 | init(topic: TopicModel) {
21 | self.topic = topic
22 | self.replyDataService = ReplyDataService(topic: topic)
23 | addSubscribers()
24 | }
25 |
26 | private func addSubscribers() {
27 | replyDataService.$replies
28 | .sink { [weak self] returnedReplies in
29 | self?.replies = returnedReplies
30 | }
31 | .store(in: &cancelables)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/V2EX/Core/Detail/Views/DetailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DetailView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct DetailLoadingView: View {
11 |
12 | @Binding var topic: TopicModel?
13 |
14 | var body: some View {
15 | ZStack {
16 | if let topic = topic {
17 | DetailView(topic: topic)
18 | }
19 | }
20 | }
21 | }
22 |
23 | struct DetailView: View {
24 | @Environment(\.dismiss) var dismiss
25 | @StateObject private var vm: DetailViewModel
26 | @State private var isFavorite: Bool = false
27 |
28 | @State private var showNodeDetailView: Bool = false
29 | @State private var selectedNode: NodeModel? = nil
30 |
31 | init(topic: TopicModel) {
32 | _vm = StateObject(wrappedValue: DetailViewModel(topic: topic))
33 | _selectedNode = State(wrappedValue: topic.node)
34 | print("Initial DetailView for \(topic.id)")
35 | }
36 |
37 | var body: some View {
38 | List {
39 | header
40 | .listRowSeparator(.hidden)
41 | .listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
42 |
43 | Divider()
44 | .listRowSeparator(.hidden)
45 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
46 |
47 | Text(vm.topic.content)
48 | .foregroundStyle(Color.theme.body)
49 | .font(.body)
50 | .listRowSeparator(.hidden)
51 | .listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
52 |
53 | Rectangle()
54 | .foregroundStyle(Color.theme.border)
55 | .frame(height: 5)
56 | .listRowSeparator(.hidden)
57 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
58 |
59 | // 回复
60 | replyView
61 | }
62 | .listStyle(.plain)
63 | .customNavigationBar(title: "")
64 | .toolbar {
65 | ToolbarItem(placement: .topBarTrailing) {
66 | Button(action: {
67 | withAnimation {
68 | isFavorite.toggle()
69 | }
70 | }, label: {
71 | Image(systemName: isFavorite ? "heart.fill" : "heart")
72 | .foregroundStyle(isFavorite ? Color.theme.dager : Color.theme.title)
73 | })
74 | }
75 | }
76 | .navigationDestination(isPresented: $showNodeDetailView) {
77 | NodeDetailLoadingView(node: $selectedNode)
78 | }
79 | }
80 | }
81 |
82 | #Preview {
83 | NavigationStack {
84 | DetailView(topic: DeveloperPreview.instance.topicModel)
85 | }
86 | }
87 |
88 | extension DetailView {
89 | private var header: some View {
90 | HStack(alignment: .top, spacing: 15) {
91 | UserImageView(member: vm.topic.member)
92 | .frame(width: 40, height: 40)
93 | .clipShape(.rect(cornerRadius: 5.0))
94 |
95 | VStack(alignment: .leading, spacing: 10) {
96 | HStack {
97 | Text(vm.topic.member.username)
98 | .foregroundStyle(Color.theme.secondary)
99 | .font(.caption)
100 | Spacer()
101 | HStack (spacing: 5) {
102 | Text("发布于".localized)
103 | Text(vm.topic.created.asTimeAgoDisplay())
104 | }
105 | .foregroundStyle(Color.theme.caption)
106 | .font(.caption)
107 | }
108 |
109 | Text(vm.topic.title)
110 | .foregroundStyle(Color.theme.body)
111 | .font(.body)
112 |
113 | HStack(spacing: 15) {
114 | IconImageView(imageName: "ellipsis.message", title: "\(vm.topic.replies)")
115 | .foregroundStyle(Color.theme.caption)
116 |
117 | IconImageView(imageName: "clock.arrow.circlepath", title: Date(timeIntervalSince1970: vm.topic.lastTouched).timeAgoDisplay())
118 | .foregroundStyle(Color.theme.caption)
119 |
120 | Spacer()
121 |
122 | IconImageView(imageName: "newspaper.fill", title: vm.topic.node.name)
123 | .foregroundStyle(Color.theme.secondary)
124 | .onTapGesture {
125 | showNodeDetailView.toggle()
126 | }
127 | }
128 | .font(.caption)
129 | }
130 | }
131 | }
132 |
133 | @ViewBuilder
134 | private var replyView: some View {
135 | if vm.replies.isEmpty {
136 | Text("现在还没有任何回复~".localized)
137 | .foregroundStyle(Color.theme.caption)
138 | .font(.body)
139 | .frame(maxWidth: .infinity, alignment: .center)
140 | .listRowSeparator(.hidden)
141 | .listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
142 | } else {
143 | VStack(alignment: .leading, spacing: 10) {
144 | IconImageView(imageName: "ellipsis.message", title: "最新回复".localized)
145 | .foregroundStyle(Color.theme.title)
146 | .font(.body)
147 |
148 | Divider()
149 | }
150 | .listRowSeparator(.hidden)
151 | .listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0))
152 |
153 | ForEach(vm.replies) { reply in
154 | ReplyRowView(reply: reply)
155 | }
156 | .listRowSeparator(.hidden)
157 | .listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
158 | }
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/V2EX/Core/Detail/Views/ReplyRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReplyRowView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/12/12.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ReplyRowView: View {
11 |
12 | let reply: ReplyModel
13 |
14 | var body: some View {
15 | VStack(spacing: 0) {
16 | HStack(alignment: .top, spacing: 15) {
17 | UserImageView(member: reply.member)
18 | .frame(width: 40, height: 40)
19 | .clipShape(.rect(cornerRadius: 5))
20 |
21 | VStack(alignment: .leading, spacing: 15) {
22 | HStack {
23 | Text(reply.member.username)
24 | .foregroundStyle(Color.theme.secondary)
25 | .font(.caption)
26 | Spacer()
27 | IconImageView(imageName: "clock.arrow.circlepath", title: String(format: "回复于 %@".localized, Double(reply.lastModified ?? 0).asTimeAgoDisplay()))
28 | .foregroundStyle(Color.theme.caption)
29 | .font(.caption)
30 | }
31 | .padding(.top, 5)
32 |
33 | Text(reply.content ?? "")
34 | .foregroundStyle(Color.theme.body)
35 | .font(.body)
36 | }
37 | }
38 | .padding(.top, 10)
39 | Divider()
40 | .padding(.top, 10)
41 | }
42 | }
43 | }
44 |
45 | #Preview {
46 | ReplyRowView(reply: DeveloperPreview.instance.replyModel)
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Core/Home/ViewModels/HomeViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/21.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class HomeViewModel: ObservableObject {
12 |
13 | @Published var topics: [TopicModel] = []
14 | @Published var isLoading: Bool = false
15 |
16 | private let dataService = TopicDataService()
17 | private var cancelables = Set()
18 |
19 | init() {
20 | addSubscribers()
21 | }
22 |
23 | private func addSubscribers() {
24 | dataService.$hotTopics
25 | .sink { [weak self] returnedTopics in
26 | self?.topics = returnedTopics
27 | self?.isLoading = false
28 | }
29 | .store(in: &cancelables)
30 |
31 | dataService.$latestTopics
32 | .sink { [weak self] returnedTopics in
33 | self?.topics = returnedTopics
34 | self?.isLoading = false
35 | }
36 | .store(in: &cancelables)
37 | }
38 |
39 | func getTopics(category: TopicCategory) {
40 | isLoading = true
41 | dataService.getTopics(category: category)
42 | }
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/V2EX/Core/Home/ViewModels/SiteStatViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SiteStatViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class SiteStatViewModel: ObservableObject {
12 |
13 | @Published var stat: SiteStatModel = SiteStatModel(topicMax: 0, memberMax: 0)
14 |
15 | private let dataService = StatDataService()
16 | private var cancelables = Set()
17 |
18 | init() {
19 | addSubscribers()
20 | }
21 |
22 | private func addSubscribers() {
23 | dataService.$stat
24 | .sink { [weak self] returnedStat in
25 | self?.stat = returnedStat
26 | }
27 | .store(in: &cancelables)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/V2EX/Core/Home/Views/HomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct HomeView: View {
11 |
12 | @StateObject private var vm: HomeViewModel = HomeViewModel()
13 | @State private var isSidebarOpened: Bool = false
14 | @State private var category: TopicCategory = .hot
15 |
16 | @State private var selectedTopic: TopicModel? = nil
17 | @State private var showDetailView: Bool = false
18 |
19 | private let leftViewWidth: CGFloat = 80
20 |
21 | var body: some View {
22 | ZStack(alignment: .leading) {
23 | contentView
24 | .frame(maxWidth: .infinity) // 占满全屏
25 | .offset(x : isSidebarOpened ? leftViewWidth : 0) // 根据状态偏移
26 | .transition(.move(edge: .trailing))
27 |
28 | if isSidebarOpened {
29 | SidebarView(isSidebarVisible: $isSidebarOpened, category: $category) {
30 | print(category)
31 | vm.getTopics(category: category)
32 | }
33 | .frame(width: leftViewWidth)
34 | .transition(.move(edge: .leading))
35 | }
36 | }
37 | .animation(.easeInOut, value: isSidebarOpened) // 组件变化时使用的动画
38 | .onAppear {
39 | vm.getTopics(category: category)
40 | }
41 | .navigationTitle(
42 | Text(category.rawValue)
43 | )
44 | .navigationBarTitleDisplayMode(.inline)
45 | .toolbar {
46 | ToolbarItem(placement: .topBarLeading) {
47 | Button(action: {
48 | isSidebarOpened.toggle()
49 | }, label: {
50 | Image(systemName: "list.bullet")
51 | .foregroundStyle(Color.theme.title)
52 | })
53 | }
54 |
55 | ToolbarItem(placement: .topBarTrailing) {
56 | NavigationLink {
57 | SiteStatView()
58 | .toolbar(.hidden, for: .tabBar)
59 | } label: {
60 | Image(systemName: "chart.bar")
61 | .foregroundStyle(Color.theme.secondary)
62 | }
63 | }
64 | }
65 | .navigationDestination(isPresented: $showDetailView) {
66 | DetailLoadingView(topic: $selectedTopic)
67 | .toolbar(.hidden, for: .tabBar)
68 | }
69 | }
70 |
71 | private func segue(topic: TopicModel) {
72 | selectedTopic = topic
73 | showDetailView = true
74 | }
75 | }
76 |
77 | #Preview {
78 | NavigationStack {
79 | HomeView()
80 | }
81 | }
82 |
83 | extension HomeView {
84 | @ViewBuilder
85 | private var contentView: some View {
86 | if vm.isLoading {
87 | ProgressView()
88 | .progressViewStyle(.circular)
89 | .foregroundStyle(Color.theme.secondary)
90 | }
91 | if vm.topics.isEmpty {
92 | VStack(spacing: 15) {
93 | Image(systemName: "magnifyingglass")
94 | .foregroundStyle(Color.theme.caption)
95 | .font(.system(size: 50))
96 | Text("没有主题数据".localized)
97 | .foregroundStyle(Color.theme.caption)
98 | .font(.body)
99 | Button(action: {
100 | vm.getTopics(category: category)
101 | }, label: {
102 | Text("刷新".localized)
103 | .foregroundStyle(Color.theme.secondary)
104 | .font(.body)
105 | .padding(.horizontal, 40)
106 | .padding(.vertical, 5)
107 | })
108 | .overlay {
109 | RoundedRectangle(cornerRadius: 5)
110 | .stroke(Color.theme.secondary, lineWidth: 1.0)
111 | }
112 | }
113 | } else {
114 | listView
115 | }
116 | }
117 |
118 | // 列表视图
119 | private var listView: some View {
120 | List {
121 | TopicListView(selectedTopic: $selectedTopic, showDetailView: $showDetailView, topics: vm.topics)
122 | }
123 | .listStyle(.plain)
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/V2EX/Core/Home/Views/SidebarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SidebarView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/20.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SidebarView: View {
11 |
12 | @Binding var isSidebarVisible: Bool
13 | @Binding var category: TopicCategory
14 | private var selectedCallback: () -> Void
15 |
16 | init(isSidebarVisible: Binding, category: Binding, _ callback: @escaping () -> Void) {
17 | self._isSidebarVisible = isSidebarVisible
18 | self._category = category
19 | self.selectedCallback = callback
20 | }
21 |
22 | var body: some View {
23 | ZStack {
24 | Color.theme.background
25 | .opacity(isSidebarVisible ? 1 : 0)
26 |
27 | content
28 | }
29 | .ignoresSafeArea(edges: .bottom)
30 | }
31 | }
32 |
33 | extension SidebarView {
34 |
35 | private var content: some View {
36 | VStack(spacing: 20) {
37 | VStack(spacing: 5) {
38 | Image(systemName: "newspaper.fill")
39 | .font(.system(size: 20))
40 | Text(TopicCategory.hot.rawValue)
41 | .font(.system(size: 14))
42 | }
43 | .background(Color.clear) // 添加透明背景
44 | .foregroundStyle(category == .hot ? Color.theme.secondary : Color.theme.caption)
45 | .onTapGesture {
46 | category = .hot
47 | isSidebarVisible = false
48 | selectedCallback()
49 | }
50 |
51 | VStack(spacing: 5) {
52 | Image(systemName: "flame.fill")
53 | .font(.system(size: 20))
54 | Text(TopicCategory.latest.rawValue)
55 | .font(.system(size: 14))
56 | }
57 | .background(Color.clear) // 添加透明背景
58 | .foregroundStyle(category == .latest ? Color.theme.secondary : Color.theme.caption)
59 | .onTapGesture {
60 | category = .latest
61 | isSidebarVisible = false
62 | selectedCallback()
63 | }
64 |
65 | Spacer()
66 | }
67 | .padding(.vertical, 20)
68 | }
69 | }
70 |
71 | #Preview {
72 | SidebarView(isSidebarVisible: .constant(true), category: .constant(.hot)){}
73 | }
74 |
--------------------------------------------------------------------------------
/V2EX/Core/Home/Views/SiteStatView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SiteStatView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SiteStatView: View {
11 | @Environment(\.dismiss) var dismiss
12 | @StateObject private var vm: SiteStatViewModel = SiteStatViewModel()
13 |
14 | var body: some View {
15 | List {
16 | HStack(spacing: 20) {
17 | Text("注册会员".localized)
18 | .foregroundStyle(Color.theme.body)
19 | Text("\(vm.stat.memberMax)")
20 | .foregroundStyle(Color.theme.caption)
21 | }
22 | HStack(spacing: 20) {
23 | Text("主题数量".localized)
24 | .foregroundStyle(Color.theme.body)
25 | Text("\(vm.stat.topicMax)")
26 | .foregroundStyle(Color.theme.caption)
27 | }
28 | }
29 | .listStyle(.plain)
30 | .font(.body)
31 | .frame(width: UIScreen.main.bounds.width, alignment: .leading)
32 | .background(Color.theme.background)
33 | .customNavigationBar(title: "V2EX统计".localized)
34 | }
35 | }
36 |
37 | #Preview {
38 | NavigationStack {
39 | SiteStatView()
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/V2EX/Core/Login/ViewModels/LoginViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/9.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class LoginViewModel: ObservableObject {
13 |
14 | @Published var member: MemberModel? = nil
15 | @Published var message: String? = nil
16 | @Published var loginSuccess: Bool = false
17 |
18 | private let memberService = MemberDataService()
19 | private var cancelable = Set()
20 |
21 | init() {
22 | addSubscribers()
23 | }
24 |
25 | private func addSubscribers() {
26 | memberService.$member
27 | .sink { [weak self] returnedMember in
28 | if let returnedMember = returnedMember {
29 | self?.member = returnedMember
30 | self?.loginSuccess = true
31 | } else {
32 | self?.message = "登录失败,请检查您的授权码。".localized
33 | }
34 | }
35 | .store(in: &cancelable)
36 | }
37 |
38 | func login(token: String) {
39 | // "0d023c5a-442c-4bc6-9084-30db95d111f1"
40 | memberService.getMember(token: token)
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/V2EX/Core/Login/Views/LoginView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/9.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LoginView: View {
11 |
12 | @Environment(\.dismiss) private var dismiss
13 | @EnvironmentObject private var appState: AppState
14 | @StateObject private var viewModel = LoginViewModel()
15 |
16 | @State private var token: String = ""
17 | @State private var showServiceView: Bool = false
18 | @State private var showPolicyView: Bool = false
19 | @State private var showToast: Bool = false
20 | @State private var toastMessage: String = ""
21 |
22 | var body: some View {
23 | ZStack {
24 | Color.red.opacity(0.2)
25 | .ignoresSafeArea()
26 |
27 | VStack {
28 | HStack {
29 | Spacer()
30 | Link(destination: URL(string: "https://www.v2ex.com/settings/tokens")!) {
31 | Text("获取token".localized)
32 | .foregroundStyle(Color.theme.body)
33 | .font(.body)
34 | }
35 | }
36 | .padding()
37 |
38 | Spacer()
39 | Image("256")
40 | .resizable()
41 | .aspectRatio(contentMode: .fit)
42 | .frame(width: 100, height: 100)
43 | .clipShape(.rect(cornerRadius: 20))
44 | .padding(.vertical, 50)
45 |
46 | VStack(spacing: 20) {
47 | TextField("输入授权码..".localized, text: $token)
48 | .foregroundStyle(Color.black)
49 | .padding(.horizontal)
50 | .frame(minHeight: 50)
51 | .background(Color.white)
52 | .clipShape(.rect(cornerRadius: 10))
53 |
54 | Button {
55 | viewModel.login(token: token)
56 | } label: {
57 | Text("使用 Token 授权登录".localized)
58 | .padding(.horizontal)
59 | .foregroundStyle(Color.theme.surface)
60 | .font(.body)
61 | .frame(maxWidth: .infinity, minHeight: 50)
62 | .background(Color.theme.secondary)
63 | .clipShape(.rect(cornerRadius: 10))
64 | .opacity(token.isEmpty ? 0.7 : 1)
65 | }
66 | .disabled(token.isEmpty)
67 |
68 | Button {
69 | dismiss()
70 | } label: {
71 | Text("跳过,暂不授权".localized)
72 | .foregroundStyle(Color.theme.body)
73 | .font(.callout)
74 | }
75 | .padding(.top, 10)
76 |
77 | }
78 | .padding(.horizontal)
79 |
80 | Spacer()
81 | Spacer()
82 |
83 | HStack(spacing: 0) {
84 | Text("登录即表示你同意".localized)
85 | Text("服务条款".localized)
86 | .foregroundStyle(Color.blue)
87 | .underline()
88 | .onTapGesture {
89 | showServiceView.toggle()
90 | }
91 | Text("和".localized)
92 | Text("隐私政策".localized)
93 | .foregroundStyle(Color.blue)
94 | .underline()
95 | .onTapGesture {
96 | showPolicyView.toggle()
97 | }
98 | }
99 | .foregroundStyle(Color.theme.body)
100 | .font(.footnote)
101 | }
102 | }
103 | .toast(toastMessage, showToast: $showToast)
104 | .sheet(isPresented: $showServiceView) {
105 | NavigationStack {
106 | TermsServiceView()
107 | }
108 | }
109 | .sheet(isPresented: $showPolicyView) {
110 | NavigationStack {
111 | PrivacyView()
112 | }
113 | }
114 | .onChange(of: viewModel.loginSuccess) { _, success in
115 | if success {
116 | appState.saveMember(member: viewModel.member)
117 | dismiss()
118 | } else {
119 | toastMessage = viewModel.message ?? ""
120 | showToast = true
121 | }
122 | }
123 | }
124 | }
125 |
126 | #Preview {
127 | LoginView()
128 | }
129 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/MemberModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MemberModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import Foundation
9 |
10 | // JSON Response
11 | /**
12 | URL:
13 | https://www.v2ex.com/api/members/show.json?username=mx3y
14 |
15 | Response:
16 | {
17 | "id": 82242,
18 | "username": "mx3y",
19 | "url": "https://www.v2ex.com/u/mx3y",
20 | "website": null,
21 | "twitter": null,
22 | "psn": null,
23 | "github": null,
24 | "btc": null,
25 | "location": null,
26 | "tagline": null,
27 | "bio": null,
28 | "avatar_mini": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_mini.png?m=1689670440",
29 | "avatar_normal": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_normal.png?m=1689670440",
30 | "avatar_large": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_large.png?m=1689670440",
31 | "created": 1416278855,
32 | "last_modified": 1689670440,
33 | "status": "found"
34 | }
35 | */
36 |
37 | // MARK: - Member
38 | struct MemberModel: Codable {
39 | let id: Int
40 | let username: String
41 | let url: String
42 | let website, twitter, psn, github: String?
43 | let btc, location, tagline, bio: String?
44 | let avatarMini, avatarNormal, avatarLarge: String
45 | let created, lastModified: Int
46 | let status: String?
47 |
48 | enum CodingKeys: String, CodingKey {
49 | case id, username, url, website, twitter, psn, github, btc, location, tagline, bio, created, status
50 | case avatarMini = "avatar_mini"
51 | case avatarNormal = "avatar_normal"
52 | case avatarLarge = "avatar_large"
53 | case lastModified = "last_modified"
54 | }
55 | }
56 |
57 | struct MemberModelWrapper: Codable {
58 | let success: Bool
59 | let member: MemberModel
60 |
61 | enum CodingKeys: String, CodingKey {
62 | case success
63 | case member = "result"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/NodeModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Node.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import Foundation
9 |
10 | // JSON Response
11 | /**
12 | URL:
13 | https://www.v2ex.com/api/nodes/all.json
14 |
15 | Response:
16 | {
17 | "avatar_large": "https://cdn.v2ex.com/navatar/c4ca/4238/1_large.png?m=1700044199",
18 | "name": "babel",
19 | "avatar_normal": "https://cdn.v2ex.com/navatar/c4ca/4238/1_normal.png?m=1700044199",
20 | "title": "Project Babel",
21 | "url": "https://www.v2ex.com/go/babel",
22 | "topics": 1123,
23 | "footer": "",
24 | "header": "",
25 | "title_alternative": "Project Babel",
26 | "avatar_mini": "https://cdn.v2ex.com/navatar/c4ca/4238/1_mini.png?m=1700044199",
27 | "stars": 411,
28 | "aliases": [
29 |
30 | ],
31 | "root": false,
32 | "id": 1,
33 | "parent_node_name": "v2ex"
34 | }
35 | */
36 |
37 | // MARK: - Node
38 | struct NodeModel: Codable, Identifiable, Hashable {
39 | let avatarLarge: String
40 | let name: String
41 | let avatarNormal: String
42 | let title: String
43 | let url: String
44 | let topics: Int
45 | let footer, header, titleAlternative: String?
46 | let avatarMini: String
47 | let stars: Int
48 | let aliases: [Int]
49 | let root: Bool
50 | let id: Int
51 | let parentNodeName: String?
52 |
53 |
54 | var isFavorite: Bool = false
55 |
56 | enum CodingKeys: String, CodingKey {
57 | case avatarLarge = "avatar_large"
58 | case avatarNormal = "avatar_normal"
59 | case avatarMini = "avatar_mini"
60 | case name, title, url, topics
61 | case footer, header
62 | case titleAlternative = "title_alternative"
63 | case stars, aliases, root, id
64 | case parentNodeName = "parent_node_name"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/NotificationModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotificationModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/26.
6 | //
7 |
8 | import Foundation
9 |
10 | /**
11 | URL:
12 | GET https://www.v2ex.com/api/v2/notifications?p=2
13 | Authorization: Bearer 0d023c5a-442c-4bc6-9084-30db95d111f1
14 |
15 | Response:
16 | {
17 | "id": 24084148,
18 | "member_id": 10131,
19 | "for_member_id": 629868,
20 | "text": "lidashuang 在 学习 Django 还有必要吗 里回复了你",
21 | "payload": "可以试试 rails https://ruby-china.org/topics/43935\r\nRuby 三年后,仍在热爱 Ruby",
22 | "payload_rendered": "可以试试 rails https://ruby-china.org/topics/43935
Ruby 三年后,仍在热爱 Ruby",
23 | "created": 1732689217,
24 | "member": {
25 | "username": "lidashuang"
26 | }
27 | }
28 | */
29 |
30 | // MARK: - NotificationModel
31 | struct NotificationModel: Identifiable, Codable {
32 | let id: Int
33 | let memberID, forMemberID: Int?
34 | let text: String
35 | let payload, payloadRendered: String?
36 | let created: Double
37 | // let member: MemberModel
38 |
39 | enum CodingKeys: String, CodingKey {
40 | case id
41 | case memberID = "member_id"
42 | case forMemberID = "for_member_id"
43 | case text
44 | case payload
45 | case payloadRendered = "payload_rendered"
46 | case created
47 | // case member
48 | }
49 | }
50 |
51 | struct NotificationResult: Codable {
52 | let success: Bool
53 | let message: String
54 | let result: [NotificationModel]
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/ReplyModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReplyModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/12.
6 | //
7 |
8 | import Foundation
9 |
10 | // JSON Data
11 | /**
12 | URL:
13 | https://www.v2ex.com/api/replies/show.json?topic_id=1096995&page=1&page_size=100
14 |
15 | Response:
16 | {
17 | "member": {
18 | "id": 602847,
19 | "username": "ttkanni",
20 | "url": "https://www.v2ex.com/u/ttkanni",
21 | "website": "",
22 | "twitter": null,
23 | "psn": null,
24 | "github": null,
25 | "btc": null,
26 | "location": "",
27 | "tagline": "",
28 | "bio": "",
29 | "avatar_mini": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_mini.png?m=1733797042",
30 | "avatar_normal": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_normal.png?m=1733797042",
31 | "avatar_large": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_large.png?m=1733797042",
32 | "avatar_xlarge": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_xlarge.png?m=1733797042",
33 | "avatar_xxlarge": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_xlarge.png?m=1733797042",
34 | "avatar_xxxlarge": "https://cdn.v2ex.com/avatar/b1c9/deca/602847_xlarge.png?m=1733797042",
35 | "created": 1669007467,
36 | "last_modified": 1733797042
37 | },
38 | "created": 1733983641,
39 | "topic_id": 1096995,
40 | "content": "没钱创个毛~",
41 | "content_rendered": "没钱创个毛~",
42 | "last_modified": 1733983641,
43 | "member_id": 602847,
44 | "id": 15659718
45 | }
46 | */
47 |
48 | struct ReplyModel: Codable, Identifiable {
49 | let member: MemberModel
50 | let created, topicID: Int?
51 | let content, contentRendered: String?
52 | let lastModified, memberID, id: Int?
53 | }
54 |
55 |
56 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/SiteStatModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SiteStatModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 |
10 | /**
11 | URL:
12 | https://www.v2ex.com/api/site/stats.json
13 |
14 | Response:
15 | {
16 | "topic_max" : 1092273,
17 | "member_max" : 721847
18 | }
19 | */
20 |
21 | struct SiteStatModel: Codable {
22 |
23 | let topicMax: Int
24 | let memberMax: Int
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/V2EX/Core/Models/TopicModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | /**
11 | URL:
12 | https://www.v2ex.com/api/topics/hot.json
13 |
14 | Response:
15 | {
16 | "node": {
17 | "avatar_large": "https://cdn.v2ex.com/navatar/c20a/d4d7/12_large.png?m=1650095340",
18 | "name": "qna",
19 | "avatar_normal": "https://cdn.v2ex.com/navatar/c20a/d4d7/12_normal.png?m=1650095340",
20 | "title": "问与答",
21 | "url": "https://www.v2ex.com/go/qna",
22 | "topics": 225477,
23 | "footer": "",
24 | "header": "一个更好的世界需要你持续地提出好问题。",
25 | "title_alternative": "Questions and Answers",
26 | "avatar_mini": "https://cdn.v2ex.com/navatar/c20a/d4d7/12_mini.png?m=1650095340",
27 | "stars": 4270,
28 | "aliases": [],
29 | "root": false,
30 | "id": 12,
31 | "parent_node_name": "v2ex"
32 | },
33 | "member": {
34 | "id": 82242,
35 | "username": "mx3y",
36 | "url": "https://www.v2ex.com/u/mx3y",
37 | "website": null,
38 | "twitter": null,
39 | "psn": null,
40 | "github": null,
41 | "btc": null,
42 | "location": null,
43 | "tagline": null,
44 | "bio": null,
45 | "avatar_mini": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_mini.png?m=1689670440",
46 | "avatar_normal": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_normal.png?m=1689670440",
47 | "avatar_large": "https://cdn.v2ex.com/avatar/fcb4/b749/82242_large.png?m=1689670440",
48 | "created": 1416278855,
49 | "last_modified": 1689670440
50 | },
51 | "last_reply_by": "znyb",
52 | "last_touched": 1732065304,
53 | "title": "求指导,怎样让自己脾气好起来",
54 | "url": "https://www.v2ex.com/t/1090746",
55 | "created": 1731985590,
56 | "deleted": 0,
57 | "content": "RT\r\n\r\n我开车遇到后面一直按喇叭不停的,会停下来拉对方车门。\r\n晚上大半夜楼下有飙摩托车的,我会跑下去堵人车。\r\n小区物业不作为,我会直接去物业办公室开怼。\r\n公共场所看见有人插队,我会堵住插队人让对方排队。\r\n。。。。。。。。。。。。\r\n这种情况我会经常发生,只要我出门,看见不对的,我就会动起来。。。\r\n这几天看到珠海、成都,今天看到常德的事情,我怂了,我想改改,之前我不认为我有错,现在我也不觉得我这样有错,但是感觉我这种脾气可能哪天就上新闻了,希望改一改。\r\n\r\n求有之前类似情况的 V 友分享下你们脾气改了吗,怎么做到的。",
58 | "content_rendered": "RT\u003Cbr /\u003E\u003Cbr /\u003E我开车遇到后面一直按喇叭不停的,会停下来拉对方车门。\u003Cbr /\u003E晚上大半夜楼下有飙摩托车的,我会跑下去堵人车。\u003Cbr /\u003E小区物业不作为,我会直接去物业办公室开怼。\u003Cbr /\u003E公共场所看见有人插队,我会堵住插队人让对方排队。\u003Cbr /\u003E。。。。。。。。。。。。\u003Cbr /\u003E这种情况我会经常发生,只要我出门,看见不对的,我就会动起来。。。\u003Cbr /\u003E这几天看到珠海、成都,今天看到常德的事情,我怂了,我想改改,之前我不认为我有错,现在我也不觉得我这样有错,但是感觉我这种脾气可能哪天就上新闻了,希望改一改。\u003Cbr /\u003E\u003Cbr /\u003E求有之前类似情况的 V 友分享下你们脾气改了吗,怎么做到的。",
59 | "last_modified": 1731985590,
60 | "replies": 171,
61 | "id": 1090746
62 | }
63 | */
64 |
65 | // MARK: - TopicModel - 帖子
66 | struct TopicModel: Identifiable, Codable {
67 | let node: NodeModel
68 | let member: MemberModel
69 | let lastReplyBy: String
70 | let lastTouched: Double
71 | let title: String
72 | let url: String
73 | let created, lastModified: Double
74 | let deleted, replies: Int
75 | let id: Int
76 | let content: String
77 | let contentRendered: String
78 |
79 |
80 | enum CodingKeys: String, CodingKey {
81 | case node, member
82 | case lastReplyBy = "last_reply_by"
83 | case lastTouched = "last_touched"
84 | case title, url, created, deleted, content
85 | case contentRendered = "content_rendered"
86 | case lastModified = "last_modified"
87 | case replies, id
88 | }
89 | }
90 |
91 |
92 |
--------------------------------------------------------------------------------
/V2EX/Core/My/ViewModels/MyProfileViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyProfileViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/20.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class MyProfileViewModel: ObservableObject {
12 |
13 | @Published var topics: [TopicModel] = []
14 |
15 | private let topicDataService = TopicDataService()
16 | private var cancelables = Set()
17 |
18 | init(member: MemberModel) {
19 | addSubscribers()
20 | topicDataService.getTopics(category: .user(userName: member.username))
21 | }
22 |
23 | private func addSubscribers() {
24 | topicDataService.$userTopics
25 | .sink { [weak self] returnedTopics in
26 | self?.topics = returnedTopics
27 | }
28 | .store(in: &cancelables)
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/V2EX/Core/My/ViewModels/MyTopicViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyTopicViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/23.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class MyTopicViewModel: ObservableObject {
12 |
13 | @Published var topics: [TopicModel] = []
14 |
15 | private let dataService = TopicDataService()
16 | private var cancelables = Set()
17 |
18 | init(member: MemberModel) {
19 | addSubscribers()
20 | dataService.getTopics(category: .user(userName: member.username))
21 | }
22 |
23 | private func addSubscribers() {
24 | dataService.$userTopics
25 | .sink { [weak self] returnedTopics in
26 | self?.topics = returnedTopics
27 | }
28 | .store(in: &cancelables)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/V2EX/Core/My/ViewModels/MyViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import Foundation
9 |
10 | class MyViewModel: ObservableObject {
11 |
12 | }
13 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/LicenseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LicenseView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/12/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LicenseView: View {
11 | var body: some View {
12 | VStack {
13 | Text("""
14 | Copyright (C)
15 |
16 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
17 |
18 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
19 |
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22 | """)
23 | .foregroundStyle(Color.theme.body)
24 | .font(.body)
25 | .padding(.vertical, 20)
26 | .background(Color.theme.surface)
27 | Spacer()
28 | }
29 | .background(Color.theme.background)
30 | .customNavigationBar(title: "开源协议".localized)
31 | }
32 | }
33 |
34 | #Preview {
35 | NavigationStack {
36 | LicenseView()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/MyProfileView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyProfileView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/20.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MyProfileView: View {
11 | @EnvironmentObject private var appState: AppState
12 | @StateObject private var vm: MyProfileViewModel
13 |
14 | @State private var selectedTopic: TopicModel? = nil
15 | @State private var showDetailView: Bool = false
16 | @State private var showLogoutDialog: Bool = false
17 |
18 | @Binding var isPresented: Bool
19 |
20 | private let member: MemberModel
21 |
22 | init(isPresented: Binding, member: MemberModel) {
23 | self._isPresented = isPresented
24 | self.member = member
25 | self._vm = StateObject(wrappedValue: MyProfileViewModel(member: member))
26 | }
27 |
28 | var body: some View {
29 | List {
30 | profileView
31 | .listRowInsets(EdgeInsets())
32 | .listRowSeparator(.hidden)
33 |
34 | memberTopicsView
35 | .listRowInsets(EdgeInsets())
36 | .listRowSeparator(.hidden)
37 | }
38 | .listStyle(.plain)
39 | .customNavigationBar(title: "")
40 | .toolbar {
41 | ToolbarItem(placement: .topBarTrailing) {
42 | if let member = appState.member,
43 | member.id == self.member.id {
44 | Button {
45 | showLogoutDialog.toggle()
46 | } label: {
47 | Image(systemName: "rectangle.portrait.and.arrow.right")
48 | .foregroundStyle(Color.theme.title)
49 | }
50 | } else {
51 | Button {
52 |
53 | } label: {
54 | Text("关注".localized)
55 | .foregroundStyle(Color.theme.secondary)
56 | .font(.body)
57 | }
58 | }
59 | }
60 | }
61 | .navigationDestination(isPresented: $showDetailView) {
62 | DetailLoadingView(topic: $selectedTopic)
63 | }
64 | .alert("确定退出吗?".localized, isPresented: $showLogoutDialog) {
65 | Button(role: .cancel) {
66 | showLogoutDialog.toggle()
67 | } label: {
68 | Text("取消".localized)
69 | }
70 | Button(role: .destructive) {
71 | appState.clearMember()
72 | isPresented.toggle()
73 | } label: {
74 | Text("确定".localized)
75 | }
76 | }
77 | }
78 | }
79 |
80 | #Preview {
81 | NavigationStack {
82 | MyProfileView(isPresented: .constant(false), member: DeveloperPreview.instance.memberModel)
83 | .environmentObject(AppState())
84 | }
85 | }
86 |
87 | extension MyProfileView {
88 | private var profileView: some View {
89 | VStack {
90 | VStack(alignment: .leading, spacing: 15) {
91 | HStack(alignment: .top, spacing: 20) {
92 | UserImageView(member: member)
93 | .frame(width: 60, height: 60)
94 | .clipShape(.rect(cornerRadius: 10))
95 |
96 | VStack(alignment: .leading, spacing: 10) {
97 | Text(member.username)
98 | .foregroundStyle(Color.theme.secondary)
99 | .font(.headline)
100 |
101 | if let bio = member.bio {
102 | Text(bio)
103 | .foregroundStyle(Color.theme.body)
104 | .font(.body)
105 | }
106 |
107 | Text(String(format: "加入于 %@".localized, Date(timeIntervalSince1970: Double(member.created)).asHyphenString()))
108 | .foregroundStyle(Color.theme.caption)
109 | .font(.caption)
110 | }
111 | }
112 | .frame(maxWidth: .infinity, alignment: .leading)
113 |
114 | Text("Full Stack Developer. UTC+08:00. Talk is cheap.")
115 | .foregroundStyle(Color.theme.body)
116 | .font(.body)
117 |
118 | HStack {
119 | IconImageView(imageName: "location", title: "ShenZhen")
120 | IconImageView(imageName: "link", title: "Aaron")
121 | }
122 | .foregroundStyle(Color.theme.body)
123 | .font(.caption)
124 |
125 | HStack {
126 | IconImageView(imageName: "location", title: "Aaron")
127 | IconImageView(imageName: "link", title: "Aaron")
128 | IconImageView(imageName: "link", title: "Aaron")
129 | }
130 | .foregroundStyle(Color.theme.body)
131 | .font(.caption)
132 |
133 | Text(String(format: "从%@开始成为V2EX用户".localized, Date(timeIntervalSince1970: Double(member.created)).asChineseDateString()))
134 | .foregroundStyle(Color.theme.caption)
135 | .font(.caption)
136 | }
137 | .padding(.horizontal)
138 | .padding(.vertical, 10)
139 |
140 | Rectangle()
141 | .frame(maxHeight: 5)
142 | .foregroundStyle(Color.theme.border)
143 | }
144 | }
145 |
146 | private var memberTopicsView: some View {
147 | VStack(alignment: .leading) {
148 | Image(systemName: "newspaper")
149 | .foregroundStyle(Color.theme.caption)
150 | .font(.body)
151 | .padding(.horizontal)
152 |
153 | TopicListView(selectedTopic: $selectedTopic, showDetailView: $showDetailView, topics: vm.topics)
154 | }
155 | .padding(.top, 20)
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/MyTopicView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyTopicView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MyTopicView: View {
11 |
12 | @StateObject private var vm: MyTopicViewModel
13 | @State private var selectedTopic: TopicModel? = nil
14 | @State private var showDetailView: Bool = false
15 |
16 | init(member: MemberModel) {
17 | _vm = StateObject(wrappedValue: MyTopicViewModel(member: member))
18 | }
19 |
20 | var body: some View {
21 | List {
22 | TopicListView(selectedTopic: $selectedTopic, showDetailView: $showDetailView, topics: vm.topics)
23 | }
24 | .listStyle(.plain)
25 | .customNavigationBar(title: "我的主题".localized)
26 | .toolbar {
27 | ToolbarItem(placement: .topBarTrailing) {
28 | Button {
29 |
30 | } label: {
31 | Image(systemName: "line.3.horizontal")
32 | .foregroundStyle(Color.theme.title)
33 | }
34 | }
35 | }
36 | .navigationDestination(isPresented: $showDetailView) {
37 | DetailLoadingView(topic: $selectedTopic)
38 | }
39 |
40 | }
41 | }
42 |
43 | #Preview {
44 | NavigationStack {
45 | MyTopicView(member: DeveloperPreview.instance.memberModel)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/MyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MyView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/11/10.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 | import MessageUI
11 |
12 | struct MyView: View {
13 |
14 | @EnvironmentObject private var appState: AppState
15 | @State private var cancellable: AnyCancellable?
16 |
17 | @State private var showProfileView: Bool = false
18 | @State private var showTopicsView: Bool = false
19 | @State private var showThemeSettingView: Bool = false
20 | @State private var showLanguageSettingView: Bool = false
21 | @State private var showLoginView: Bool = false
22 | @State private var showRotationView: Bool = false
23 | @State private var degree: CGFloat = 0
24 | @State private var showToast: Bool = false
25 | @State private var toastMessage: String = ""
26 | @State private var showShareSheet = false
27 | @State private var isMailViewPresented = false
28 |
29 | private let textToShare = "Check out this amazing SwiftUI App!"
30 | private let urlToShare = URL(string: "https://github.com/Aaron0927")!
31 |
32 | private var appVersion: String {
33 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
34 | }
35 |
36 | private var buildNumber: String {
37 | Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
38 | }
39 |
40 |
41 | private var member: MemberModel? {
42 | appState.member
43 | }
44 |
45 | var body: some View {
46 | ZStack {
47 | List {
48 | profileSection
49 | .listRowInsets(EdgeInsets())
50 | .listRowSeparator(.hidden)
51 | settingsSection
52 | generalSection
53 | feedbackSection
54 | }
55 | .listStyle(.insetGrouped)
56 |
57 | if showRotationView {
58 | Image(systemName: "arrow.2.circlepath")
59 | .foregroundStyle(Color.theme.title)
60 | .font(.system(size: 50))
61 | .rotationEffect(Angle(degrees: degree), anchor: .center)
62 | }
63 | }
64 | .background(Color.theme.background)
65 | .navigationBarTitleDisplayMode(.inline)
66 | .navigationDestination(isPresented: $showProfileView) {
67 | if let member = member {
68 | MyProfileView(isPresented: $showProfileView, member: member)
69 | .toolbar(.hidden, for: .tabBar)
70 | }
71 | }
72 | .navigationDestination(isPresented: $showTopicsView) {
73 | if let member = member {
74 | MyTopicView(member: member)
75 | .toolbar(.hidden, for: .tabBar)
76 | }
77 | }
78 | .navigationDestination(isPresented: $showThemeSettingView) {
79 | SettingThemeView()
80 | .toolbar(.hidden, for: .tabBar)
81 | }
82 | .navigationDestination(isPresented: $showLanguageSettingView) {
83 | SettingLanguageView()
84 | .toolbar(.hidden, for: .tabBar)
85 | }
86 | .sheet(isPresented: $showLoginView) {
87 | LoginView()
88 | .environmentObject(appState)
89 | }
90 | .sheet(isPresented: $showShareSheet) {
91 | ShareSheet(items: [textToShare, urlToShare])
92 | }
93 | .sheet(isPresented: $isMailViewPresented) {
94 | MailView(subject: "V2EX",
95 | recipients: ["aaronxiao0927@gmail.com"],
96 | body: "This is the email body.")
97 | }
98 | .toast(toastMessage, showToast: $showToast)
99 | }
100 | }
101 |
102 | #Preview {
103 | NavigationStack {
104 | MyView()
105 | .environmentObject(AppState())
106 | }
107 | }
108 |
109 | extension MyView {
110 | private var profileView: some View {
111 | VStack {
112 | HStack {
113 | HStack(alignment: .top, spacing: 20) {
114 | if let member = member {
115 | UserImageView(member: member)
116 | .frame(width: 60, height: 60)
117 | .clipShape(.rect(cornerRadius: 10))
118 |
119 | VStack(alignment: .leading, spacing: 10) {
120 | Text(member.username)
121 | .foregroundStyle(Color.theme.secondary)
122 | .font(.headline)
123 |
124 | if let tagline = member.tagline {
125 | Text(tagline)
126 | .foregroundStyle(Color.theme.body)
127 | .font(.body)
128 | }
129 |
130 | Text(String(format: "加入于 %@".localized, Date(timeIntervalSince1970: Double(member.created)).asHyphenString()))
131 | .foregroundStyle(Color.theme.caption)
132 | .font(.caption)
133 | }
134 | } else {
135 | Rectangle()
136 | .background(Color.theme.background)
137 | .frame(width: 60, height: 60)
138 | .clipShape(.rect(cornerRadius: 10))
139 |
140 | VStack(alignment: .leading, spacing: 10) {
141 | Text("去登录".localized)
142 | .foregroundStyle(Color.theme.secondary)
143 | .font(.headline)
144 | Text("登录体验更多V2EX功能".localized)
145 | .foregroundStyle(Color.theme.body)
146 | .font(.callout)
147 | }
148 | .background(Color.clear)
149 | .onTapGesture {
150 | showLoginView.toggle()
151 | }
152 | }
153 | }
154 | Spacer()
155 | Image(systemName: "chevron.right")
156 | .font(.caption)
157 | .foregroundStyle(Color.theme.caption)
158 | }
159 | .padding(.horizontal)
160 | .padding(.top, 20)
161 | .padding(.bottom, 5)
162 | Divider()
163 | }
164 | .onTapGesture {
165 | showProfileView.toggle()
166 | }
167 | }
168 |
169 | private var profileSection: some View {
170 | Section {
171 | profileView
172 |
173 | HStack {
174 | MyProfileItemView(title: "我的主题".localized, value: 22)
175 | .onTapGesture {
176 | showTopicsView.toggle()
177 | }
178 | Spacer()
179 | MyProfileItemView(title: "收藏主题".localized, value: 22)
180 | .onTapGesture {
181 | showTopicsView.toggle()
182 | }
183 | Spacer()
184 | MyProfileItemView(title: "关注的人".localized, value: 22)
185 | .onTapGesture {
186 | showTopicsView.toggle()
187 | }
188 | Spacer()
189 | MyProfileItemView(title: "浏览记录".localized, value: 22)
190 | .onTapGesture {
191 | showTopicsView.toggle()
192 | }
193 | }
194 | .padding()
195 | .frame(maxWidth: .infinity, alignment: .center)
196 | }
197 | }
198 |
199 | private var settingsSection: some View {
200 | Section {
201 | ProfileRowView(imageName: "paintpalette", title: "主题设置".localized)
202 | .onTapGesture {
203 | showThemeSettingView.toggle()
204 | }
205 | ProfileRowView(imageName: "globe", title: "语言环境".localized)
206 | .onTapGesture {
207 | showLanguageSettingView.toggle()
208 | }
209 | ProfileRowView(imageName: "arrow.2.circlepath", title: "缓存清理".localized)
210 | .onTapGesture {
211 | showRotationView.toggle()
212 | withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
213 | self.degree = 360
214 | }
215 | toastMessage = ""
216 | cancellable = LocalFileManager.instance.clearCache()
217 | .sink { success in
218 | self.showRotationView = false
219 | self.degree = 0
220 | self.toastMessage = success ? "清理成功".localized : "清理失败".localized
221 | self.showToast.toggle()
222 | self.cancellable?.cancel()
223 | }
224 | }
225 | } header: {
226 | Text("设置".localized)
227 | }
228 | }
229 |
230 | private var generalSection: some View {
231 | Section {
232 | ProfileRowView(imageName: "star", title: "评分鼓励".localized)
233 | .onTapGesture {
234 | toastMessage = "暂未实现该功能".localized
235 | showToast.toggle()
236 | }
237 | ProfileRowView(imageName: "square.and.arrow.up", title: "分享给好友".localized)
238 | .onTapGesture {
239 | showShareSheet.toggle()
240 | }
241 | ProfileRowView(imageName: "link", title: "URL Schemes")
242 | .onTapGesture {
243 | toastMessage = "暂未实现该功能".localized
244 | showToast.toggle()
245 | }
246 | ProfileRowView(imageName: "lock.open", title: "开源协议".localized)
247 | .onTapGesture {
248 | toastMessage = "暂未实现该功能".localized
249 | showToast.toggle()
250 | }
251 | ProfileRowView(imageName: "person.2", title: "关于".localized)
252 | .onTapGesture {
253 | toastMessage = "暂未实现该功能".localized
254 | showToast.toggle()
255 | }
256 | } header: {
257 | Text("综合".localized)
258 | }
259 | }
260 |
261 | private var feedbackSection: some View {
262 | Section {
263 | ProfileRowView(imageName: "envelope", title: "邮件".localized, detail: "aaronxiao0927@gmail.com")
264 | .onTapGesture {
265 | if MFMailComposeViewController.canSendMail() {
266 | isMailViewPresented = true
267 | } else {
268 | toastMessage = "Cannot send email on this device."
269 | showToast.toggle()
270 | }
271 | }
272 |
273 | ProfileRowView(imageName: "", customImageName: "logo.twitter", title: "推特".localized, detail: "Aaron")
274 | .onTapGesture {
275 | let urlString = "https://x.com/kim_18162579527"
276 | openURL(urlStr: urlString)
277 | }
278 | ProfileRowView(imageName: "", customImageName: "logo.github", title: "Github", detail: "Aaron0927")
279 | .onTapGesture {
280 | let urlString = "https://github.com/Aaron0927"
281 | openURL(urlStr: urlString)
282 | }
283 | } header: {
284 | Text("反馈".localized)
285 | } footer: {
286 | HStack {
287 | VStack(spacing: 10) {
288 | Text("V2EX \(appVersion)(\(buildNumber))")
289 | Text("V2EX创意工作者们的社区".localized)
290 | }
291 | .font(.footnote)
292 | .foregroundStyle(Color.theme.caption)
293 | }
294 | .frame(maxWidth: .infinity, alignment: .center)
295 | .padding(.vertical, 20)
296 | }
297 | }
298 |
299 | private func openURL(urlStr: String) {
300 | if let url = URL(string: urlStr) {
301 | UIApplication.shared.open(url, options: [:]) { success in
302 | if !success {
303 | print("Failed to open url \(url).")
304 | }
305 | }
306 | }
307 | }
308 | }
309 |
310 |
311 | struct MyProfileItemView: View {
312 |
313 | let title: String
314 | let value: Int
315 |
316 | var body: some View {
317 | VStack {
318 | Text("\(value)")
319 | .foregroundStyle(Color.theme.title)
320 | .font(.headline)
321 | .bold()
322 | Text(title)
323 | .foregroundStyle(Color.theme.body)
324 | .font(.body)
325 | }
326 | }
327 | }
328 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/PrivacyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrivacyView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/12/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct PrivacyView: View {
11 | var body: some View {
12 | VStack {
13 | Text("""
14 | V2EX不会收集您的任何信息,所有的内容都基于V2EX开放API提供。
15 |
16 | 这个项目使用了 SwiftUI 构建了一个 V2EX 移动客户端应用。目的是为了学习 SwiftUI 如何开发一个完整的应用。客户端数据基于 V2EX 开放 API。
17 |
18 | 项目完全开源,您可用Clone代码,检查代码。
19 |
20 | 开源地址: https://github.com/Aaron0927/V2EX
21 | """)
22 | .foregroundStyle(Color.theme.body)
23 | .font(.body)
24 | .padding(.vertical, 20)
25 | .background(Color.theme.surface)
26 | Spacer()
27 | }
28 | .background(Color.theme.background)
29 | .customNavigationBar(title: "隐私政策".localized)
30 | }
31 | }
32 |
33 | #Preview {
34 | NavigationStack {
35 | PrivacyView()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/ProfileRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PorfileRowView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/19.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ProfileRowView: View {
11 |
12 | private let imageName: String
13 | private let customImageName: String?
14 | private let title: String
15 | private let detail: String?
16 |
17 | init(imageName: String, customImageName: String? = nil, title: String, detail: String? = nil) {
18 | self.imageName = imageName
19 | self.customImageName = customImageName
20 | self.title = title
21 | self.detail = detail
22 | }
23 |
24 | var body: some View {
25 | HStack {
26 | if let customImageName = customImageName {
27 | Image(customImageName)
28 | .resizable()
29 | .aspectRatio(contentMode: .fit)
30 | .frame(width: 24, height: 24)
31 | .foregroundStyle(Color.theme.secondary)
32 | } else {
33 | Image(systemName: imageName)
34 | .resizable()
35 | .aspectRatio(contentMode: .fit)
36 | .frame(width: 24, height: 24)
37 | .foregroundStyle(Color.theme.secondary)
38 | }
39 | Text(title)
40 | .font(.body)
41 | .foregroundStyle(.body)
42 | Spacer()
43 | if let detail = detail {
44 | Text(detail)
45 | .font(.caption)
46 | .foregroundStyle(Color.theme.caption)
47 | }
48 | Image(systemName: "chevron.right")
49 | .font(.caption)
50 | .foregroundStyle(Color.theme.caption)
51 | }
52 | .contentShape(Rectangle())
53 | }
54 | }
55 |
56 | #Preview {
57 | ProfileRowView(imageName: "shareplay", title: "主题设置")
58 | }
59 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/SettingLanguageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingLanguageView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum AppLanguage: String, CaseIterable {
11 | case auto = "自动"
12 | case chs = "简体中文"
13 | case en = "English"
14 |
15 | var name: String {
16 | switch self {
17 | case .auto:
18 | if let languageCode = Locale.current.language.languageCode?.identifier {
19 | return languageCode
20 | }
21 | return "en"
22 | case .chs:
23 | return "zh-Hans"
24 | case .en:
25 | return "en"
26 | }
27 | }
28 | }
29 |
30 | struct SettingLanguageView: View {
31 |
32 | @EnvironmentObject private var appState: AppState
33 |
34 | var body: some View {
35 | List {
36 | ForEach(AppLanguage.allCases, id: \.self) { language in
37 | Text(language.rawValue.localized)
38 | .padding(.horizontal)
39 | .frame(height: 45)
40 | .frame(maxWidth: .infinity, alignment: .leading)
41 | .background(Color.theme.surface)
42 | .font(.body)
43 | .foregroundStyle(language == appState.language ? Color.theme.secondary : Color.theme.body)
44 | .onTapGesture {
45 | appState.language = language
46 | }
47 | }
48 | .listRowInsets(EdgeInsets())
49 | }
50 | .listStyle(.plain)
51 | .background(Color.theme.background)
52 | .customNavigationBar(title: "选择语言".localized)
53 | }
54 | }
55 |
56 | #Preview {
57 | NavigationStack {
58 | SettingLanguageView()
59 | .environmentObject(AppState())
60 | }
61 | }
62 |
63 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/SettingThemeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingThemeView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum Theme: String, CaseIterable {
11 | case auto = "自动"
12 | case light = "日间"
13 | case dark = "深色"
14 | }
15 |
16 | struct SettingThemeView: View {
17 |
18 | @EnvironmentObject private var appState: AppState
19 |
20 | var body: some View {
21 | List {
22 | ForEach(Theme.allCases, id: \.self) { theme in
23 | Text(theme.rawValue.localized)
24 | .padding(.horizontal)
25 | .frame(height: 45)
26 | .frame(maxWidth: .infinity, alignment: .leading)
27 | .background(Color.theme.surface)
28 | .font(.body)
29 | .foregroundStyle(theme == appState.theme ? Color.theme.secondary : Color.theme.body)
30 | .onTapGesture {
31 | appState.theme = theme
32 | }
33 | }
34 | .listRowInsets(EdgeInsets())
35 | }
36 | .listStyle(.plain)
37 | .background(Color.theme.background)
38 | .customNavigationBar(title: "选择主题")
39 | }
40 | }
41 |
42 | #Preview {
43 | NavigationStack {
44 | SettingThemeView()
45 | .environmentObject(AppState())
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Core/My/Views/TermsServiceView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TermsServiceView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/15.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TermsServiceView: View {
11 | var body: some View {
12 | ScrollView {
13 | LazyVStack {
14 | Text("""
15 | 开源软件服务条款
16 | 感谢您使用 [项目名称]!在使用本开源软件(以下简称“本软件”)之前,请您仔细阅读并理解以下条款。通过下载、使用或修改本软件,即表示您已同意本服务条款。
17 |
18 | 1. 软件许可
19 | 本软件根据 [选择的开源协议,例如 MIT、Apache 2.0、GPL 等] 许可证发布。具体条款请参阅随附的 LICENSE 文件。您可以自由地使用、修改和分发本软件,但须遵守相应开源许可证的规定。
20 |
21 | 2. 知识产权
22 | 本软件的原始代码、文档及其他附加资源均为本项目贡献者的知识产权。
23 | 您对根据开源许可证修改和分发的代码拥有相应的权利,但需保留原始版权声明和许可证信息。
24 | 3. 使用限制
25 | 本软件不得用于以下目的:
26 |
27 | 违反法律法规的行为;
28 | 用于传播恶意软件、病毒或其他有害代码;
29 | 冒充本项目或原始作者的身份。
30 | 4. 贡献协议
31 | 如果您向本项目提交代码、文档或其他贡献,表示您同意以下内容:
32 |
33 | 您拥有提交内容的合法权利;
34 | 您的贡献将根据本软件适用的开源协议进行发布;
35 | 您授权本项目维护者对提交内容进行修改、合并或分发。
36 | 5. 隐私政策
37 | 本项目不会主动收集用户的任何个人信息。如果软件功能涉及隐私(如日志、远程服务器交互等),请确保在实现和使用过程中遵守相关隐私法规。
38 |
39 | 6. 免责声明
40 | 本软件按“现状”提供,不附带任何明示或暗示的保证,包括但不限于适销性、特定用途适用性和非侵权的保证。
41 | 使用本软件产生的任何后果由您自行承担风险。项目维护者及贡献者不对因使用或无法使用本软件导致的任何损失负责。
42 | 7. 责任限制
43 | 在任何情况下,本项目维护者及贡献者对因使用本软件引起的任何直接、间接、偶然或后续性损害概不负责,即使已被告知可能发生此类损害。
44 |
45 | 8. 软件更新与维护
46 | 本项目维护者可根据需要对软件进行更新或修改,但无义务对软件进行持续维护或提供支持。
47 | 如需要支持或讨论,您可通过 项目主页 或 [联系邮箱] 与我们取得联系。
48 | 9. 争议解决
49 | 因本软件使用引发的任何争议,适用 [适用法律,如“国际版权法”或具体国家法律]。如有争议,双方应本着友好协商原则解决。
50 |
51 | 10. 其他条款
52 | 如果本条款的任何部分被视为无效或不可执行,其余部分仍保持有效。
53 | 您在使用本软件时,亦须遵守适用的法律法规。
54 | """)
55 | .foregroundStyle(Color.theme.body)
56 | .font(.body)
57 | }
58 | .padding()
59 | .customNavigationBar(title: "服务条款".localized)
60 | }
61 | }
62 | }
63 |
64 | #Preview {
65 | NavigationStack {
66 | TermsServiceView()
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/ViewModels/NodeDetailViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeDetailViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/26.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class NodeDetailViewModel: ObservableObject {
12 |
13 | @Published var allTopics: [TopicModel] = []
14 | @Published var node: NodeModel
15 |
16 | private let dataService = TopicDataService()
17 | private var cancelables = Set()
18 | private let dataManager = CoreDataManager.shared
19 |
20 | init(node: NodeModel) {
21 | self.node = node
22 | addSubscribers()
23 | getNodeTopics(nodeName: node.name)
24 | }
25 |
26 | private func addSubscribers() {
27 | dataService.$nodeTopics
28 | .sink { [weak self] returnedTopics in
29 | self?.allTopics = returnedTopics
30 | }
31 | .store(in: &cancelables)
32 | }
33 |
34 | private func getNodeTopics(nodeName: String) {
35 | dataService.getTopics(category: .node(nodeName: nodeName))
36 | }
37 |
38 | // 添加收藏
39 | func toggleFavorite() {
40 | node.isFavorite.toggle()
41 | dataManager.updateNode(node: node, isFavorite: node.isFavorite)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/ViewModels/NodeViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class NodeViewModel: ObservableObject {
12 |
13 | @Published var allNodes: [NodeModel] = [] // 全部节点
14 | @Published var sectionNodes: [String: [NodeModel]] = [:] // 分组节点
15 | @Published var searchText: String = "" // 搜索过滤
16 |
17 | private let dataService = NodeDataService()
18 | private var cancelables = Set()
19 | private let dataManager = CoreDataManager.shared
20 | // 常用父节点集合
21 | private var parentNodeNames: [String]
22 |
23 | init() {
24 | parentNodeNames = [
25 | "dev", "programming", "apple", "life", "geek"
26 | ]
27 | addSubscribers()
28 | }
29 |
30 | private func addSubscribers() {
31 | dataService.$allNodes
32 | .map(filterNodeSection)
33 | .combineLatest(dataManager.$nodeEntities)
34 | .map(combineNodes)
35 | .map(groupNodesByParentName)
36 | .sink { [weak self] returnedNodes in
37 | self?.sectionNodes = returnedNodes
38 | }
39 | .store(in: &cancelables)
40 |
41 |
42 |
43 |
44 | $searchText.combineLatest(dataService.$allNodes)
45 | .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
46 | .map(filterNodes)
47 | .sink { [weak self] returnedNodes in
48 | self?.allNodes = returnedNodes
49 | }
50 | .store(in: &cancelables)
51 | }
52 |
53 | private func filterNodes(text: String, nodes: [NodeModel]) -> [NodeModel] {
54 | guard !text.isEmpty else {
55 | return nodes
56 | }
57 |
58 | let lowercasedText = text.lowercased()
59 |
60 | return nodes.filter { node in
61 | return node.title.lowercased().contains(lowercasedText) ||
62 | node.name.lowercased().contains(lowercasedText) ||
63 | (node.parentNodeName?.lowercased().contains(lowercasedText) ?? false)
64 | }
65 | }
66 |
67 | // 节点分组过滤
68 | private func filterNodeSection(nodes: [NodeModel]) -> [NodeModel] {
69 | nodes.filter { parentNodeNames.contains($0.parentNodeName ?? "") }
70 | }
71 |
72 | // 节点分组
73 | private func groupNodesByParentName(nodes: [NodeModel]) -> [String: [NodeModel]] {
74 | var groupedNodes = [String: [NodeModel]]()
75 |
76 | nodes.forEach { node in
77 | if let parentName = node.parentNodeName, !parentName.isEmpty {
78 | groupedNodes[parentName, default: []].append(node)
79 | }
80 | }
81 | return groupedNodes
82 | }
83 |
84 | // 同步数据库收藏数据
85 | private func combineNodes(nodes: [NodeModel], nodeEntities: [NodeEntity]) -> [NodeModel] {
86 | nodes.map { node in
87 | var updatedNode = node // 创建一个新的节点副本
88 | if let entity = nodeEntities.first(where: { $0.nodeID == node.id }) {
89 | updatedNode.isFavorite = entity.isFavorite
90 | }
91 | return updatedNode // 返回更新后的节点
92 | }
93 | }
94 |
95 | }
96 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/Views/NodeDetailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeDetailView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeDetailLoadingView: View {
11 |
12 | @Binding var node: NodeModel?
13 |
14 | var body: some View {
15 | ZStack {
16 | if let node = node {
17 | NodeDetailView(node: node)
18 | }
19 | }
20 | }
21 | }
22 |
23 | struct NodeDetailView: View {
24 | @StateObject private var vm: NodeDetailViewModel
25 |
26 | @State private var selectedTopic: TopicModel? = nil
27 | @State private var showTopicDetailView: Bool = false
28 | @State private var selectedTab: Int = 0
29 |
30 | init(node: NodeModel) {
31 | self._vm = StateObject(wrappedValue: NodeDetailViewModel(node: node))
32 | }
33 |
34 | var body: some View {
35 | List {
36 | // Header View
37 | headerView
38 | .listRowInsets(EdgeInsets())
39 | .listRowSeparator(.hidden)
40 |
41 | Divider()
42 | .frame(height: 5)
43 | .overlay(Color.theme.border)
44 | .listRowInsets(EdgeInsets())
45 | .listRowSeparator(.hidden)
46 |
47 | MenuTabView(currentSelected: $selectedTab, titles: ["最新".localized, "所有".localized])
48 | .listRowInsets(EdgeInsets())
49 | .listRowSeparator(.hidden)
50 |
51 | TopicListView(selectedTopic: $selectedTopic, showDetailView: $showTopicDetailView, topics: vm.allTopics)
52 | }
53 | .listStyle(.plain)
54 | .customNavigationBar(title: "")
55 | .toolbar {
56 | ToolbarItem(placement: .topBarTrailing) {
57 | Button(action: {
58 | vm.toggleFavorite()
59 | }, label: {
60 | if vm.node.isFavorite {
61 | Image(systemName: "heart.fill")
62 | .foregroundStyle(Color.theme.dager)
63 | } else {
64 | Image(systemName: "heart")
65 | .foregroundStyle(Color.theme.title)
66 | }
67 | })
68 |
69 | }
70 | }
71 | .navigationDestination(isPresented: $showTopicDetailView) {
72 | DetailLoadingView(topic: $selectedTopic)
73 | }
74 | }
75 |
76 | private func segue(topic: TopicModel) {
77 | selectedTopic = topic
78 | showTopicDetailView = true
79 | }
80 | }
81 |
82 | #Preview {
83 | NavigationStack {
84 | NodeDetailView(node: DeveloperPreview.instance.nodeModel)
85 | }
86 | }
87 |
88 | extension NodeDetailView {
89 |
90 | private var headerView: some View {
91 | VStack(alignment: .leading, spacing: 15) {
92 | HStack(alignment: .top, spacing: 10) {
93 | NodeImageView(node: vm.node)
94 | .frame(width: 40, height: 40)
95 | .clipShape(.rect(cornerRadius: 5))
96 | VStack(alignment: .leading, spacing: 10) {
97 | Text(vm.node.title)
98 | .foregroundStyle(Color.theme.body)
99 | .font(.headline)
100 | HStack(spacing: 15) {
101 | IconImageView(imageName: "newspaper", title: "\(vm.node.topics)")
102 | IconImageView(imageName: "heart", title: "\(vm.node.stars)")
103 | IconImageView(imageName: "link", title: "create")
104 | }
105 | Text("该节点最近活跃于 xxxx 年 xx 月 xx 日".localized)
106 | .foregroundStyle(Color.theme.caption)
107 | .font(.caption)
108 | }
109 | .foregroundStyle(Color.theme.caption)
110 | .font(.caption)
111 |
112 | Spacer()
113 | }
114 |
115 | Text(vm.node.header ?? "")
116 | .foregroundStyle(Color.theme.body)
117 | .font(.body)
118 |
119 | Text("节点从 xxxx 年 xx 月 xx 日创建至今".localized)
120 | .foregroundStyle(Color.theme.caption)
121 | .font(.footnote)
122 | }
123 | .padding(.horizontal, 10)
124 | }
125 |
126 | }
127 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/Views/NodeRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeRowView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeRowView: View {
11 |
12 | var node: NodeModel
13 |
14 | var body: some View {
15 | Text(node.title)
16 | .foregroundStyle(Color.theme.secondary)
17 | .font(.body)
18 | .padding(5)
19 | .background(
20 | RoundedRectangle(cornerRadius: 3)
21 | .stroke(Color.theme.border, lineWidth: 1)
22 | )
23 | }
24 | }
25 |
26 | #Preview {
27 | NodeRowView(node: DeveloperPreview.instance.nodeModel)
28 | }
29 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/Views/NodeSearchView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeSearchView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeSearchView: View {
11 |
12 | @EnvironmentObject private var vm: NodeViewModel
13 | @Environment(\.dismiss) var dismiss
14 |
15 | @State private var selectedNode: NodeModel? = nil
16 | @State private var showDetailView: Bool = false
17 |
18 | var body: some View {
19 | // 搜索框
20 | SearchBarView(searchText: $vm.searchText)
21 |
22 | List {
23 | NodeSectionView(selectedNode: $selectedNode, showDetailView: $showDetailView, title: nil, nodes: vm.allNodes)
24 | .listRowSeparator(.hidden)
25 | }
26 | .listStyle(.plain)
27 | .navigationDestination(isPresented: $showDetailView) {
28 | NodeDetailLoadingView(node: $selectedNode)
29 | }
30 | }
31 | }
32 |
33 | #Preview {
34 | NodeSearchView()
35 | .environmentObject(DeveloperPreview.instance.nodeVM)
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/Views/NodeSectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeSectionView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/17.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeSectionView: View {
11 | @Binding var selectedNode: NodeModel?
12 | @Binding var showDetailView: Bool
13 |
14 | let title: String?
15 | let nodes: [NodeModel]
16 |
17 | var body: some View {
18 | VStack(alignment: .leading) {
19 | if let title = title {
20 | Text(title)
21 | .foregroundStyle(Color.theme.title)
22 | .font(.title)
23 | .bold()
24 | }
25 |
26 | VFlow(alignment: .leading, spacing: 20) {
27 | ForEach(nodes) { node in
28 | NodeRowView(node: node)
29 | .onTapGesture {
30 | segue(node: node)
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
37 | private func segue(node: NodeModel) {
38 | selectedNode = node
39 | showDetailView = true
40 | }
41 | }
42 |
43 | #Preview {
44 | NodeSectionView(selectedNode: .constant(DeveloperPreview.instance.nodeModel), showDetailView: .constant(false), title: "我的关注", nodes: DeveloperPreview.instance.nodes)
45 | }
46 |
--------------------------------------------------------------------------------
/V2EX/Core/Node/Views/NodeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NodeView: View {
11 | @StateObject private var vm: NodeViewModel = NodeViewModel()
12 | @State private var showSheet: Bool = false
13 | @State private var selectedNode: NodeModel? = nil
14 | @State private var showDetailView: Bool = false
15 |
16 |
17 | var body: some View {
18 | NavigationStack {
19 | ScrollView(.vertical) {
20 | LazyVStack {
21 | ForEach(vm.sectionNodes.keys.sorted(), id:\.self) { key in
22 | NodeSectionView(selectedNode: $selectedNode, showDetailView: $showDetailView, title: key.capitalized, nodes: vm.sectionNodes[key] ?? [])
23 | Divider()
24 | .frame(height: 5)
25 | .background(Color.theme.background)
26 | .padding(.vertical, 10)
27 | }
28 | }
29 | }
30 | .scrollIndicators(.hidden)
31 | .padding(.horizontal)
32 | .navigationTitle("节点".localized)
33 | .navigationBarTitleDisplayMode(.inline)
34 | .navigationDestination(isPresented: $showDetailView, destination: {
35 | NodeDetailLoadingView(node: $selectedNode)
36 | .toolbar(.hidden, for: .tabBar)
37 | })
38 | .toolbar {
39 | ToolbarItem(placement: .topBarTrailing) {
40 | Button(action: {
41 | showSheet.toggle()
42 | }, label: {
43 | Image(systemName: "magnifyingglass")
44 | .foregroundStyle(Color.theme.title)
45 | })
46 | }
47 | }
48 | .sheet(isPresented: $showSheet, content: {
49 | NodeSearchView()
50 | })
51 | .environmentObject(vm)
52 | }
53 | }
54 | }
55 |
56 | #Preview {
57 | NavigationStack {
58 | NodeView()
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/V2EX/Core/Notification/ViewModels/NotificationViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotificationViewModel.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/26.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class NotificationViewModel: ObservableObject {
12 |
13 | @Published var notifies: [NotificationModel] = []
14 | @Published var isLoading: Bool = false
15 |
16 | private let dataService = NotifyDataService()
17 | private var cancelables = Set()
18 |
19 | init() {
20 | addSubscribers()
21 | }
22 |
23 | private func addSubscribers() {
24 | dataService.$notifies
25 | .sink { [weak self] returnedNotifies in
26 | self?.notifies = returnedNotifies
27 | self?.isLoading = false
28 | }
29 | .store(in: &cancelables)
30 | }
31 |
32 | func getNotifies() {
33 | isLoading = true
34 | dataService.getNotifies()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Core/Notification/Views/NotifcationRowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotifcationRowView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/11/10.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NotifcationRowView: View {
11 |
12 | @EnvironmentObject private var appState: AppState
13 | var notification: NotificationModel
14 |
15 | var body: some View {
16 | VStack {
17 | HStack(alignment: .top, spacing: 15) {
18 | if let member = appState.member {
19 | UserImageView(member: member)
20 | .frame(width: 40, height: 40)
21 | .clipShape(.rect(cornerRadius: 5))
22 | } else {
23 | Rectangle()
24 | .background(Color.red)
25 | .frame(width: 40, height: 40)
26 | .clipShape(.rect(cornerRadius: 5))
27 | }
28 |
29 | VStack(alignment: .leading, spacing: 20) {
30 | markdownView
31 |
32 | if let payload = notification.payload {
33 | Text(payload)
34 | .foregroundStyle(Color.theme.body)
35 | .font(.body)
36 | }
37 | HStack {
38 | Text(notification.created.asTimeAgoDisplay())
39 | Spacer()
40 | Image(systemName: "ellipsis")
41 | }
42 | .font(.caption)
43 | .foregroundStyle(.caption)
44 | }
45 | }
46 |
47 | Divider()
48 | .foregroundStyle(Color.theme.border)
49 | }
50 | .padding(.horizontal, 10)
51 | .padding(.top, 15)
52 | .padding(.bottom, 5)
53 | }
54 | }
55 |
56 | #Preview {
57 | NotifcationRowView(notification: DeveloperPreview.instance.notificationModel)
58 | .previewLayout(.sizeThatFits)
59 | .environmentObject(AppState())
60 | }
61 |
62 | extension NotifcationRowView {
63 |
64 | private var markdownView: some View {
65 | let topic = notification.text.convertHTMLToAttributedString()
66 | return Text(topic)
67 | .foregroundStyle(Color.theme.body)
68 | .font(.body)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/V2EX/Core/Notification/Views/NotificationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotificationView.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/11/10.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NotificationView: View {
11 | @EnvironmentObject private var appState: AppState
12 | @StateObject private var vm: NotificationViewModel = NotificationViewModel()
13 |
14 | @State private var showLoginView: Bool = false
15 |
16 | var body: some View {
17 | ZStack {
18 | if let _ = appState.member {
19 | ZStack {
20 | if vm.isLoading {
21 | ProgressView()
22 | } else {
23 | if vm.notifies.isEmpty {
24 | emptyView
25 | }
26 |
27 | List {
28 | ForEach(vm.notifies) { notify in
29 | NotifcationRowView(notification: notify)
30 | .listRowInsets(EdgeInsets())
31 | .listRowSeparator(.hidden)
32 | }
33 | }
34 | }
35 | }
36 | .onAppear {
37 | vm.getNotifies()
38 | }
39 | } else {
40 | // not login
41 | VStack(spacing: 15) {
42 | Image(systemName: "bell.fill")
43 | .foregroundStyle(Color.theme.caption)
44 | .font(.system(size: 50))
45 | Text("此功能需要登录,要不登录下?".localized)
46 | .foregroundStyle(Color.theme.caption)
47 | .font(.body)
48 | Button(action: {
49 | showLoginView.toggle()
50 | }, label: {
51 | Text("去登录".localized)
52 | .foregroundStyle(Color.theme.secondary)
53 | .font(.body)
54 | .padding(.vertical, 5)
55 | .padding(.horizontal, 40)
56 | })
57 | .overlay {
58 | RoundedRectangle(cornerRadius: 5)
59 | .stroke(Color.theme.secondary, lineWidth: 1.0)
60 | }
61 | }
62 | }
63 | }
64 | .listStyle(.plain)
65 | .navigationTitle("通知".localized)
66 | .navigationBarTitleDisplayMode(.inline)
67 | .sheet(isPresented: $showLoginView) {
68 | LoginView()
69 | }
70 | }
71 | }
72 |
73 | #Preview {
74 | NavigationStack {
75 | NotificationView()
76 | .environmentObject(AppState())
77 | }
78 | }
79 |
80 | extension NotificationView {
81 | // Empty View
82 | private var emptyView: some View {
83 | VStack(spacing: 20) {
84 | Image(systemName: "bell")
85 | .foregroundStyle(Color.theme.caption)
86 | .font(.system(size: 48))
87 |
88 | Text("暂时还没有通知".localized)
89 | .foregroundStyle(Color.theme.caption)
90 | .font(.body)
91 |
92 | Button(action: {
93 | vm.getNotifies()
94 | }, label: {
95 | Text("刷新下".localized)
96 | .foregroundStyle(Color.theme.secondary)
97 | .font(.body)
98 | .padding(.horizontal, 30)
99 | .padding(.vertical, 5)
100 | .background(Color.clear)
101 | .overlay(
102 | RoundedRectangle(cornerRadius: 5)
103 | .stroke(Color.theme.secondary, lineWidth: 1)
104 | )
105 | })
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/V2EX/Extension/Color.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Color.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/13.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension Color {
12 | static let theme = ColorTheme()
13 | }
14 |
15 | struct ColorTheme {
16 | let accent = Color("AccentColor")
17 | let body = Color("BodyColor")
18 | let secondary = Color("SecondaryColor")
19 | let background = Color("BackgroundColor")
20 | let surface = Color("SurfaceColor")
21 | let border = Color("BorderColor")
22 | let caption = Color("CaptionColor")
23 | let title = Color("TitleColor")
24 | let tabBarNormal = Color("TabBarButtonSelectedColor")
25 | let dager = Color("DangerColor")
26 | let dark = Color("DarkColor")
27 | }
28 |
--------------------------------------------------------------------------------
/V2EX/Extension/Date.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Date.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Date {
11 | // 时间戳 -》 Date -> String
12 | /**
13 | xx 分钟前
14 | xx 小时前
15 | xx 天前
16 | xx 周前
17 | xx 年前
18 | yyyy/MM/dd
19 | */
20 | func timeAgoDisplay() -> String {
21 | let secondsAgo = Int(Date().timeIntervalSince(self))
22 |
23 | let minute = 60
24 | let hour = 60 * minute
25 | let day = 24 * hour
26 | let week = 7 * day
27 | let month = 30 * day
28 | let year = 365 * day
29 |
30 | if secondsAgo < minute {
31 | return String(format: "%ld 秒前".localized, secondsAgo)
32 | } else if secondsAgo < hour {
33 | return String(format: "%ld 分钟前".localized, secondsAgo / minute)
34 | } else if secondsAgo < day {
35 | return String(format: "%ld 小时前".localized, secondsAgo / hour)
36 | } else if secondsAgo < week {
37 | return String(format: "%ld 天前".localized, secondsAgo / day)
38 | } else if secondsAgo < month {
39 | return String(format: "%ld 周前".localized, secondsAgo / week)
40 | } else if secondsAgo < year {
41 | return String(format: "%ld 月前".localized, secondsAgo / month)
42 | } else {
43 | let formatter = DateFormatter()
44 | formatter.dateFormat = "yyyy/MM/dd"
45 | return formatter.string(from: self)
46 | }
47 | }
48 |
49 |
50 | // yyyy-MM-dd
51 | private var hyphenFormatter: DateFormatter {
52 | let formatter = DateFormatter()
53 | formatter.dateFormat = "yyyy-MM-dd"
54 | return formatter
55 | }
56 |
57 | func asHyphenString() -> String {
58 | hyphenFormatter.string(from: self)
59 | }
60 |
61 | // yyyy年MM月dd日
62 | private var chineseFormatter: DateFormatter {
63 | let formatter = DateFormatter()
64 | formatter.dateFormat = "yyyy年MM月dd日"
65 | return formatter
66 | }
67 |
68 | func asChineseDateString() -> String {
69 | chineseFormatter.string(from: self)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/V2EX/Extension/Double.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Double.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/27.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Double {
11 | func asTimeAgoDisplay() -> String {
12 | return Date(timeIntervalSince1970: self).timeAgoDisplay()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/V2EX/Extension/PreviewProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreviewProvider.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/20.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | class DeveloperPreview {
12 | static let instance = DeveloperPreview()
13 | private init() { }
14 |
15 | let homeVM = HomeViewModel()
16 |
17 | let nodeVM = NodeViewModel()
18 |
19 | let notifyVM = NotificationViewModel()
20 |
21 | lazy var memberModel: MemberModel = {
22 | MemberModel(
23 | id: 82242,
24 | username: "mx3y",
25 | url: "https://www.v2ex.com/u/mx3y",
26 | website: nil,
27 | twitter: nil,
28 | psn: nil,
29 | github: nil,
30 | btc: nil,
31 | location: nil,
32 | tagline: nil,
33 | bio: nil,
34 | avatarMini: "https://cdn.v2ex.com/avatar/fcb4/b749/82242_mini.png?m=1689670440",
35 | avatarNormal: "https://cdn.v2ex.com/avatar/fcb4/b749/82242_normal.png?m=1689670440",
36 | avatarLarge: "https://cdn.v2ex.com/avatar/fcb4/b749/82242_large.png?m=1689670440",
37 | created: 1416278855,
38 | lastModified: 1689670440,
39 | status: "found"
40 | )
41 | }()
42 |
43 | lazy var nodeModel: NodeModel = {
44 | NodeModel(
45 | avatarLarge: "https://cdn.v2ex.com/navatar/c20a/d4d7/12_large.png?m=1650095340",
46 | name: "qna",
47 | avatarNormal: "https://cdn.v2ex.com/navatar/c20a/d4d7/12_normal.png?m=1650095340",
48 | title: "问与答",
49 | url: "https://www.v2ex.com/go/qna",
50 | topics: 225477,
51 | footer: "",
52 | header: "一个更好的世界需要你持续地提出好问题。",
53 | titleAlternative: "Questions and Answers",
54 | avatarMini: "https://cdn.v2ex.com/navatar/c20a/d4d7/12_mini.png?m=1650095340",
55 | stars: 4270,
56 | aliases: [],
57 | root: false,
58 | id: 12,
59 | parentNodeName: "v2ex"
60 | )
61 | }()
62 |
63 | lazy var topicModel: TopicModel = {
64 | TopicModel(
65 | node: nodeModel,
66 | member: memberModel,
67 | lastReplyBy: "znyb",
68 | lastTouched: 1732065304,
69 | title: "求指导,怎样让自己脾气好起来",
70 | url: "https://www.v2ex.com/t/1090746",
71 | created: 1731985590,
72 | lastModified: 1731985590,
73 | deleted: 0,
74 | replies: 171,
75 | id: 1090746,
76 | content: "RT\r\n\r\n我开车遇到后面一直按喇叭不停的,会停下来拉对方车门。\r\n晚上大半夜楼下有飙摩托车的,我会跑下去堵人车。\r\n小区物业不作为,我会直接去物业办公室开怼。\r\n公共场所看见有人插队,我会堵住插队人让对方排队。\r\n。。。。。。。。。。。。\r\n这种情况我会经常发生,只要我出门,看见不对的,我就会动起来。。。\r\n这几天看到珠海、成都,今天看到常德的事情,我怂了,我想改改,之前我不认为我有错,现在我也不觉得我这样有错,但是感觉我这种脾气可能哪天就上新闻了,希望改一改。\r\n\r\n求有之前类似情况的 V 友分享下你们脾气改了吗,怎么做到的。",
77 | contentRendered: ""
78 | )
79 | }()
80 |
81 | lazy var nodes: [NodeModel] = {
82 | return [
83 | nodeModel,
84 | NodeModel(avatarLarge: "https://cdn.v2ex.com/navatar/c4ca/4238/1_large.png?m=1700044199", name: "babel", avatarNormal: "https://cdn.v2ex.com/navatar/c4ca/4238/1_normal.png?m=1700044199", title: "Project Babel", url: "https://www.v2ex.com/go/babel", topics: 1123, footer: "", header: "", titleAlternative: "Project Babel", avatarMini: "https://cdn.v2ex.com/navatar/c4ca/4238/1_mini.png?m=1700044199", stars: 411, aliases: [], root: false, id: 1, parentNodeName: "v2ex"),
85 | NodeModel(avatarLarge: "https://cdn.v2ex.com/navatar/e4da/3b7f/5_large.png?m=1584385880", name: "movie", avatarNormal: "https://cdn.v2ex.com/navatar/e4da/3b7f/5_normal.png?m=1584385880", title: "电影", url: "https://www.v2ex.com/go/movie", topics: 1379, footer: "", header: "用 90 分钟去体验另外一个世界。", titleAlternative: "Movie", avatarMini: "https://cdn.v2ex.com/navatar/e4da/3b7f/5_mini.png?m=1584385880", stars: 2088, aliases: [], root: false, id: 5, parentNodeName: "life"),
86 | ]
87 | }()
88 |
89 | lazy var notificationModel: NotificationModel = {
90 | NotificationModel(
91 | id: 24084148,
92 | memberID: 10131,
93 | forMemberID: 629868,
94 | text: "lidashuang 在 学习 Django 还有必要吗 里回复了你",
95 | payload: "可以试试 rails https://ruby-china.org/topics/43935\r\nRuby 三年后,仍在热爱 Ruby",
96 | payloadRendered: "可以试试 rails https://ruby-china.org/topics/43935
Ruby 三年后,仍在热爱 Ruby",
97 | created: 1732689217
98 | // member: memberModel
99 | )
100 | }()
101 |
102 | lazy var replyModel: ReplyModel = {
103 | ReplyModel(
104 | member: memberModel,
105 | created: 1733983641,
106 | topicID: 1096995,
107 | content: "没钱创个毛~",
108 | contentRendered: "没钱创个毛~",
109 | lastModified: 1733983641,
110 | memberID: 602847,
111 | id: 15659718
112 | )
113 | }()
114 | }
115 |
--------------------------------------------------------------------------------
/V2EX/Extension/String.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/27.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension String {
12 | // 将 HTML 格式文本转成富文本
13 | func convertHTMLToAttributedString() -> AttributedString {
14 | do {
15 | let attr = try NSAttributedString(
16 | data: Data(self.utf8),
17 | options: [.documentType: NSAttributedString.DocumentType.html,
18 | .characterEncoding: String.Encoding.utf8.rawValue],
19 | documentAttributes: nil
20 | )
21 | var atttibutedString = AttributedString(attr)
22 | atttibutedString.foregroundColor = .body
23 | atttibutedString.font = .headline
24 | // 修改链接样式
25 | for run in atttibutedString.runs {
26 | if run.link != nil {
27 | atttibutedString[run.range].foregroundColor = Color.theme.secondary
28 | atttibutedString[run.range].underlineStyle = .single
29 | }
30 | }
31 |
32 | return atttibutedString
33 | } catch {
34 | return AttributedString()
35 | }
36 | }
37 |
38 | // 多语言适配
39 | var localized: String {
40 | @AppStorage("language") var language: AppLanguage = .auto
41 | if let path = Bundle.main.path(forResource: language.name, ofType: "lproj") {
42 | return NSLocalizedString(self, tableName: "V2EXLocalizable", bundle: Bundle(path: path) ?? .main, comment: "")
43 | } else {
44 | return self
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Extension/UIApplication.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplication.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension UIApplication {
12 |
13 | func endEditing() {
14 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
15 | }
16 |
17 | static var window: UIWindow? {
18 | guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
19 | let window = windowScene.windows.first else {
20 | return nil
21 | }
22 | return window
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/V2EX/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleURLTypes
6 |
7 |
8 | CFBundleTypeRole
9 | Editor
10 | CFBundleURLName
11 | com.kim.xiao.V2EX
12 | CFBundleURLSchemes
13 |
14 | v2ex
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/V2EX/Lib/SwiftUIFlow/API/Flow.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIFlow
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | public struct Flow: View
10 | where Content : View
11 | {
12 | public var body: some View {
13 | GeometryReader { geometry in
14 | ZStack(alignment: alignment) {
15 | Color.clear
16 | .hidden()
17 |
18 | FlowLayout(alignment: alignment,
19 | axis: axis,
20 | content: content,
21 | horizontalSpacing: horizontalSpacing ?? 8,
22 | size: geometry.size,
23 | verticalSpacing: verticalSpacing ?? 8)
24 | .transaction {
25 | updateTransaction($0)
26 | }
27 | .background(
28 | GeometryReader { geometry in
29 | Color.clear
30 | .onAppear { contentSize = geometry.size }
31 | .onChange(of: geometry.size) { newValue in
32 | DispatchQueue.main.async {
33 | withTransaction(transaction) {
34 | contentSize = newValue
35 | }
36 | }
37 | }
38 | }
39 | .hidden()
40 | )
41 | }
42 | }
43 | .frame(width: axis == .horizontal ? contentSize.width : nil,
44 | height: axis == .vertical ? contentSize.height : nil)
45 | }
46 |
47 | @State private var contentSize = CGSize.zero
48 | @State private var transaction = Transaction()
49 |
50 | private var alignment: Alignment
51 | private var axis: Axis
52 | private var content: () -> Content
53 | private var horizontalSpacing: CGFloat?
54 | private var verticalSpacing: CGFloat?
55 | }
56 |
57 |
58 | public extension Flow {
59 | /// A view that arranges its children in a flow.
60 | ///
61 | /// This view returns a flexible preferred size, orthogonal to the layout axis, to its parent layout.
62 | ///
63 | /// - Parameters:
64 | /// - axis: The layout axis of this flow.
65 | /// - alignment: The guide for aligning the subviews in this flow on both
66 | /// the x- and y-axes.
67 | /// - spacing: The distance between adjacent subviews, or `nil` if you
68 | /// want the flow to choose a default distance for each pair of
69 | /// subviews.
70 | /// - content: A view builder that creates the content of this flow.
71 | init(_ axis: Axis,
72 | alignment: Alignment = .center,
73 | spacing: CGFloat? = nil,
74 | @ViewBuilder content: @escaping () -> Content)
75 | {
76 | self.alignment = alignment
77 | self.axis = axis
78 | self.content = content
79 | self.horizontalSpacing = spacing
80 | self.verticalSpacing = spacing
81 | }
82 |
83 | /// A view that arranges its children in a flow.
84 | ///
85 | /// This view returns a flexible preferred size, orthogonal to the layout axis, to its parent layout.
86 | ///
87 | /// - Parameters:
88 | /// - axis: The layout axis of this flow.
89 | /// - alignment: The guide for aligning the subviews in this flow on both
90 | /// the x- and y-axes.
91 | /// - horizontalSpacing: The distance between horizontally adjacent
92 | /// subviews, or `nil` if you want the flow to choose a default distance
93 | /// for each pair of subviews.
94 | /// - verticalSpacing: The distance between vertically adjacent
95 | /// subviews, or `nil` if you want the flow to choose a default distance
96 | /// for each pair of subviews.
97 | /// - content: A view builder that creates the content of this flow.
98 | init(_ axis: Axis,
99 | alignment: Alignment = .center,
100 | horizontalSpacing: CGFloat? = nil,
101 | verticalSpacing: CGFloat? = nil,
102 | @ViewBuilder content: @escaping () -> Content)
103 | {
104 | self.alignment = alignment
105 | self.axis = axis
106 | self.content = content
107 | self.horizontalSpacing = horizontalSpacing
108 | self.verticalSpacing = verticalSpacing
109 | }
110 | }
111 |
112 |
113 | private extension Flow {
114 | func updateTransaction(_ newValue: Transaction) {
115 | if transaction.animation != newValue.animation
116 | || transaction.disablesAnimations != newValue.disablesAnimations
117 | || transaction.isContinuous != newValue.isContinuous
118 | {
119 | DispatchQueue.main.async {
120 | transaction = newValue
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/V2EX/Lib/SwiftUIFlow/API/HFlow.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIFlow
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | public struct HFlow: View
10 | where Content : View
11 | {
12 | public var body: Flow
13 | }
14 |
15 |
16 | public extension HFlow {
17 | /// A view that arranges its children in a horizontal flow.
18 | ///
19 | /// This view returns a flexible preferred height to its parent layout.
20 | ///
21 | /// - Parameters:
22 | /// - alignment: The guide for aligning the subviews in this flow. This
23 | /// guide has the same vertical screen coordinate for every child view.
24 | /// - spacing: The distance between adjacent subviews, or `nil` if you
25 | /// want the flow to choose a default distance for each pair of
26 | /// subviews.
27 | /// - content: A view builder that creates the content of this flow.
28 | init(alignment: VerticalAlignment = .center,
29 | spacing: CGFloat? = nil,
30 | @ViewBuilder content: @escaping () -> Content)
31 | {
32 | self.body = Flow(.horizontal,
33 | alignment: Alignment(horizontal: .leading, vertical: alignment),
34 | spacing: spacing,
35 | content: content)
36 | }
37 |
38 | /// A view that arranges its children in a horizontal flow.
39 | ///
40 | /// This view returns a flexible preferred height to its parent layout.
41 | ///
42 | /// - Parameters:
43 | /// - alignment: The guide for aligning the subviews in this flow. This
44 | /// guide has the same vertical screen coordinate for every child view.
45 | /// - horizontalSpacing: The distance between horizontally adjacent
46 | /// subviews, or `nil` if you want the flow to choose a default distance
47 | /// for each pair of subviews.
48 | /// - verticalSpacing: The distance between vertically adjacent
49 | /// subviews, or `nil` if you want the flow to choose a default distance
50 | /// for each pair of subviews.
51 | /// - content: A view builder that creates the content of this flow.
52 | init(alignment: VerticalAlignment = .center,
53 | horizontalSpacing: CGFloat? = nil,
54 | verticalSpacing: CGFloat? = nil,
55 | @ViewBuilder content: @escaping () -> Content)
56 | {
57 | self.body = Flow(.horizontal,
58 | alignment: Alignment(horizontal: .leading, vertical: alignment),
59 | horizontalSpacing: horizontalSpacing,
60 | verticalSpacing: verticalSpacing,
61 | content: content)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/V2EX/Lib/SwiftUIFlow/API/VFlow.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIFlow
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | public struct VFlow: View
10 | where Content : View
11 | {
12 | public var body: Flow
13 | }
14 |
15 |
16 | public extension VFlow {
17 | /// A view that arranges its children in a vertical flow.
18 | ///
19 | /// This view returns a flexible preferred width to its parent layout.
20 | ///
21 | /// - Parameters:
22 | /// - alignment: The guide for aligning the subviews in this flow. This
23 | /// guide has the same horizontal screen coordinate for every child view.
24 | /// - spacing: The distance between adjacent subviews, or `nil` if you
25 | /// want the flow to choose a default distance for each pair of
26 | /// subviews.
27 | /// - content: A view builder that creates the content of this flow.
28 | init(alignment: HorizontalAlignment = .center,
29 | spacing: CGFloat? = nil,
30 | @ViewBuilder content: @escaping () -> Content)
31 | {
32 | self.body = Flow(.vertical,
33 | alignment: Alignment(horizontal: alignment, vertical: .top),
34 | spacing: spacing,
35 | content: content)
36 | }
37 |
38 | /// A view that arranges its children in a vertical flow.
39 | ///
40 | /// This view returns a flexible preferred width to its parent layout.
41 | ///
42 | /// - Parameters:
43 | /// - alignment: The guide for aligning the subviews in this flow. This
44 | /// guide has the same horizontal screen coordinate for every child view.
45 | /// - horizontalSpacing: The distance between horizontally adjacent
46 | /// subviews, or `nil` if you want the flow to choose a default distance
47 | /// for each pair of subviews.
48 | /// - verticalSpacing: The distance between vertically adjacent
49 | /// subviews, or `nil` if you want the flow to choose a default distance
50 | /// for each pair of subviews.
51 | /// - content: A view builder that creates the content of this flow.
52 | init(alignment: HorizontalAlignment = .center,
53 | horizontalSpacing: CGFloat? = nil,
54 | verticalSpacing: CGFloat? = nil,
55 | @ViewBuilder content: @escaping () -> Content)
56 | {
57 | self.body = Flow(.vertical,
58 | alignment: Alignment(horizontal: alignment, vertical: .top),
59 | horizontalSpacing: horizontalSpacing,
60 | verticalSpacing: verticalSpacing,
61 | content: content)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/V2EX/Lib/SwiftUIFlow/Internal/FlowLayout.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIFlow
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal struct FlowLayout: View
10 | where Content : View
11 | {
12 | var alignment: Alignment
13 | var axis: Axis
14 | var content: () -> Content
15 | var horizontalSpacing: CGFloat
16 | var size: CGSize
17 | var verticalSpacing: CGFloat
18 |
19 | var body: some View {
20 | var alignments: [CGSize] = []
21 |
22 | var currentIndex = 0
23 | var lineFirstIndex = 0
24 | var isLastLineAdjusted = false
25 | var leading: CGFloat = 0
26 | var top: CGFloat = 0
27 | var maxValue: CGFloat = 0
28 | var maxLineValue: CGFloat = 0
29 | var maxIndex: Int? = nil
30 |
31 | ZStack(alignment: .topLeading) {
32 | content()
33 | .fixedSize()
34 | .alignmentGuide(.leading) { dimensions in
35 | if let maxIndex = maxIndex, currentIndex > maxIndex {
36 | currentIndex %= maxIndex
37 | }
38 |
39 | if alignments.indices.contains(currentIndex) {
40 | return alignments[currentIndex].width
41 | }
42 |
43 | switch axis {
44 | case .horizontal:
45 | if (abs(top - dimensions.height) > size.height) {
46 | //Adjust previous lines
47 | if -top > maxLineValue {
48 | let adjustment = (maxLineValue + top)
49 | * (dimensions[alignment.vertical] / dimensions[.bottom])
50 |
51 | for index in 0.. size.width) {
80 | //Adjust previous lines
81 | if -leading > maxLineValue {
82 | let adjustment = (maxLineValue + leading)
83 | * (dimensions[alignment.horizontal] / dimensions[.trailing])
84 |
85 | for index in 0.. maxIndex {
115 | currentIndex %= maxIndex
116 | }
117 |
118 | let top: CGFloat
119 | if alignments.indices.contains(currentIndex) {
120 | top = alignments[currentIndex].height
121 | } else {
122 | top = 0
123 | }
124 |
125 | currentIndex += 1
126 | return top
127 | }
128 |
129 | Color.clear
130 | .frame(width: 1, height: 1)
131 | .alignmentGuide(.leading) { dimensions in
132 | if maxIndex == nil {
133 | maxIndex = currentIndex
134 | }
135 |
136 | if !isLastLineAdjusted, let lastIndex = alignments.indices.last {
137 | switch axis {
138 | case .horizontal:
139 | let adjustment = (maxLineValue + top)
140 | * (dimensions[alignment.vertical] / dimensions[.bottom])
141 |
142 | for index in lineFirstIndex...lastIndex {
143 | alignments[index].height -= adjustment
144 | }
145 |
146 | case .vertical:
147 | let adjustment = (maxLineValue + leading)
148 | * (dimensions[alignment.horizontal] / dimensions[.trailing])
149 |
150 | for index in lineFirstIndex...lastIndex {
151 | alignments[index].width -= adjustment
152 | }
153 | }
154 |
155 | isLastLineAdjusted = true
156 | }
157 |
158 | currentIndex = 0
159 | lineFirstIndex = 0
160 | leading = 0
161 | top = 0
162 | maxValue = 0
163 | maxLineValue = 0
164 | return 0
165 | }
166 | .hidden()
167 | }
168 | .frame(width: axis == .vertical ? 0 : nil,
169 | height: axis == .horizontal ? 0 : nil,
170 | alignment: alignment)
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/V2EX/Modifier/EmptyViewModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmptyViewModifier.swift
3 | // V2EX
4 | //
5 | // Created by Aaron on 2024/11/11.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EmptyViewModifier: ViewModifier {
11 | @Binding var isEmpty: Bool
12 | @ViewBuilder var emptyView: () -> T
13 |
14 | func body(content: Content) -> some View {
15 | content
16 | .overlay {
17 | emptyView()
18 | .opacity(isEmpty ? 1 : 0)
19 | }
20 | }
21 | }
22 |
23 | extension View {
24 | func emptyView(isEmpty: Binding, @ViewBuilder content: @escaping () -> Content) -> some View {
25 | modifier(EmptyViewModifier(isEmpty: isEmpty, emptyView: content))
26 | }
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/V2EX/Modifier/NavigationBarModifiler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationBarModifiler.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/13.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NavigationBarModifiler: ViewModifier {
11 | @Environment(\.dismiss) var dismiss
12 | let title: String
13 |
14 | func body(content: Content) -> some View {
15 | content
16 | .navigationTitle(title)
17 | .navigationBarTitleDisplayMode(.inline)
18 | .navigationBarBackButtonHidden()
19 | .toolbar {
20 | ToolbarItem(placement: .topBarLeading) {
21 | Button(action: {
22 | dismiss()
23 | }, label: {
24 | Image(systemName: "chevron.backward")
25 | .foregroundStyle(Color.theme.title)
26 | })
27 | }
28 | }
29 | }
30 | }
31 |
32 | extension View {
33 | func customNavigationBar(title: String) -> some View {
34 | modifier(NavigationBarModifiler(title: title))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Modifier/ToastViewModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ToastViewModifier.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/15.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ToastView: View {
11 | var message: String
12 |
13 | var body: some View {
14 | Text(message)
15 | .padding()
16 | .background(Color.black.opacity(0.7))
17 | .foregroundColor(.white)
18 | .cornerRadius(8)
19 | .padding(.horizontal, 20)
20 | .frame(maxWidth: .infinity)
21 | .transition(.slide)
22 | .animation(.easeInOut, value: message)
23 | .padding(.bottom, 50)
24 | }
25 | }
26 |
27 | struct ToastViewModifier: ViewModifier {
28 | var message: String
29 | @Binding var showToast: Bool
30 |
31 | func body(content: Content) -> some View {
32 | ZStack {
33 | content
34 |
35 | if showToast {
36 | ToastView(message: message)
37 | .onAppear {
38 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
39 | showToast = false
40 | }
41 | }
42 | }
43 | }
44 | }
45 | }
46 |
47 | extension View {
48 | func toast(_ message: String, showToast: Binding) -> some View {
49 | modifier(ToastViewModifier(message: message, showToast: showToast))
50 | }
51 | }
52 |
53 |
54 |
--------------------------------------------------------------------------------
/V2EX/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/V2EX/Representable/MailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MailView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/15.
6 | //
7 |
8 | import SwiftUI
9 | import MessageUI
10 |
11 | struct MailView: UIViewControllerRepresentable {
12 | @Environment(\.dismiss) var dismiss // 用于关闭邮件视图
13 | var subject: String
14 | var recipients: [String]?
15 | var body: String
16 |
17 | class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
18 | let parent: MailView
19 |
20 | init(parent: MailView) {
21 | self.parent = parent
22 | }
23 |
24 | func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
25 | controller.dismiss(animated: true) {
26 | self.parent.dismiss()
27 | }
28 | }
29 | }
30 |
31 | func makeCoordinator() -> Coordinator {
32 | return Coordinator(parent: self)
33 | }
34 |
35 | func makeUIViewController(context: Context) -> MFMailComposeViewController {
36 | let mailComposeVC = MFMailComposeViewController()
37 | mailComposeVC.mailComposeDelegate = context.coordinator
38 | mailComposeVC.setSubject(subject)
39 | mailComposeVC.setToRecipients(recipients)
40 | mailComposeVC.setMessageBody(body, isHTML: false)
41 | return mailComposeVC
42 | }
43 |
44 | func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {
45 | // No updates needed
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/V2EX/Representable/ShareSheet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShareSheet.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/15.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ShareSheet: UIViewControllerRepresentable {
11 | var items: [Any] // 要分享的内容
12 | var excludedActivityTypes: [UIActivity.ActivityType]? = nil
13 |
14 | func makeUIViewController(context: Context) -> UIActivityViewController {
15 | let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
16 | controller.excludedActivityTypes = excludedActivityTypes
17 | return controller
18 | }
19 |
20 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
21 | // No updates needed
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/V2EX/Services/MemberDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MemberDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2025/1/9.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class MemberDataService: ObservableObject {
13 |
14 | @Published var member: MemberModel?
15 | @AppStorage("token") var token: String = ""
16 |
17 | private var memberSubscription: AnyCancellable?
18 |
19 | init() { }
20 |
21 | func getMember(token: String) {
22 | guard let url = URL(string: "https://www.v2ex.com/api/v2/member") else {
23 | return
24 | }
25 |
26 | memberSubscription = NetworkingManager.download(url: url, token: token)
27 | .decode(type: MemberModelWrapper.self, decoder: JSONDecoder())
28 | .sink(receiveCompletion: NetworkingManager.handleCompletion) { [weak self] returnedMember in
29 | self?.member = returnedMember.member
30 | if let _ = self?.member {
31 | self?.token = token
32 | }
33 | self?.memberSubscription?.cancel()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Services/NodeDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class NodeDataService: ObservableObject {
12 |
13 | @Published var allNodes: [NodeModel] = []
14 |
15 | private var nodeSubscribtion: AnyCancellable?
16 |
17 | init() {
18 | getAllNodes()
19 | }
20 |
21 | private func getAllNodes() {
22 | guard let url = URL(string: "https://www.v2ex.com/api/nodes/all.json") else {
23 | return
24 | }
25 |
26 | nodeSubscribtion = NetworkingManager.download(url: url)
27 | .decode(type: [NodeModel].self, decoder: JSONDecoder())
28 | .sink(receiveCompletion:NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedNodes in
29 | self?.allNodes = returnedNodes
30 | self?.nodeSubscribtion?.cancel()
31 | })
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/V2EX/Services/NodeImageDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NodeImageDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/18.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class NodeImageDataService: ObservableObject {
13 |
14 | @Published var nodeImage: UIImage? = nil
15 |
16 | private var imageSubscribtion: AnyCancellable?
17 | private let node: NodeModel
18 | private let fileManager = LocalFileManager.instance
19 | private let imageName: String
20 | private let folderName: String = "node_iamges"
21 |
22 | init(node: NodeModel) {
23 | self.node = node
24 | self.imageName = "\(node.id)"
25 | getNodeImage()
26 | }
27 |
28 | private func getNodeImage() {
29 | if let savedImage = fileManager.getImage(imageName: imageName, folderName: folderName) {
30 | nodeImage = savedImage
31 | } else {
32 | downloadNodeImage()
33 | }
34 | }
35 |
36 | private func downloadNodeImage() {
37 | guard let url = URL(string: node.avatarNormal) else {
38 | return
39 | }
40 |
41 | imageSubscribtion = NetworkingManager.download(url: url)
42 | .tryMap({ data in
43 | return UIImage(data: data)
44 | })
45 | .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedImage in
46 | self?.nodeImage = returnedImage
47 | self?.imageSubscribtion?.cancel()
48 | })
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/V2EX/Services/NotifyDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotifyDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/27.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class NotifyDataService: ObservableObject {
13 |
14 | @Published var notifies: [NotificationModel] = []
15 | @AppStorage("token") var token: String = ""
16 |
17 | private var notifySubscribtion: AnyCancellable?
18 |
19 | init() {}
20 |
21 | func getNotifies() {
22 | guard !token.isEmpty,
23 | let url = URL(string: "https://www.v2ex.com/api/v2/notifications?p=1") else {
24 | notifies = []
25 | return
26 | }
27 | notifySubscribtion = NetworkingManager.download(url: url, token: token)
28 | .map({ data -> [NotificationModel] in
29 | do {
30 | let returnedValue = try JSONDecoder().decode(NotificationResult.self, from: data)
31 | return returnedValue.result
32 | } catch {
33 | print(error)
34 | return []
35 | }
36 | })
37 | // .decode(type: [NotificationModel].self, decoder: NetworkingManager.decoder)
38 | .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedNotifies in
39 | self?.notifies = returnedNotifies
40 | self?.notifySubscribtion?.cancel()
41 | })
42 | }
43 | }
44 |
45 |
46 |
--------------------------------------------------------------------------------
/V2EX/Services/ReplyDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicReplyDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/12/12.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | /// 主题回复
12 | class ReplyDataService: ObservableObject {
13 |
14 | @Published var replies: [ReplyModel] = []
15 |
16 | private let topic: TopicModel
17 | private var replySubscribtion: AnyCancellable?
18 |
19 | init(topic: TopicModel) {
20 | self.topic = topic
21 | getData()
22 | }
23 |
24 | private func getData() {
25 | guard let url = URL(string: "https://www.v2ex.com/api/replies/show.json?topic_id=\(topic.id)&page=1&page_size=100") else {
26 | return
27 | }
28 |
29 | replySubscribtion = NetworkingManager.download(url: url)
30 | .map({ data -> [ReplyModel] in
31 | do {
32 | let returnedValue = try JSONDecoder().decode([ReplyModel].self, from: data)
33 | return returnedValue
34 | } catch {
35 | print(error)
36 | return []
37 | }
38 | })
39 | // .decode(type: [ReplyModel].self, decoder: JSONDecoder())
40 | .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedNotifies in
41 | self?.replies = returnedNotifies
42 | self?.replySubscribtion?.cancel()
43 | })
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/V2EX/Services/StatDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/25.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class StatDataService: ObservableObject {
12 |
13 | @Published var stat: SiteStatModel = SiteStatModel(topicMax: 0, memberMax: 0)
14 |
15 | private var statSubscribtion: AnyCancellable?
16 |
17 | init() {
18 | getSiteStat()
19 | }
20 |
21 | private func getSiteStat() {
22 | guard let url = URL(string: "https://www.v2ex.com/api/site/stats.json") else {
23 | return
24 | }
25 |
26 | let jsonDecoder = JSONDecoder()
27 | jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
28 |
29 | statSubscribtion = NetworkingManager.download(url: url)
30 | .decode(type: SiteStatModel.self, decoder: jsonDecoder)
31 | .sink(receiveCompletion:NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedStat in
32 | self?.stat = returnedStat
33 | self?.statSubscribtion?.cancel()
34 | })
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/V2EX/Services/TopicDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TopicDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/21.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | // 定义枚举表示选中状态
12 | enum TopicCategory: Equatable {
13 | case hot // 热门
14 | case latest // 最新
15 | case node(nodeName: String) // 节点下的所有主题
16 | case user(userName: String) // 用户发表的所有主题
17 |
18 | var rawValue: String {
19 | switch self {
20 | case .hot:
21 | return "热门".localized
22 | case .latest:
23 | return "最新".localized
24 | case .node:
25 | return ""
26 | case .user:
27 | return ""
28 | }
29 | }
30 |
31 | var urlString: String {
32 | switch self {
33 | case .hot:
34 | return "https://www.v2ex.com/api/topics/hot.json"
35 | case .latest:
36 | return "https://www.v2ex.com/api/topics/latest.json"
37 | case .node(let nodeName):
38 | return "https://www.v2ex.com/api/topics/show.json?node_name=\(nodeName)"
39 | case .user(let userName):
40 | return "https://www.v2ex.com/api/topics/show.json?username=\(userName)"
41 | }
42 | }
43 | }
44 |
45 | class TopicDataService: ObservableObject {
46 |
47 | @Published var hotTopics: [TopicModel] = []
48 | @Published var latestTopics: [TopicModel] = []
49 | @Published var nodeTopics: [TopicModel] = []
50 | @Published var userTopics: [TopicModel] = []
51 |
52 | private var topicSubscribtion: AnyCancellable?
53 |
54 | init() { }
55 |
56 | func getTopics(category: TopicCategory) {
57 | guard let url = URL(string: category.urlString) else {
58 | return
59 | }
60 |
61 | topicSubscribtion = NetworkingManager.download(url: url)
62 | // .map({ data -> [TopicModel] in
63 | // do {
64 | // let returnedValue = try JSONDecoder().decode([TopicModel].self, from: data)
65 | // return returnedValue
66 | // } catch {
67 | // print(error)
68 | // return []
69 | // }
70 | // })
71 | .decode(type: [TopicModel].self, decoder: JSONDecoder())
72 | .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedTopics in
73 | switch category {
74 | case .hot:
75 | self?.hotTopics = returnedTopics
76 | case .latest:
77 | self?.latestTopics = returnedTopics
78 | case .node:
79 | self?.nodeTopics = returnedTopics
80 | case .user:
81 | self?.userTopics = returnedTopics
82 | }
83 | self?.topicSubscribtion?.cancel()
84 | })
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/V2EX/Services/UserImageDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserImageDataService.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/21.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | class UserImageDataService: ObservableObject {
13 |
14 | @Published var userImage: UIImage? = nil
15 |
16 | private var imageSubscribtion: AnyCancellable?
17 | private let member: MemberModel
18 | private let fileManager = LocalFileManager.instance
19 | private let imageName: String
20 | private let folderName: String = "user_iamges"
21 |
22 | init(member: MemberModel) {
23 | self.member = member
24 | self.imageName = "\(member.id)"
25 | getUserImage()
26 | }
27 |
28 | private func getUserImage() {
29 | if let savedImage = fileManager.getImage(imageName: imageName, folderName: folderName) {
30 | userImage = savedImage
31 | } else {
32 | downloadUserImage()
33 | }
34 | }
35 |
36 | private func downloadUserImage() {
37 | guard let url = URL(string: member.avatarNormal) else {
38 | return
39 | }
40 |
41 | imageSubscribtion = NetworkingManager.download(url: url)
42 | .tryMap({ data in
43 | return UIImage(data: data)
44 | })
45 | .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedImage in
46 | self?.userImage = returnedImage
47 | self?.imageSubscribtion?.cancel()
48 | })
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/V2EX/TabContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum Tab: Hashable {
11 | case home
12 | case node
13 | case notification
14 | case me
15 | }
16 |
17 | struct TabContentView: View {
18 | @State private var selectedTab: Tab = .home
19 |
20 | var body: some View {
21 | TabView(selection: $selectedTab) {
22 | NavigationStack {
23 | HomeView()
24 | }
25 | .tabItem {
26 | Image(systemName: "flame")
27 | }
28 | .tag(Tab.home)
29 |
30 | NavigationStack {
31 | NodeView()
32 | }
33 | .tabItem {
34 | Image(systemName: "note.text")
35 | }
36 | .tag(Tab.node)
37 |
38 | NavigationStack {
39 | NotificationView()
40 | }
41 | .tabItem {
42 | Image(systemName: "bell.fill")
43 | }
44 | .tag(Tab.notification)
45 |
46 | NavigationStack {
47 | MyView()
48 | }
49 | .tabItem {
50 | Image(systemName: "person")
51 | }
52 | .tag(Tab.me)
53 | }
54 | .font(.headline)
55 | .tint(Color.theme.secondary) // 设置选中颜色
56 | }
57 | }
58 |
59 | #Preview {
60 | TabContentView()
61 | }
62 |
--------------------------------------------------------------------------------
/V2EX/Utilties/LocalFileManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalFileManager.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/22.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 | import Combine
11 |
12 | class LocalFileManager {
13 |
14 | static let instance = LocalFileManager()
15 | private init() { }
16 |
17 | func saveImage(image: UIImage, imageName: String, folderName: String) {
18 |
19 | // create folder
20 | createFolderIfNeeded(folderName: folderName)
21 |
22 | // get path for image
23 | guard let data = image.pngData(),
24 | let url = getURLForImage(imageName: imageName, folderName: folderName) else {
25 | return
26 | }
27 |
28 | // save image to path
29 | do {
30 | try data.write(to: url)
31 | } catch let error {
32 | print("Error saving image. ImageName: \(imageName). \(error)")
33 | }
34 | }
35 |
36 | func getImage(imageName: String, folderName: String) -> UIImage? {
37 | guard let url = getURLForImage(imageName: imageName, folderName: folderName),
38 | FileManager.default.fileExists(atPath: url.path(percentEncoded: true)) else {
39 | return nil
40 | }
41 |
42 | return UIImage(contentsOfFile: url.path(percentEncoded: true))
43 | }
44 |
45 | func clearCache(folderName: String? = nil) -> Future {
46 | return Future { promise in
47 | if let folderName = folderName {
48 | // clear specific folder
49 | guard let folderURL = self.getURLForFolder(folderName: folderName) else {
50 | promise(.success(false))
51 | return
52 | }
53 | do {
54 | try FileManager.default.removeItem(at: folderURL)
55 | promise(.success(true))
56 | print("Successfully cleared folder: \(folderName)")
57 | } catch {
58 | promise(.success(false))
59 | print("Error clearing folder. FolderName: \(folderName). \(error)")
60 | }
61 | } else {
62 | // clear all cache directory
63 | guard let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
64 | promise(.success(false))
65 | return
66 | }
67 | do {
68 | let contents = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil)
69 | for fileURL in contents {
70 | try FileManager.default.removeItem(at: fileURL)
71 | }
72 | promise(.success(true))
73 | print("Successfully cleared all cache")
74 | } catch {
75 | promise(.success(false))
76 | print("Error clearing cache. \(error)")
77 | }
78 | }
79 | }
80 | }
81 |
82 | private func createFolderIfNeeded(folderName: String) {
83 | guard let url = getURLForFolder(folderName: folderName) else { return }
84 |
85 | if !FileManager.default.fileExists(atPath: url.path(percentEncoded: true)) {
86 | do {
87 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
88 | } catch let error {
89 | print("Error creating directory. FolderName: \(folderName). \(error)")
90 | }
91 | }
92 | }
93 |
94 | private func getURLForFolder(folderName: String) -> URL? {
95 | guard let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
96 | return nil
97 | }
98 | return url.appending(path: folderName)
99 | }
100 |
101 | private func getURLForImage(imageName: String, folderName: String) -> URL? {
102 | guard let url = getURLForFolder(folderName: folderName) else {
103 | return nil
104 | }
105 | return url.appending(path: imageName + ".png")
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/V2EX/Utilties/NetworkingManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkingManager.swift
3 | // CryptoApp
4 | //
5 | // Created by Aaron on 2024/11/20.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | class NetworkingManager {
12 |
13 | enum NetworkingError: LocalizedError {
14 | case badURLResponse(url: URL)
15 | case unknown
16 |
17 | var errorDescription: String? {
18 | switch self {
19 | case .badURLResponse(let url):
20 | return "[🔥] Bad response from URL: \(url)"
21 | case .unknown:
22 | return "[⚠️] Unknown error occured"
23 | }
24 | }
25 | }
26 |
27 | static func download(url: URL, token: String? = nil) -> AnyPublisher {
28 |
29 | var request = URLRequest(url: url)
30 | if let token = token {
31 | request.httpMethod = "GET"
32 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
33 | }
34 |
35 | return URLSession.shared.dataTaskPublisher(for: request)
36 | .subscribe(on: DispatchQueue.global(qos: .default))
37 | .tryMap({ try self.handleURLResponse(output: $0, url: url) })
38 | .receive(on: DispatchQueue.main)
39 | .eraseToAnyPublisher()
40 | }
41 |
42 | static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL) throws -> Data {
43 | guard let response = output.response as? HTTPURLResponse,
44 | response.statusCode >= 200 && response.statusCode < 300 else {
45 | throw NetworkingError.badURLResponse(url: url)
46 | }
47 | return output.data
48 | }
49 |
50 | static func handleCompletion(completion: Subscribers.Completion) {
51 | switch completion {
52 | case .finished:
53 | break
54 | case .failure(let error):
55 | print(error.localizedDescription)
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/V2EX/Utilties/V2EXLocalizable.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "zh-Hans",
3 | "strings" : {
4 | "%ld 分钟前" : {
5 | "extractionState" : "manual",
6 | "localizations" : {
7 | "en" : {
8 | "stringUnit" : {
9 | "state" : "translated",
10 | "value" : "%ld minutes ago"
11 | }
12 | }
13 | }
14 | },
15 | "%ld 周前" : {
16 | "extractionState" : "manual",
17 | "localizations" : {
18 | "en" : {
19 | "stringUnit" : {
20 | "state" : "translated",
21 | "value" : "%ld weeks ago"
22 | }
23 | }
24 | }
25 | },
26 | "%ld 天前" : {
27 | "extractionState" : "manual",
28 | "localizations" : {
29 | "en" : {
30 | "stringUnit" : {
31 | "state" : "translated",
32 | "value" : "%ld days ago"
33 | }
34 | }
35 | }
36 | },
37 | "%ld 小时前" : {
38 | "extractionState" : "manual",
39 | "localizations" : {
40 | "en" : {
41 | "stringUnit" : {
42 | "state" : "translated",
43 | "value" : "%ld hours ago"
44 | }
45 | }
46 | }
47 | },
48 | "%ld 月前" : {
49 | "extractionState" : "manual",
50 | "localizations" : {
51 | "en" : {
52 | "stringUnit" : {
53 | "state" : "translated",
54 | "value" : "%ld months ago"
55 | }
56 | }
57 | }
58 | },
59 | "%ld 秒前" : {
60 | "extractionState" : "manual",
61 | "localizations" : {
62 | "en" : {
63 | "stringUnit" : {
64 | "state" : "translated",
65 | "value" : "%ld seconds ago"
66 | }
67 | }
68 | }
69 | },
70 | "V2EX创意工作者们的社区" : {
71 | "extractionState" : "manual",
72 | "localizations" : {
73 | "en" : {
74 | "stringUnit" : {
75 | "state" : "translated",
76 | "value" : "A community for V2EX creative workers"
77 | }
78 | }
79 | }
80 | },
81 | "V2EX统计" : {
82 | "extractionState" : "manual",
83 | "localizations" : {
84 | "en" : {
85 | "stringUnit" : {
86 | "state" : "translated",
87 | "value" : "Statistics"
88 | }
89 | }
90 | }
91 | },
92 | "主题数量" : {
93 | "extractionState" : "manual",
94 | "localizations" : {
95 | "en" : {
96 | "stringUnit" : {
97 | "state" : "translated",
98 | "value" : "Topics"
99 | }
100 | }
101 | }
102 | },
103 | "主题设置" : {
104 | "extractionState" : "manual",
105 | "localizations" : {
106 | "en" : {
107 | "stringUnit" : {
108 | "state" : "translated",
109 | "value" : "Topic Setting"
110 | }
111 | }
112 | }
113 | },
114 | "从%@开始成为V2EX用户" : {
115 | "extractionState" : "manual",
116 | "localizations" : {
117 | "en" : {
118 | "stringUnit" : {
119 | "state" : "translated",
120 | "value" : "Become a V2EX user starting from %@"
121 | }
122 | }
123 | }
124 | },
125 | "使用 Token 授权登录" : {
126 | "extractionState" : "manual",
127 | "localizations" : {
128 | "en" : {
129 | "stringUnit" : {
130 | "state" : "translated",
131 | "value" : "Use Token authorization"
132 | }
133 | }
134 | }
135 | },
136 | "关于" : {
137 | "extractionState" : "manual",
138 | "localizations" : {
139 | "en" : {
140 | "stringUnit" : {
141 | "state" : "translated",
142 | "value" : "About"
143 | }
144 | }
145 | }
146 | },
147 | "关注" : {
148 | "extractionState" : "manual",
149 | "localizations" : {
150 | "en" : {
151 | "stringUnit" : {
152 | "state" : "translated",
153 | "value" : "Follow"
154 | }
155 | }
156 | }
157 | },
158 | "关注的人" : {
159 | "extractionState" : "manual",
160 | "localizations" : {
161 | "en" : {
162 | "stringUnit" : {
163 | "state" : "translated",
164 | "value" : "Following"
165 | }
166 | }
167 | }
168 | },
169 | "最新" : {
170 | "extractionState" : "manual",
171 | "localizations" : {
172 | "en" : {
173 | "stringUnit" : {
174 | "state" : "translated",
175 | "value" : "Latest"
176 | }
177 | }
178 | }
179 | },
180 | "最新回复" : {
181 | "extractionState" : "manual",
182 | "localizations" : {
183 | "en" : {
184 | "stringUnit" : {
185 | "state" : "translated",
186 | "value" : "Latest reply"
187 | }
188 | }
189 | }
190 | },
191 | "分享给好友" : {
192 | "extractionState" : "manual",
193 | "localizations" : {
194 | "en" : {
195 | "stringUnit" : {
196 | "state" : "translated",
197 | "value" : "Share"
198 | }
199 | }
200 | }
201 | },
202 | "刷新" : {
203 | "extractionState" : "manual",
204 | "localizations" : {
205 | "en" : {
206 | "stringUnit" : {
207 | "state" : "translated",
208 | "value" : "refresh"
209 | }
210 | }
211 | }
212 | },
213 | "刷新下" : {
214 | "extractionState" : "manual",
215 | "localizations" : {
216 | "en" : {
217 | "stringUnit" : {
218 | "state" : "translated",
219 | "value" : "Refresh"
220 | }
221 | }
222 | }
223 | },
224 | "加入于 %@" : {
225 | "extractionState" : "manual",
226 | "localizations" : {
227 | "en" : {
228 | "stringUnit" : {
229 | "state" : "translated",
230 | "value" : "Join in %@"
231 | }
232 | }
233 | }
234 | },
235 | "去登录" : {
236 | "extractionState" : "manual",
237 | "localizations" : {
238 | "en" : {
239 | "stringUnit" : {
240 | "state" : "translated",
241 | "value" : "Login"
242 | }
243 | }
244 | }
245 | },
246 | "反馈" : {
247 | "extractionState" : "manual",
248 | "localizations" : {
249 | "en" : {
250 | "stringUnit" : {
251 | "state" : "translated",
252 | "value" : "feedback"
253 | }
254 | }
255 | }
256 | },
257 | "发布于" : {
258 | "extractionState" : "manual",
259 | "localizations" : {
260 | "en" : {
261 | "stringUnit" : {
262 | "state" : "translated",
263 | "value" : "Posted in"
264 | }
265 | }
266 | }
267 | },
268 | "取消" : {
269 | "extractionState" : "manual",
270 | "localizations" : {
271 | "en" : {
272 | "stringUnit" : {
273 | "state" : "translated",
274 | "value" : "Cancel"
275 | }
276 | }
277 | }
278 | },
279 | "和" : {
280 | "extractionState" : "manual",
281 | "localizations" : {
282 | "en" : {
283 | "stringUnit" : {
284 | "state" : "translated",
285 | "value" : "and"
286 | }
287 | }
288 | }
289 | },
290 | "回复于 %@" : {
291 | "extractionState" : "manual",
292 | "localizations" : {
293 | "en" : {
294 | "stringUnit" : {
295 | "state" : "translated",
296 | "value" : "Reply on %@"
297 | }
298 | }
299 | }
300 | },
301 | "开源协议" : {
302 | "extractionState" : "manual",
303 | "localizations" : {
304 | "en" : {
305 | "stringUnit" : {
306 | "state" : "translated",
307 | "value" : "Open Source Protocol"
308 | }
309 | }
310 | }
311 | },
312 | "我的主题" : {
313 | "extractionState" : "manual",
314 | "localizations" : {
315 | "en" : {
316 | "stringUnit" : {
317 | "state" : "translated",
318 | "value" : "Topic"
319 | }
320 | }
321 | }
322 | },
323 | "所有" : {
324 | "extractionState" : "manual",
325 | "localizations" : {
326 | "en" : {
327 | "stringUnit" : {
328 | "state" : "translated",
329 | "value" : "All"
330 | }
331 | }
332 | }
333 | },
334 | "推特" : {
335 | "extractionState" : "manual",
336 | "localizations" : {
337 | "en" : {
338 | "stringUnit" : {
339 | "state" : "translated",
340 | "value" : "Twitter"
341 | }
342 | }
343 | }
344 | },
345 | "搜索节点" : {
346 | "extractionState" : "manual",
347 | "localizations" : {
348 | "en" : {
349 | "stringUnit" : {
350 | "state" : "translated",
351 | "value" : "Search nodes"
352 | }
353 | }
354 | }
355 | },
356 | "收藏主题" : {
357 | "extractionState" : "manual",
358 | "localizations" : {
359 | "en" : {
360 | "stringUnit" : {
361 | "state" : "translated",
362 | "value" : "Favorite"
363 | }
364 | }
365 | }
366 | },
367 | "日间" : {
368 | "extractionState" : "manual",
369 | "localizations" : {
370 | "en" : {
371 | "stringUnit" : {
372 | "state" : "translated",
373 | "value" : "light"
374 | }
375 | }
376 | }
377 | },
378 | "暂时还没有通知" : {
379 | "extractionState" : "manual",
380 | "localizations" : {
381 | "en" : {
382 | "stringUnit" : {
383 | "state" : "translated",
384 | "value" : "No Notifications"
385 | }
386 | }
387 | }
388 | },
389 | "暂未实现该功能" : {
390 | "extractionState" : "manual",
391 | "localizations" : {
392 | "en" : {
393 | "stringUnit" : {
394 | "state" : "translated",
395 | "value" : "not implemented"
396 | }
397 | }
398 | }
399 | },
400 | "服务条款" : {
401 | "extractionState" : "manual",
402 | "localizations" : {
403 | "en" : {
404 | "stringUnit" : {
405 | "state" : "translated",
406 | "value" : "Terms of Service"
407 | }
408 | }
409 | }
410 | },
411 | "此功能需要登录,要不登录下?" : {
412 | "extractionState" : "manual",
413 | "localizations" : {
414 | "en" : {
415 | "stringUnit" : {
416 | "state" : "translated",
417 | "value" : "This function requires login. Do you want to log in?"
418 | }
419 | }
420 | }
421 | },
422 | "没有主题数据" : {
423 | "extractionState" : "manual",
424 | "localizations" : {
425 | "en" : {
426 | "stringUnit" : {
427 | "state" : "translated",
428 | "value" : "No Topics"
429 | }
430 | }
431 | }
432 | },
433 | "注册会员" : {
434 | "extractionState" : "manual",
435 | "localizations" : {
436 | "en" : {
437 | "stringUnit" : {
438 | "state" : "translated",
439 | "value" : "Registered Members"
440 | }
441 | }
442 | }
443 | },
444 | "浏览记录" : {
445 | "extractionState" : "manual",
446 | "localizations" : {
447 | "en" : {
448 | "stringUnit" : {
449 | "state" : "translated",
450 | "value" : "History"
451 | }
452 | }
453 | }
454 | },
455 | "深色" : {
456 | "extractionState" : "manual",
457 | "localizations" : {
458 | "en" : {
459 | "stringUnit" : {
460 | "state" : "translated",
461 | "value" : "Dark"
462 | }
463 | }
464 | }
465 | },
466 | "清理失败" : {
467 | "extractionState" : "manual",
468 | "localizations" : {
469 | "en" : {
470 | "stringUnit" : {
471 | "state" : "translated",
472 | "value" : "\nCleanup failed"
473 | }
474 | }
475 | }
476 | },
477 | "清理成功" : {
478 | "extractionState" : "manual",
479 | "localizations" : {
480 | "en" : {
481 | "stringUnit" : {
482 | "state" : "translated",
483 | "value" : "Cleanup successful"
484 | }
485 | }
486 | }
487 | },
488 | "清理缓存" : {
489 | "extractionState" : "manual",
490 | "localizations" : {
491 | "en" : {
492 | "stringUnit" : {
493 | "state" : "translated",
494 | "value" : "clear cache"
495 | }
496 | }
497 | }
498 | },
499 | "清除全部缓存" : {
500 | "extractionState" : "manual",
501 | "localizations" : {
502 | "en" : {
503 | "stringUnit" : {
504 | "state" : "translated",
505 | "value" : "clear all cache"
506 | }
507 | }
508 | }
509 | },
510 | "热门" : {
511 | "extractionState" : "manual",
512 | "localizations" : {
513 | "en" : {
514 | "stringUnit" : {
515 | "state" : "translated",
516 | "value" : "Hot"
517 | }
518 | }
519 | }
520 | },
521 | "现在还没有任何回复~" : {
522 | "extractionState" : "manual",
523 | "localizations" : {
524 | "en" : {
525 | "stringUnit" : {
526 | "state" : "translated",
527 | "value" : "There is no reply yet~"
528 | }
529 | }
530 | }
531 | },
532 | "登录体验更多V2EX功能" : {
533 | "extractionState" : "manual",
534 | "localizations" : {
535 | "en" : {
536 | "stringUnit" : {
537 | "state" : "translated",
538 | "value" : "Log in to experience more V2EX functions"
539 | }
540 | }
541 | }
542 | },
543 | "登录即表示你同意" : {
544 | "extractionState" : "manual",
545 | "localizations" : {
546 | "en" : {
547 | "stringUnit" : {
548 | "state" : "translated",
549 | "value" : "By logging in you agree"
550 | }
551 | }
552 | }
553 | },
554 | "登录失败,请检查您的授权码。" : {
555 | "extractionState" : "manual",
556 | "localizations" : {
557 | "en" : {
558 | "stringUnit" : {
559 | "state" : "translated",
560 | "value" : "Login failed, please check your authorization code."
561 | }
562 | }
563 | }
564 | },
565 | "确定" : {
566 | "extractionState" : "manual",
567 | "localizations" : {
568 | "en" : {
569 | "stringUnit" : {
570 | "state" : "translated",
571 | "value" : "OK"
572 | }
573 | }
574 | }
575 | },
576 | "确定退出吗?" : {
577 | "extractionState" : "manual",
578 | "localizations" : {
579 | "en" : {
580 | "stringUnit" : {
581 | "state" : "translated",
582 | "value" : "Are you sure logout?"
583 | }
584 | }
585 | }
586 | },
587 | "综合" : {
588 | "extractionState" : "manual",
589 | "localizations" : {
590 | "en" : {
591 | "stringUnit" : {
592 | "state" : "translated",
593 | "value" : "General"
594 | }
595 | }
596 | }
597 | },
598 | "缓存清理" : {
599 | "extractionState" : "manual",
600 | "localizations" : {
601 | "en" : {
602 | "stringUnit" : {
603 | "state" : "translated",
604 | "value" : "Clean Cache"
605 | }
606 | }
607 | }
608 | },
609 | "自动" : {
610 | "extractionState" : "manual",
611 | "localizations" : {
612 | "en" : {
613 | "stringUnit" : {
614 | "state" : "translated",
615 | "value" : "auto"
616 | }
617 | }
618 | }
619 | },
620 | "节点" : {
621 | "extractionState" : "manual",
622 | "localizations" : {
623 | "en" : {
624 | "stringUnit" : {
625 | "state" : "translated",
626 | "value" : "Node"
627 | }
628 | }
629 | }
630 | },
631 | "节点从 xxxx 年 xx 月 xx 日创建至今" : {
632 | "extractionState" : "manual",
633 | "localizations" : {
634 | "en" : {
635 | "stringUnit" : {
636 | "state" : "translated",
637 | "value" : "The node was created from xx, xx, xxxx"
638 | }
639 | }
640 | }
641 | },
642 | "节点最近活跃于 xxxx 年 xx 月 xx 日" : {
643 | "extractionState" : "manual",
644 | "localizations" : {
645 | "en" : {
646 | "stringUnit" : {
647 | "state" : "translated",
648 | "value" : "The node was last active on xx, xx, xxxx"
649 | }
650 | }
651 | }
652 | },
653 | "获取token" : {
654 | "extractionState" : "manual",
655 | "localizations" : {
656 | "en" : {
657 | "stringUnit" : {
658 | "state" : "translated",
659 | "value" : "get token"
660 | }
661 | }
662 | }
663 | },
664 | "设置" : {
665 | "extractionState" : "manual",
666 | "localizations" : {
667 | "en" : {
668 | "stringUnit" : {
669 | "state" : "translated",
670 | "value" : "Settings"
671 | }
672 | }
673 | }
674 | },
675 | "评分鼓励" : {
676 | "extractionState" : "manual",
677 | "localizations" : {
678 | "en" : {
679 | "stringUnit" : {
680 | "state" : "translated",
681 | "value" : "Encourage"
682 | }
683 | }
684 | }
685 | },
686 | "语言环境" : {
687 | "extractionState" : "manual",
688 | "localizations" : {
689 | "en" : {
690 | "stringUnit" : {
691 | "state" : "translated",
692 | "value" : "Language"
693 | }
694 | }
695 | }
696 | },
697 | "跳过,暂不授权" : {
698 | "extractionState" : "manual",
699 | "localizations" : {
700 | "en" : {
701 | "stringUnit" : {
702 | "state" : "translated",
703 | "value" : "Skip, not authorized yet"
704 | }
705 | }
706 | }
707 | },
708 | "输入授权码.." : {
709 | "extractionState" : "manual",
710 | "localizations" : {
711 | "en" : {
712 | "stringUnit" : {
713 | "state" : "translated",
714 | "value" : "Enter the token.."
715 | }
716 | }
717 | }
718 | },
719 | "选择主题" : {
720 | "extractionState" : "manual",
721 | "localizations" : {
722 | "en" : {
723 | "stringUnit" : {
724 | "state" : "translated",
725 | "value" : "Select Topic"
726 | }
727 | }
728 | }
729 | },
730 | "选择语言" : {
731 | "extractionState" : "manual",
732 | "localizations" : {
733 | "en" : {
734 | "stringUnit" : {
735 | "state" : "translated",
736 | "value" : "Language"
737 | }
738 | }
739 | }
740 | },
741 | "通知" : {
742 | "extractionState" : "manual",
743 | "localizations" : {
744 | "en" : {
745 | "stringUnit" : {
746 | "state" : "translated",
747 | "value" : "Notification"
748 | }
749 | }
750 | }
751 | },
752 | "邮件" : {
753 | "extractionState" : "manual",
754 | "localizations" : {
755 | "en" : {
756 | "stringUnit" : {
757 | "state" : "translated",
758 | "value" : "Email"
759 | }
760 | }
761 | }
762 | },
763 | "隐私政策" : {
764 | "extractionState" : "manual",
765 | "localizations" : {
766 | "en" : {
767 | "stringUnit" : {
768 | "state" : "translated",
769 | "value" : "privacy policy"
770 | }
771 | }
772 | }
773 | }
774 | },
775 | "version" : "1.0"
776 | }
--------------------------------------------------------------------------------
/V2EX/V2EX.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/V2EX/V2EXApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // V2EXApp.swift
3 | // V2EX
4 | //
5 | // Created by kim on 2024/11/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct V2EXApp: App {
12 |
13 | @StateObject private var appState = AppState() // 全局状态
14 |
15 | var body: some Scene {
16 | WindowGroup {
17 | TabContentView()
18 | .onAppear {
19 | switch appState.theme {
20 | case .auto:
21 | UIApplication.window?.overrideUserInterfaceStyle = .unspecified
22 | case .light:
23 | UIApplication.window?.overrideUserInterfaceStyle = .light
24 | case .dark:
25 | UIApplication.window?.overrideUserInterfaceStyle = .dark
26 | }
27 | }
28 | .environmentObject(appState) // 注入到环境中
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/pics/dark_screenshot.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/pics/dark_screenshot.jpeg
--------------------------------------------------------------------------------
/pics/light_screenshot.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaron0927/V2EX/363c5db8865e85d88259fe92e2d4f43d0653a82c/pics/light_screenshot.jpeg
--------------------------------------------------------------------------------