├── .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 | ![](./pics/light_screenshot.jpeg) 22 | 23 | ![](./pics/dark_screenshot.jpeg) 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 | ![](./pics/light_screenshot.jpeg) 16 | 17 | ![](./pics/dark_screenshot.jpeg) 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 --------------------------------------------------------------------------------