├── .all-contributorsrc ├── .gitignore ├── LICENSE ├── README.md ├── V2er.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── V2er ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-20x20.png │ │ ├── Icon-App-20x20@2x-1.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29.png │ │ ├── Icon-App-29x29@2x-1.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40.png │ │ ├── Icon-App-40x40@2x-1.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76.png │ │ ├── Icon-App-76x76@2x.png │ │ ├── Icon-App-83.5x83.5@2x.png │ │ └── Icon-App-iTunes.png │ ├── Colors │ │ ├── Contents.json │ │ ├── LightWhite.colorset │ │ │ └── Contents.json │ │ ├── bodyText.colorset │ │ │ └── Contents.json │ │ ├── clear.colorset │ │ │ └── Contents.json │ │ ├── grey.colorset │ │ │ └── Contents.json │ │ ├── indictor.colorset │ │ │ └── Contents.json │ │ ├── primary.colorset │ │ │ └── Contents.json │ │ ├── selected.colorset │ │ │ └── Contents.json │ │ ├── tabBg.colorset │ │ │ └── Contents.json │ │ └── unselected.colorset │ │ │ └── Contents.json │ ├── Contents.json │ ├── avatar.imageset │ │ ├── Contents.json │ │ └── avar.png │ ├── captcha.imageset │ │ ├── Contents.json │ │ └── captcha.png │ ├── demo.imageset │ │ ├── Contents.json │ │ └── demo.png │ ├── explore_tab.imageset │ │ ├── Contents.json │ │ ├── explore_tab.png │ │ ├── explore_tab@2x.png │ │ └── explore_tab@3x.png │ ├── feed_tab.imageset │ │ ├── Contents.json │ │ ├── feed_tab.png │ │ ├── feed_tab@2x.png │ │ └── feed_tab@3x.png │ ├── logo.imageset │ │ ├── Contents.json │ │ └── v2er.png │ ├── me_tab.imageset │ │ ├── Contents.json │ │ ├── me_tab.png │ │ ├── me_tab@2x.png │ │ └── me_tab@3x.png │ ├── message_tab.imageset │ │ ├── Contents.json │ │ ├── message_tab.png │ │ ├── message_tab@2x.png │ │ └── message_tab@3x.png │ └── share_node_v2ex.imageset │ │ ├── Contents.json │ │ └── share_node_v2ex.png ├── General │ ├── AccountState.swift │ ├── Color.swift │ ├── Device.swift │ ├── Extentions.swift │ ├── Persist.swift │ ├── RootView.swift │ ├── Utils.swift │ └── V2erApp.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── State │ ├── DataFlow │ │ ├── Actions │ │ │ ├── Action.swift │ │ │ ├── ExploreActions.swift │ │ │ ├── FeedDetailActions.swift │ │ │ ├── GlobalActions.swift │ │ │ ├── MeActions.swift │ │ │ └── UserDetailActions.swift │ │ ├── BaseState.swift │ │ ├── Model │ │ │ ├── AccountInfo.swift │ │ │ ├── BaseModel.swift │ │ │ ├── DailyInfo.swift │ │ │ ├── ExploreInfo.swift │ │ │ ├── FeedDetailInfo.swift │ │ │ ├── FeedInfo.swift │ │ │ ├── ModelUtils.swift │ │ │ ├── Nodes.swift │ │ │ ├── TabInfo.swift │ │ │ ├── TagDetailInfo.swift │ │ │ ├── ThxAuthorInfo.swift │ │ │ ├── TwoStepLoginInfo.swift │ │ │ ├── TwoStepLoginResultInfo.swift │ │ │ └── UserDetailInfo.swift │ │ ├── Reducers │ │ │ ├── CreateReducer.swift │ │ │ ├── DefaultReducer.swift │ │ │ ├── ExploreReducer.swift │ │ │ ├── FeedReducer.swift │ │ │ ├── LoginReducer.swift │ │ │ ├── MeReducer.swift │ │ │ ├── MessageReducer.swift │ │ │ ├── MyFavoriteReducer.swift │ │ │ ├── MyFollowReducer.swift │ │ │ ├── MyRecentReducer.swift │ │ │ ├── SearchStateReducer.swift │ │ │ ├── SettingReducer.swift │ │ │ ├── TagDetailReducer.swift │ │ │ ├── UserDetailReducer.swift │ │ │ └── UserFeedReducer.swift │ │ ├── State │ │ │ ├── AppState.swift │ │ │ ├── CreateTopicState.swift │ │ │ ├── ExploreState.swift │ │ │ ├── FeedDetailState.swift │ │ │ ├── FeedState.swift │ │ │ ├── FluxState.swift │ │ │ ├── GlobalState.swift │ │ │ ├── LoginState.swift │ │ │ ├── MeState.swift │ │ │ ├── MessageState.swift │ │ │ ├── MyFavoriteState.swift │ │ │ ├── MyFollowState.swift │ │ │ ├── MyRecentState.swift │ │ │ ├── SearchState.swift │ │ │ ├── SettingState.swift │ │ │ ├── TagDetailState.swift │ │ │ ├── UpdatableState.swift │ │ │ ├── UserDetailState.swift │ │ │ └── UserFeedState.swift │ │ └── Store.swift │ └── Networking │ │ ├── APIService.swift │ │ ├── Endpoint.swift │ │ ├── Headers.swift │ │ ├── NetworkException.swift │ │ ├── SwiftSoupExtention.swift │ │ └── UA.swift ├── View │ ├── Explore │ │ ├── ExplorePage.swift │ │ ├── SearchPage.swift │ │ └── SwiftUIView.swift │ ├── Feed │ │ ├── FeedItemView.swift │ │ └── FeedPage.swift │ ├── FeedDetail │ │ ├── AuthorInfoView.swift │ │ ├── FeedDetailPage.swift │ │ ├── FeedDetailReducer.swift │ │ ├── HtmlView.swift │ │ ├── NewsContentView.swift │ │ └── ReplyItemView.swift │ ├── Login │ │ ├── LoginPage.swift │ │ └── TwoStepLoginPage.swift │ ├── MainPage.swift │ ├── Me │ │ ├── CreateTopicPage.swift │ │ ├── MePage.swift │ │ ├── MyFavoritePage.swift │ │ ├── MyFollowPage.swift │ │ ├── MyRecentPage.swift │ │ ├── NodeChooserPage.swift │ │ ├── UserDetailPage.swift │ │ └── UserFeedPage.swift │ ├── Message │ │ └── MessagePage.swift │ ├── Settings │ │ ├── AboutView.swift │ │ ├── AppearanceSettingView.swift │ │ ├── BrowseSettingView.swift │ │ ├── FeedbackHelperView.swift │ │ ├── OtherSettingsView.swift │ │ └── SettingsPage.swift │ ├── StateView.swift │ ├── Syles.swift │ ├── Tag │ │ └── TagDetailPage.swift │ ├── ViewExtension.swift │ ├── WebBrowserView.swift │ └── Widget │ │ ├── AvatarView.swift │ │ ├── FlowStack.swift │ │ ├── MultilineTextField.swift │ │ ├── NavbarHostView.swift │ │ ├── NodeView.swift │ │ ├── RichText.swift │ │ ├── RichText │ │ ├── RichText.swift │ │ └── TestView.swift │ │ ├── RichTextView │ │ ├── Enums.swift │ │ ├── Extension.swift │ │ ├── RichText.swift │ │ └── Webview.swift │ │ ├── SectionItemView.swift │ │ ├── SectionTitleView.swift │ │ ├── TabBar.swift │ │ ├── Toast.swift │ │ ├── TopBar.swift │ │ ├── Updatable │ │ ├── ActivityIndicator.swift │ │ ├── HeadIndicatorView.swift │ │ ├── Helper.swift │ │ ├── LoadmoreIndicatorView.swift │ │ └── UpdatableView.swift │ │ └── VEBlur.swift └── www │ ├── bootstrap.min.css │ ├── email.js │ ├── font.css │ ├── highlight.js │ ├── image_holder_failed.png │ ├── image_holder_loading.gif │ ├── jquery.min.js │ ├── tomorrow.css │ ├── user_manual.html │ ├── v2er.css │ ├── v2er.html │ └── v2er.js ├── V2erTests ├── Info.plist └── V2erTests.swift ├── V2erUITests ├── Info.plist └── V2erUITests.swift └── pravicy.md /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "graycreate", 11 | "name": "GRAY", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/5203798?v=4", 13 | "profile": "https://github.com/graycreate", 14 | "contributions": [ 15 | "code" 16 | ] 17 | }, 18 | { 19 | "login": "shatyuka", 20 | "name": "Shatyuka", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/31368738?v=4", 22 | "profile": "https://github.com/shatyuka", 23 | "contributions": [ 24 | "code" 25 | ] 26 | } 27 | ], 28 | "contributorsPerLine": 7, 29 | "skipCi": true, 30 | "repoType": "github", 31 | "repoHost": "https://github.com", 32 | "projectName": "iOS", 33 | "projectOwner": "v2er-app" 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .DS_Store 3 | xcuserdata/ 4 | ## App packaging 5 | *.ipa 6 | *.dSYM.zip 7 | *.dSYM 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2er-iOS 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) 4 | 5 | A beautiful V2EX client built for iOS platform. 6 | 7 | This project is under develop, is not avaiable in App store yet, you could download it from [App Store](https://apps.apple.com/app/id1596137027) 8 | 9 | ![](https://i.loli.net/2021/11/24/l8VA4IQosYdNUD2.png) 10 | 11 | ## Contributors 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
GRAY
GRAY

💻
Shatyuka
Shatyuka

💻
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | # Licensing 38 | The source code is licensed under GPL. License is available [here](./LICENSE). 39 | -------------------------------------------------------------------------------- /V2er.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /V2er.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /V2er.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "kingfisher", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/onevcat/Kingfisher.git", 7 | "state" : { 8 | "branch" : "master", 9 | "revision" : "9375e4a0e5db3698c1f92bd5d1dc88b0b71caa8b" 10 | } 11 | }, 12 | { 13 | "identity" : "swiftrichtext", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/sethcreate/SwiftRichText", 16 | "state" : { 17 | "revision" : "86d3f06a48d1641e348f162afc99832b42cb9ce9" 18 | } 19 | }, 20 | { 21 | "identity" : "swiftsoup", 22 | "kind" : "remoteSourceControl", 23 | "location" : "https://github.com/scinfu/SwiftSoup.git", 24 | "state" : { 25 | "revision" : "774dc9c7213085db8aa59595e27c1cd22e428904", 26 | "version" : "2.3.2" 27 | } 28 | }, 29 | { 30 | "identity" : "swiftui-webview", 31 | "kind" : "remoteSourceControl", 32 | "location" : "https://github.com/kylehickinson/SwiftUI-WebView", 33 | "state" : { 34 | "revision" : "a67dcaff2377573a51f75761bb6e768b78931909", 35 | "version" : "0.3.0" 36 | } 37 | } 38 | ], 39 | "version" : 2 40 | } 41 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-App-20x20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon-App-20x20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-App-29x29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-App-29x29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-App-40x40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-App-40x40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-App-60x60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-App-60x60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-App-20x20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-App-20x20@2x-1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-App-29x29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-App-29x29@2x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-App-40x40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-App-40x40@2x-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-App-76x76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-App-76x76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-App-83.5x83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "Icon-App-iTunes.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-iTunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/AppIcon.appiconset/Icon-App-iTunes.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/LightWhite.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.924", 9 | "green" : "0.924", 10 | "red" : "0.924" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/bodyText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x55", 9 | "green" : "0x55", 10 | "red" : "0x55" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/clear.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.000", 8 | "blue" : "0.333", 9 | "green" : "0.333", 10 | "red" : "0.333" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/grey.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x98", 9 | "green" : "0x98", 10 | "red" : "0x98" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/indictor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x54", 9 | "green" : "0x54", 10 | "red" : "0x54" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/primary.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 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/selected.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.900", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/tabBg.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.098", 8 | "blue" : "0.333", 9 | "green" : "0.333", 10 | "red" : "0.333" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Colors/unselected.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.315", 9 | "green" : "0.312", 10 | "red" : "0.313" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/avatar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "avar.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/avatar.imageset/avar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/avatar.imageset/avar.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/captcha.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "captcha.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 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/captcha.imageset/captcha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/captcha.imageset/captcha.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/demo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "demo.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 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/demo.imageset/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/demo.imageset/demo.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/explore_tab.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "explore_tab.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "explore_tab@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "explore_tab@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/explore_tab.imageset/explore_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/explore_tab.imageset/explore_tab.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/explore_tab.imageset/explore_tab@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/explore_tab.imageset/explore_tab@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/explore_tab.imageset/explore_tab@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/explore_tab.imageset/explore_tab@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/feed_tab.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "feed_tab.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "feed_tab@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "feed_tab@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/feed_tab.imageset/feed_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/feed_tab.imageset/feed_tab.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/feed_tab.imageset/feed_tab@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/feed_tab.imageset/feed_tab@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/feed_tab.imageset/feed_tab@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/feed_tab.imageset/feed_tab@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "v2er.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 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/logo.imageset/v2er.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/logo.imageset/v2er.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/me_tab.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "me_tab.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "me_tab@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "me_tab@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/me_tab.imageset/me_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/me_tab.imageset/me_tab.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/me_tab.imageset/me_tab@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/me_tab.imageset/me_tab@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/me_tab.imageset/me_tab@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/me_tab.imageset/me_tab@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/message_tab.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "message_tab.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "message_tab@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "message_tab@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/message_tab.imageset/message_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/message_tab.imageset/message_tab.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/message_tab.imageset/message_tab@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/message_tab.imageset/message_tab@2x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/message_tab.imageset/message_tab@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/message_tab.imageset/message_tab@3x.png -------------------------------------------------------------------------------- /V2er/Assets.xcassets/share_node_v2ex.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "share_node_v2ex.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 | -------------------------------------------------------------------------------- /V2er/Assets.xcassets/share_node_v2ex.imageset/share_node_v2ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v2er-app/iOS/b06f2e5d5e85ba2098c301a4a82c60b3becd94bb/V2er/Assets.xcassets/share_node_v2ex.imageset/share_node_v2ex.png -------------------------------------------------------------------------------- /V2er/General/AccountState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountUtil.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct AccountState { 12 | static let ACCOUNT_KEY = "app.v2er.account" 13 | static var ACCOUNT: AccountInfo? 14 | 15 | static func saveAccount(_ account: AccountInfo) { 16 | do { 17 | let jsonData = try JSONEncoder().encode(account) 18 | Persist.save(value: jsonData, forkey: AccountState.ACCOUNT_KEY) 19 | log("account: \(account) saved") 20 | ACCOUNT = account 21 | } catch { 22 | log("Save account failed") 23 | } 24 | } 25 | 26 | static func deleteAccount() { 27 | Persist.save(value: String.empty, forkey: AccountState.ACCOUNT_KEY) 28 | ACCOUNT = nil 29 | APIService.shared.clearCookie() 30 | } 31 | 32 | static func getAccount() -> AccountInfo? { 33 | do { 34 | if ACCOUNT != nil { return ACCOUNT } 35 | let data = Persist.read(key: ACCOUNT_KEY) 36 | guard let data = data else { return nil } 37 | ACCOUNT = try JSONDecoder() 38 | .decode(AccountInfo.self, from: data) 39 | return ACCOUNT 40 | } catch { 41 | log("readAccount failed") 42 | } 43 | return nil 44 | } 45 | 46 | static func hasSignIn() -> Bool { 47 | return getAccount() != nil 48 | } 49 | 50 | static var userName: String { 51 | return getAccount()?.username ?? .default 52 | } 53 | 54 | static var avatarUrl: String { 55 | return getAccount()?.avatar ?? .default 56 | } 57 | 58 | static func isSelf(userName: String) -> Bool { 59 | return userName == Self.userName && userName != .default 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /V2er/General/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/6/20. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | extension Color { 13 | private init(_ hex: Int, a: CGFloat = 1.0) { 14 | self.init(UIColor(hex: hex, alpha: a)) 15 | } 16 | 17 | public static func hex(_ hex: Int, alpha: CGFloat = 1.0) -> Color { 18 | return Color(hex, a: alpha) 19 | } 20 | 21 | public static func shape(_ hex: Int, alpha: CGFloat = 1.0) -> some View { 22 | return Self.hex(hex, alpha: alpha).frame(width: .infinity) 23 | } 24 | 25 | public func shape() -> some View { 26 | self.frame(width: .infinity) 27 | } 28 | 29 | public static let border = hex(0xE8E8E8, alpha: 0.8) 30 | static let lightGray = hex(0xF5F5F5) 31 | static let almostClear = hex(0xFFFFFF, alpha: 0.000001) 32 | static let debugColor = hex(0xFF0000, alpha: 0.1) 33 | // static let bodyText = hex(0x555555) 34 | static let bodyText = hex(0x000000, alpha: 0.75) 35 | static let tintColor = hex(0x383838) 36 | static let bgColor = hex(0xE2E2E2, alpha: 0.8) 37 | static let itemBg: Color = .white 38 | static let dim = hex(0x000000, alpha: 0.6) 39 | // static let url = hex(0x60c2d4) 40 | static let url = hex(0x778087) 41 | 42 | public var uiColor: UIColor { 43 | return UIColor(self) 44 | } 45 | } 46 | 47 | extension UIColor { 48 | convenience init(hex: Int, alpha: CGFloat = 1.0) { 49 | let components = ( 50 | R: CGFloat((hex >> 16) & 0xff) / 255, 51 | G: CGFloat((hex >> 08) & 0xff) / 255, 52 | B: CGFloat((hex >> 00) & 0xff) / 255 53 | ) 54 | self.init(red: components.R, green: components.G, blue: components.B, alpha: alpha) 55 | } 56 | } 57 | 58 | 59 | 60 | struct Color_Previews: PreviewProvider { 61 | static var previews: some View { 62 | VStack { 63 | Color.hex(0xFBFBFB).frame(width: 100, height: 100) 64 | Color.hex(0x00FF00, alpha: 0.2).frame(width: 100, height: 100) 65 | Color.hex(0xFF00FF).frame(width: 100, height: 100) 66 | Color.tintColor.frame(width: 100, height: 100) 67 | Color.lightGray.frame(width: 100, height: 100) 68 | Color.border.frame(width: 100, height: 100).opacity(0.5) 69 | } 70 | 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /V2er/General/Persist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persist.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Persist { 12 | private static let userDefault = UserDefaults.standard 13 | 14 | static func save(value: Any, forkey key: String) { 15 | userDefault.set(value, forKey: key) 16 | } 17 | 18 | static func read(key: String, default: String = .empty) -> String { 19 | return userDefault.string(forKey: key) ?? `default` 20 | } 21 | 22 | static func read(key: String) -> Data? { 23 | return userDefault.data(forKey: key) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /V2er/General/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusBarController.swift 3 | // Insert this into your project. 4 | // Created by Xavier Donnellon 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct RootView : View { 10 | var content: Content 11 | 12 | init(@ViewBuilder content: ()-> Content) { 13 | self.content = content() 14 | } 15 | 16 | var body:some View { 17 | EmptyView() 18 | .withHostingWindow { window in 19 | V2erApp.window = window 20 | V2erApp.rootViewController = RootHostingController(rootView: content) 21 | window?.rootViewController = V2erApp.rootViewController 22 | } 23 | } 24 | } 25 | 26 | class RootHostingController: UIHostingController { 27 | override var preferredStatusBarStyle: UIStatusBarStyle { 28 | return V2erApp.statusBarState 29 | } 30 | } 31 | 32 | extension View { 33 | func statusBarStyle(_ style: UIStatusBarStyle, original: UIStatusBarStyle = .darkContent) -> some View { 34 | return self.onAppear { 35 | V2erApp.changeStatusBarStyle(style) 36 | } 37 | .onDisappear { 38 | V2erApp.changeStatusBarStyle(original) 39 | } 40 | .onChange(of: style) { newState in 41 | V2erApp.changeStatusBarStyle(newState) 42 | } 43 | } 44 | } 45 | 46 | 47 | struct RootHostView: View { 48 | @EnvironmentObject private var store: Store 49 | 50 | var toast: Binding { 51 | $store.appState.globalState.toast 52 | } 53 | 54 | var loginState: Binding { 55 | $store.appState.loginState 56 | } 57 | 58 | var body: some View { 59 | MainPage() 60 | .buttonStyle(.plain) 61 | .toast(isPresented: toast.isPresented) { 62 | DefaultToastView(title: toast.title.raw, icon: toast.icon.raw) 63 | } 64 | .sheet(isPresented: loginState.showLoginView) { 65 | LoginPage() 66 | } 67 | .overlay { 68 | if loginState.raw.showTwoStepDialog { 69 | TwoStepLoginPage() 70 | } 71 | } 72 | 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /V2er/General/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/4. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import UIKit 12 | import SwiftUI 13 | 14 | private let loggable: Bool = false 15 | 16 | public func log(_ items: Any..., separator: String = " ", terminator: String = "\n") { 17 | if !loggable { 18 | return 19 | } 20 | #if DEBUG 21 | print(items, separator, terminator) 22 | #endif 23 | } 24 | 25 | 26 | public func isSimulator() -> Bool { 27 | #if (arch(i386) || arch(x86_64)) && os(iOS) 28 | return true 29 | #endif 30 | return false 31 | } 32 | 33 | 34 | 35 | /// Publisher to read keyboard changes. 36 | protocol KeyboardReadable { 37 | var keyboardPublisher: AnyPublisher { get } 38 | } 39 | 40 | extension KeyboardReadable { 41 | var keyboardPublisher: AnyPublisher { 42 | Publishers.Merge( 43 | NotificationCenter.default 44 | .publisher(for: UIResponder.keyboardWillShowNotification) 45 | .map { _ in true }, 46 | 47 | NotificationCenter.default 48 | .publisher(for: UIResponder.keyboardWillHideNotification) 49 | .map { _ in false } 50 | ) 51 | .eraseToAnyPublisher() 52 | } 53 | } 54 | 55 | 56 | func runInMain(delay: Int = 0, execute work: @escaping @convention(block) () -> Void) { 57 | DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(delay), execute: work) 58 | } 59 | 60 | func hapticFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { 61 | let impactHeavy = UIImpactFeedbackGenerator(style: style) 62 | impactHeavy.impactOccurred() 63 | } 64 | 65 | func parseQueryParam(from url: String, param: String) -> String? { 66 | var tmpUrl: String = url 67 | if !tmpUrl.starts(with: "http") { 68 | tmpUrl = APIService.baseUrlString.appending(tmpUrl) 69 | } 70 | guard let tmpUrl = URLComponents(string: tmpUrl) else { return nil } 71 | return tmpUrl.queryItems?.first(where: { $0.name == param })?.value 72 | } 73 | 74 | func notEmpty(_ strs: String?...) -> Bool { 75 | for str in strs { 76 | if let str = str { 77 | if str.isEmpty { return false } 78 | } else { return false } 79 | } 80 | return true 81 | } 82 | 83 | extension URL { 84 | init?(_ url: String) { 85 | self.init(string: url) 86 | } 87 | 88 | func start() { 89 | UIApplication.shared.openURL(self) 90 | } 91 | } 92 | 93 | extension String { 94 | func openURL() { 95 | URL(self)?.start() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /V2er/General/V2erApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/7/1. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @main 12 | struct V2erApp: App { 13 | public static let deviceType = UIDevice().type 14 | public static var rootViewController: UIViewController? 15 | public static var statusBarState: UIStatusBarStyle = .darkContent 16 | public static var window: UIWindow? 17 | 18 | init() { 19 | setupApperance() 20 | } 21 | 22 | private func setupApperance() { 23 | let navbarAppearance = UINavigationBarAppearance() 24 | let tintColor = UIColor.black 25 | navbarAppearance.titleTextAttributes = [.foregroundColor: tintColor] 26 | navbarAppearance.largeTitleTextAttributes = [.foregroundColor: tintColor] 27 | navbarAppearance.backgroundColor = .clear 28 | 29 | let navAppearance = UINavigationBar.appearance() 30 | navAppearance.isTranslucent = true 31 | navAppearance.standardAppearance = navbarAppearance 32 | navAppearance.compactAppearance = navbarAppearance 33 | navAppearance.scrollEdgeAppearance = navbarAppearance 34 | navAppearance.backgroundColor = .clear 35 | navAppearance.tintColor = tintColor 36 | } 37 | 38 | var body: some Scene { 39 | WindowGroup { 40 | RootView { 41 | RootHostView() 42 | .environmentObject(Store.shared) 43 | } 44 | } 45 | } 46 | 47 | static func changeStatusBarStyle(_ style: UIStatusBarStyle) { 48 | guard style != statusBarState else { return } 49 | statusBarState = style 50 | rootViewController? 51 | .setNeedsStatusBarAppearanceUpdate() 52 | } 53 | 54 | } 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /V2er/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0.1 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | 50 | UISupportedInterfaceOrientations~ipad 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationPortraitUpsideDown 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | UIUserInterfaceStyle 58 | Light 59 | UIViewControllerBasedStatusBarAppearance 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /V2er/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Actions/Action.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Action.swift 3 | // Action 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // Actions which lead to state mutations 8 | // 9 | 10 | import Foundation 11 | 12 | protocol Action { 13 | var id: String { get } 14 | var target: Reducer { get } 15 | } 16 | 17 | extension Action { 18 | var id: String { 19 | .default 20 | } 21 | } 22 | 23 | protocol Executable {} 24 | 25 | protocol AsyncAction: Action, Executable { 26 | // Side Effect 27 | func execute(in store: Store) 28 | } 29 | 30 | protocol AwaitAction: Action, Executable { 31 | // Side Effect 32 | func execute(in store: Store) async 33 | } 34 | 35 | enum Reducer { 36 | case global 37 | case feed 38 | case feeddetail 39 | case explore 40 | case message 41 | case me 42 | case userdetail 43 | case tagdetail 44 | case login 45 | case userfeed 46 | case myfavorite 47 | case myfollow 48 | case myrecent 49 | case setting 50 | case createfeed 51 | case search 52 | } 53 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Actions/ExploreActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreActions.swift 3 | // ExploreActions 4 | // 5 | // Created by ghui on 2021/9/2. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ExploreActions { 12 | static let reducer: Reducer = .explore 13 | 14 | struct FetchData { 15 | struct Start: AwaitAction { 16 | var target: Reducer = reducer 17 | 18 | var autoLoad: Bool = false 19 | 20 | func execute(in store: Store) async { 21 | let result: APIResult = await APIService.shared 22 | .htmlGet(endpoint: .explore) 23 | dispatch(FetchData.Done(result: result)) 24 | } 25 | } 26 | 27 | struct Done: Action { 28 | var target: Reducer = reducer 29 | 30 | let result: APIResult 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Actions/GlobalActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalActions.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/22. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | private let R: Reducer = .global 13 | 14 | struct OnAppearChangeAction: Action { 15 | var target: Reducer 16 | var id: String 17 | var isAppear: Bool 18 | } 19 | 20 | struct InstanceDestoryAction: Action { 21 | var target: Reducer = R 22 | var id: String 23 | } 24 | 25 | protocol InstanceIdentifiable { 26 | var instanceId: String { 27 | get 28 | } 29 | } 30 | 31 | struct TabbarClickAction: Action { 32 | var target: Reducer = R 33 | 34 | let selectedTab: TabId 35 | } 36 | 37 | struct ShowToastAction: Action { 38 | var target: Reducer = R 39 | let title: String 40 | var icon: String = .empty 41 | } 42 | 43 | 44 | func globalStateReducer(_ state: GlobalState, _ action: Action?) -> (GlobalState, Action?) { 45 | var state = state 46 | var followingAction = action 47 | switch action { 48 | case let action as ShowToastAction: 49 | state.toast.title = action.title 50 | state.toast.icon = action.icon 51 | state.toast.isPresented = true 52 | break 53 | default: 54 | break 55 | } 56 | return (state, followingAction) 57 | } 58 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Actions/MeActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeActions.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/29. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct MeActions { 12 | private static let R: Reducer = .me 13 | // struct ShowLoginPageAction: Action { 14 | // var target: Reducer = R 15 | // } 16 | } 17 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Actions/UserDetailActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDetailActions.swift 3 | // UserDetailActions 4 | // 5 | // Created by ghui on 2021/9/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct UserDetailActions { 12 | static let R: Reducer = .userdetail 13 | 14 | struct FetchData { 15 | struct Start: AwaitAction { 16 | var target: Reducer = R 17 | var id: String 18 | var autoLoad: Bool = false 19 | 20 | func execute(in store: Store) async { 21 | let result: APIResult = await APIService.shared 22 | .htmlGet(endpoint: .userPage(userName: id ?? .default)) 23 | dispatch(FetchData.Done(id: id, result: result)) 24 | } 25 | } 26 | 27 | struct Done: Action { 28 | var target: Reducer = R 29 | var id: String 30 | let result: APIResult 31 | } 32 | } 33 | 34 | struct Follow: AwaitAction { 35 | var target: Reducer = R 36 | var id: String 37 | 38 | func execute(in store: Store) async { 39 | if AccountState.isSelf(userName: id) { 40 | Toast.show("无法关注自己") 41 | return 42 | } 43 | let state = store.appState.userDetailStates[id]! 44 | let followed = state.model.hasFollowed 45 | Toast.show(followed ? "取消中" : "关注中") 46 | let result: APIResult = await APIService.shared 47 | .htmlGet(endpoint: .general(url: state.model.followUrl), 48 | requestHeaders: Headers.userReferer(id)) 49 | dispatch(FollowDone(id: id, originalFollowed: followed, result: result)) 50 | } 51 | } 52 | 53 | struct FollowDone: Action { 54 | var target: Reducer = R 55 | var id: String 56 | let originalFollowed: Bool 57 | 58 | let result: APIResult 59 | } 60 | 61 | struct BlockUser: AwaitAction { 62 | var target: Reducer = R 63 | var id: String 64 | 65 | func execute(in store: Store) async { 66 | if AccountState.isSelf(userName: id) { 67 | Toast.show("无法屏蔽自己") 68 | return 69 | } 70 | let state = store.appState.userDetailStates[id]! 71 | let hadBlocked = state.model.hasBlocked 72 | Toast.show(hadBlocked ? "取消屏蔽" : "屏蔽中") 73 | let result: APIResult = await APIService.shared 74 | .htmlGet(endpoint: .general(url: state.model.blockUrl), 75 | requestHeaders: Headers.userReferer(id)) 76 | dispatch(BlockUserDone(id: id, originalBlocked: hadBlocked, result: result)) 77 | } 78 | 79 | } 80 | 81 | struct BlockUserDone: Action { 82 | var target: Reducer = R 83 | var id: String 84 | let originalBlocked: Bool 85 | let result: APIResult 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/BaseState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseState.swift 3 | // BaseState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol FluxState{} 12 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/AccountInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct AccountInfo: Codable { 12 | var username: String 13 | var avatar: String 14 | 15 | func isValid() -> Bool { 16 | return notEmpty(username, avatar) 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/BaseModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseModel.swift 3 | // BaseModel 4 | // 5 | // Created by ghui on 2021/8/21. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | protocol HtmlParsable { 13 | init?(from html: Element?) 14 | } 15 | 16 | protocol HtmlItemModel: HtmlParsable, Identifiable { 17 | 18 | } 19 | 20 | protocol BaseModel: HtmlParsable { 21 | var rawData: String? { get set } 22 | 23 | func isValid() -> Bool 24 | } 25 | 26 | struct SimpleModel: BaseModel { 27 | init?(from html: Element?) { } 28 | 29 | func isValid() -> Bool { 30 | true 31 | } 32 | } 33 | 34 | extension BaseModel { 35 | var rawData: String? { 36 | get { 37 | return .empty 38 | } 39 | set { 40 | 41 | } 42 | } 43 | 44 | func isValid() -> Bool { 45 | return true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/DailyInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/24. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct DailyInfo: BaseModel { 13 | var rawData: String? 14 | var userName: String = .default 15 | var avatar: String = .default 16 | var title: String = .default 17 | var checkedInDays: String = .default 18 | var hadCheckedIn: Bool = false 19 | var once: String = .default 20 | var checkedInUrl: String = .empty 21 | 22 | init() {} 23 | init(from html: Element?) { 24 | guard let root = html else { return } 25 | userName = root.pick("[href^=/member]", .href) 26 | .segment(separatedBy: "/", at: 2) 27 | avatar = parseAvatar(root.pick("img[src*=avatar/]", .src)) 28 | title = root.pick("h1") 29 | checkedInDays = root.pick("div.cell:contains(已连续)") 30 | .extractDigits() 31 | checkedInUrl = root.pick("div.cell input[type=button]", .onclick) 32 | hadCheckedIn = !checkedInUrl.isEmpty && checkedInUrl.contains("location.href = '/balance';") 33 | once = checkedInUrl 34 | .segment(separatedBy: "?") 35 | .extractDigits() 36 | } 37 | 38 | func isValid() -> Bool { 39 | return notEmpty(checkedInUrl) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/ModelUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // Utils 4 | // 5 | // Created by ghui on 2021/9/11. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | func parseAvatar(_ link: String) -> String { 13 | // Check whether is start with http 14 | var link = link 15 | if !link.starts(with: APIService.HTTP) { 16 | if link.starts(with: "//") { 17 | link = APIService.HTTPS + link 18 | } else if link.starts(with: "/") { 19 | link = APIService.baseUrlString + link 20 | } 21 | } 22 | return link 23 | .segment(separatedBy: "?m", at: .first) 24 | .replace(segs: "_normal.png", "_mini.png", "_xxlarge.png", 25 | with: "_large.png") 26 | } 27 | 28 | func parseFeedId(_ link: String) -> String { 29 | return link 30 | .remove("/t/") 31 | .segment(separatedBy: "#", at: .first) 32 | } 33 | 34 | func parseReplyUpdate(_ timeReplier: String) -> String { 35 | let result: String 36 | if timeReplier.contains("来自") { 37 | let time = timeReplier.segment(separatedBy: "•", at: .first) 38 | .trim() 39 | let replier = timeReplier.segment(separatedBy: "来自").trim() 40 | result = time.appending(" \(replier) ") 41 | .appending("回复了") 42 | } else { 43 | result = timeReplier 44 | } 45 | return result 46 | } 47 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/Nodes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Nodes.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/23. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias Nodes = [Node] 12 | 13 | struct Node: Codable, Identifiable, Equatable { 14 | var id: String 15 | var text: String 16 | var topics: Int 17 | } 18 | 19 | // TODO consider to update it reguarlly via api 20 | let HOT_NODES: Set = ["qna", "jobs", "programmer", 21 | "share", "macos", "create", 22 | "apple", "python", "career", 23 | "bb", "android", "iphone", 24 | "gts", "mbp", "cv"] 25 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/TabInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabInfo.swift 3 | // TabInfo 4 | // 5 | // Created by ghui on 2021/8/21. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Tab: String { 12 | case all 13 | case tech 14 | case creative 15 | case play 16 | case apple 17 | case jobs 18 | case deals 19 | case city 20 | case qna 21 | case hot 22 | case r2 23 | case nodes 24 | case members 25 | 26 | func displayName() -> String { 27 | var name: String? = nil 28 | switch(self) { 29 | case .all: 30 | name = "全部" 31 | case .tech: 32 | name = "技术" 33 | case .creative: 34 | name = "创意" 35 | case .play: 36 | name = "好玩" 37 | case .apple: 38 | name = "Apple" 39 | case .jobs: 40 | name = "酷工作" 41 | case .deals: 42 | name = "交易" 43 | case .city: 44 | name = "城市" 45 | case .qna: 46 | name = "问与答" 47 | case .hot: 48 | name = "最热" 49 | case .r2: 50 | name = "r2" 51 | case .nodes: 52 | name = "节点" 53 | case .members: 54 | name = "关注" 55 | } 56 | assert(name != nil , "Tab display name shouldn't be null") 57 | return "" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/TagDetailInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagDetailInfo.swift 3 | // TagDetailInfo 4 | // 5 | // Created by ghui on 2021/9/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | // div#Main 13 | struct TagDetailInfo: BaseModel { 14 | var rawData: String? 15 | // onclick="location.href = '/new/career'; 16 | // div.title div.node-breadcrumb, › 17 | var tagName: String = .default 18 | // div.cell.page-content-header div.intro 19 | var tagDesc: String = .default 20 | // div.cell.page-content-header img, alt=tagid 21 | var tagId: String = .default 22 | // div.cell.page-content-header img 23 | var tagImage: String = .default 24 | // div.cell.flex-one-row div:not(div#money) 25 | var countOfStaredPeople: String = .default 26 | // span.topic-count strong 27 | var topicsCount: String = .default 28 | var totalPage: Int = 0 29 | // a[href*=favorite/], .href 30 | var starLink: String = .default 31 | var hasStared: Bool = false 32 | // div.box div.cell:has(table) 33 | var topics: [Item] = [] 34 | 35 | func isValid() -> Bool { 36 | topics.count == 0 || topics[0].userName.notEmpty() 37 | } 38 | 39 | struct Item: HtmlItemModel { 40 | // span.item_title a 41 | var id: String = .default 42 | // img.avatar, .src 43 | var avatar: String = .default 44 | // span.item_title 45 | var title: String = .default 46 | // span.small.fade strong 47 | var userName: String = .default 48 | var replyCount: String = .default 49 | var timeAndReplier: String = .default 50 | 51 | init() {} 52 | init(from html: Element?) { 53 | guard let root = html else { return } 54 | id = parseFeedId(root.pick("span.item_title a", .href)) 55 | avatar = root.pick("img.avatar", .src) 56 | title = root.pick("span.item_title") 57 | userName = root.pick("span.topic_info strong a", at: .first) 58 | replyCount = root.pick("a.count_livid") 59 | timeAndReplier = root.pick("span.topic_info") 60 | .segment(from: "•") 61 | .trim() 62 | } 63 | } 64 | 65 | init() {} 66 | init(from html: Element?) { 67 | guard let root = html else { return } 68 | tagName = root.pick("div.title div.node-breadcrumb") 69 | .segment(separatedBy: "›") 70 | tagDesc = root.pick("div.cell.page-content-header div.intro") 71 | let imgNode = root.pickOne("div.cell.page-content-header img") 72 | tagId = imgNode?.value(.alt) ?? .default 73 | tagImage = imgNode?.value(.src) ?? .default 74 | countOfStaredPeople = root.pick("div.cell.flex-one-row div:not(div#money)") 75 | .segment(separatedBy: " ", at: .first) 76 | topicsCount = root.pick("span.topic-count strong") 77 | let lastNormalpage = root.pick("div.box a.page_normal", at: .last).int 78 | let currentPage = root.pick("div.box span.page_current").int 79 | totalPage = max(lastNormalpage, currentPage) 80 | let favoritePath = root.pick("a[href*=/favorite/]", .href) 81 | starLink = APIService.baseUrlString.appending(favoritePath) 82 | hasStared = starLink.notEmpty() && starLink.contains("/unfavorite/") 83 | 84 | let elements = root.pickAll("div#TopicsNode div.cell:has(table)") 85 | for e in elements { 86 | let item = Item(from: e) 87 | topics.append(item) 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/ThxAuthorInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThxAuthorInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/11/15. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct ThxAuthorInfo: BaseModel { 13 | var link: String = .empty 14 | 15 | init?(from html: Element?) { 16 | guard let root = html else { return } 17 | link = root.pick("a[href=/balance]", .href) 18 | } 19 | 20 | func isValid() -> Bool { 21 | link.notEmpty() 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/TwoStepLoginInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwoStepLoginInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct TwoStepInfo: BaseModel { 13 | var title: String? 14 | var once: String? 15 | 16 | init() {} 17 | 18 | init(from html: Element?) { 19 | guard let root = html?.pickOne("form[method=post]") else { return } 20 | title = root.pick("tr:first-child") 21 | once = root.pick("input[type=hidden]", .value) 22 | } 23 | 24 | func isValid() -> Bool { 25 | guard let once = once, let title = title else { 26 | return false 27 | } 28 | return !once.isEmpty 29 | && !title.isEmpty 30 | && title.contains("两步验证") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Model/TwoStepLoginResultInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwoStepLoginResultInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/12/2. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct TwoStepLoginResultInfo: BaseModel { 13 | // [href^=/member], href 14 | var userName: String 15 | // img[src*=avatar/], src 16 | var avatar: String 17 | 18 | init?(from html: Element?) { 19 | guard let root = html else { return nil } 20 | userName = root.pick("[href^=/member]", .href) 21 | .segment(separatedBy: "/", at: 2) 22 | avatar = root.pick("img[src*=avatar/]", .src) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/DefaultReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaultreducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/22. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | func defaultReducer(_ state: AppState, _ action: Action) -> (AppState, Action?) { 13 | var state = state 14 | var globalState = state.globalState 15 | var followingAction: Action? 16 | switch action { 17 | case let action as TabbarClickAction: 18 | globalState.lastSelectedTab = globalState.selectedTab 19 | globalState.selectedTab = action.selectedTab 20 | if globalState.lastSelectedTab == globalState.selectedTab { 21 | hapticFeedback(.soft) 22 | globalState.scrollTopTab = globalState.selectedTab 23 | let tab = globalState.scrollTopTab 24 | if tab == .message { 25 | state 26 | .messageState 27 | .updatableState 28 | .scrollToTop += 1 29 | } 30 | // TODO: refactor 31 | } 32 | default: 33 | break 34 | } 35 | state.globalState = globalState 36 | return (state, action) 37 | } 38 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/ExploreReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreReducer.swift 3 | // ExploreReducer 4 | // 5 | // Created by ghui on 2021/8/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func exploreStateReducer(_ state: ExploreState, _ action: Action) -> (ExploreState, Action?) { 12 | var state = state 13 | var followingAction: Action? 14 | 15 | switch action { 16 | case let action as ExploreActions.FetchData.Start: 17 | guard !state.refreshing else { break } 18 | state.showProgressView = action.autoLoad 19 | state.hasLoadedOnce = true 20 | state.refreshing = true 21 | break 22 | case let action as ExploreActions.FetchData.Done: 23 | state.refreshing = false 24 | state.showProgressView = false 25 | if case let .success(exploreState) = action.result { 26 | state.exploreInfo = exploreState ?? ExploreInfo() 27 | } else { 28 | // TODO: Loaded failed 29 | } 30 | break 31 | default: 32 | break 33 | } 34 | return (state, followingAction) 35 | } 36 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/FeedReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedReducer.swift 3 | // FeedReducer 4 | // 5 | // Created by ghui on 2021/8/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Action?) { 12 | var state = state 13 | var followingAction: Action? 14 | switch action { 15 | case let action as FeedActions.FetchData.Start: 16 | guard !state.refreshing else { break } 17 | state.showProgressView = action.autoLoad 18 | state.hasLoadedOnce = true 19 | state.refreshing = true 20 | case let action as FeedActions.FetchData.Done: 21 | state.refreshing = false 22 | state.showProgressView = false 23 | if case let .success(newsInfo) = action.result { 24 | state.feedInfo = newsInfo ?? FeedInfo() 25 | state.willLoadPage = 1 26 | } else { } 27 | case let action as FeedActions.LoadMore.Start: 28 | guard !state.refreshing else { break } 29 | guard !state.loadingMore else { break } 30 | state.loadingMore = true 31 | break 32 | case let action as FeedActions.LoadMore.Done: 33 | state.loadingMore = false 34 | state.hasMoreData = true // todo check vary tabs 35 | if case let .success(newsInfo) = action.result { 36 | state.willLoadPage += 1 37 | state.feedInfo.append(feedInfo: newsInfo!) 38 | } else { 39 | // failed 40 | } 41 | case let action as FeedActions.ClearMsgBadge: 42 | state.feedInfo.unReadNums = 0 43 | default: 44 | break 45 | } 46 | return (state, followingAction) 47 | } 48 | 49 | 50 | struct FeedActions { 51 | static let reducer: Reducer = .feed 52 | 53 | struct FetchData { 54 | struct Start: AwaitAction { 55 | var target: Reducer = reducer 56 | let tab: Tab = .all 57 | var page: Int = 0 58 | var autoLoad: Bool = false 59 | 60 | func execute(in store: Store) async { 61 | let result: APIResult = await APIService.shared 62 | .htmlGet(endpoint: .tab, ["tab": tab.rawValue]) 63 | dispatch(FetchData.Done(result: result)) 64 | } 65 | } 66 | 67 | struct Done: Action { 68 | var target: Reducer = reducer 69 | 70 | let result: APIResult 71 | } 72 | } 73 | 74 | struct LoadMore { 75 | struct Start: AwaitAction { 76 | var target: Reducer = reducer 77 | var willLoadPage: Int = 1 78 | 79 | init(_ willLoadPage: Int) { 80 | self.willLoadPage = willLoadPage 81 | } 82 | 83 | func execute(in store: Store) async { 84 | let endpoint: Endpoint = willLoadPage >= 1 ? .recent : .tab 85 | let result: APIResult = await APIService.shared 86 | .htmlGet(endpoint: endpoint, ["p": willLoadPage.string]) 87 | dispatch(FeedActions.LoadMore.Done(result: result)) 88 | } 89 | } 90 | 91 | struct Done: Action { 92 | var target: Reducer = reducer 93 | let result: APIResult 94 | } 95 | } 96 | 97 | struct ClearMsgBadge: Action { 98 | var target: Reducer = reducer 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/MeReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeReducer.swift 3 | // MeReducer 4 | // 5 | // Created by ghui on 2021/8/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func meStateReducer(_ state: MeState, _ action: Action) -> (MeState, Action?) { 12 | var state = state 13 | var followingAction: Action? 14 | 15 | switch action { 16 | // case let action as MeActions.ShowLoginPageAction: 17 | // guard !state.showLoginView else { break } 18 | // state.showLoginView = true 19 | default: 20 | break 21 | } 22 | return (state, followingAction) 23 | } 24 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/MessageReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageReducer.swift 3 | // MessageReducer 4 | // 5 | // Created by ghui on 2021/8/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func messageStateReducer(_ state: MessageState, _ action: Action) -> (MessageState, Action?) { 12 | var state = state 13 | var followingAction: Action? 14 | guard AccountState.hasSignIn() else { 15 | followingAction = nil 16 | return (state, followingAction) 17 | } 18 | switch action { 19 | case let action as MessageActions.FetchStart: 20 | 21 | guard !state.updatableState.refreshing else { break } 22 | state.updatableState.showLoadingView = action.autoLoad 23 | state.hasLoadedOnce = true 24 | state.updatableState.refreshing = true 25 | break 26 | case let action as MessageActions.FetchDone: 27 | state.updatableState.refreshing = false 28 | state.updatableState.showLoadingView = false 29 | if case let .success(messageInfo) = action.result { 30 | state.model = messageInfo! 31 | state.updatableState.willLoadPage = 2 32 | dispatch(FeedActions.ClearMsgBadge(), .default) 33 | } else { 34 | // failed 35 | } 36 | case let action as MessageActions.LoadMoreStart: 37 | guard !state.updatableState.refreshing else { break } 38 | guard !state.updatableState.loadingMore else { break } 39 | state.updatableState.loadingMore = true 40 | case let action as MessageActions.LoadMoreDone: 41 | state.updatableState.loadingMore = false 42 | if case let .success(messageInfo) = action.result { 43 | let messageInfo = messageInfo! 44 | state.updatableState.willLoadPage += 1 45 | state.updatableState.hasMoreData = state.updatableState.willLoadPage <= messageInfo.totalPage 46 | state.model.items.append(contentsOf: messageInfo.items) 47 | } else { 48 | state.updatableState.hasMoreData = true 49 | } 50 | default: 51 | break 52 | } 53 | return (state, followingAction) 54 | } 55 | 56 | 57 | struct MessageActions { 58 | private static var R: Reducer = .message 59 | 60 | struct FetchStart: AwaitAction { 61 | var target: Reducer = R 62 | var autoLoad: Bool = false 63 | 64 | func execute(in store: Store) async { 65 | let result: APIResult = await APIService.shared 66 | .htmlGet(endpoint: .message) 67 | dispatch(FetchDone(result: result)) 68 | } 69 | } 70 | 71 | struct FetchDone: Action { 72 | var target: Reducer = R 73 | let result: APIResult 74 | } 75 | 76 | struct LoadMoreStart: AwaitAction { 77 | var target: Reducer = R 78 | func execute(in store: Store) async { 79 | let state = store.appState.messageState 80 | let params = ["p": state.updatableState.willLoadPage.string] 81 | let result: APIResult = await APIService.shared 82 | .htmlGet(endpoint: .message, params) 83 | dispatch(LoadMoreDone(result: result)) 84 | } 85 | } 86 | 87 | struct LoadMoreDone: Action { 88 | var target: Reducer = R 89 | let result: APIResult 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/MyFollowReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyFollowReducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func myFollowStateReducer(_ state: MyFollowState, _ action: Action) -> (MyFollowState, Action?) { 12 | var state = state 13 | var updatable = state.updatableState 14 | var followingAction: Action? 15 | switch action { 16 | case let action as MyFollowActions.FetchStart: 17 | guard !updatable.refreshing else { break } 18 | updatable.showLoadingView = action.autoLoad 19 | updatable.hasLoadedOnce = true 20 | updatable.refreshing = true 21 | updatable.hasMoreData = true 22 | case let action as MyFollowActions.FetchDone: 23 | updatable.refreshing = false 24 | updatable.showLoadingView = false 25 | if case let .success(model) = action.result { 26 | state.model = model! 27 | updatable.willLoadPage = 2 28 | } else { 29 | // failed 30 | } 31 | case let action as MyFollowActions.LoadMoreStart: 32 | guard !updatable.refreshing else { break } 33 | guard !updatable.loadingMore else { break } 34 | updatable.loadingMore = true 35 | case let action as MyFollowActions.LoadMoreDone: 36 | updatable.loadingMore = false 37 | if case let .success(model) = action.result { 38 | let model = model! 39 | updatable.willLoadPage += 1 40 | updatable.hasMoreData = updatable.willLoadPage <= model.totalPage 41 | state.model?.items.append(contentsOf: model.items) 42 | } else { 43 | updatable.hasMoreData = true 44 | } 45 | default: 46 | break 47 | } 48 | state.updatableState = updatable 49 | return (state, followingAction) 50 | } 51 | 52 | struct MyFollowActions { 53 | private static var R: Reducer = .myfollow 54 | 55 | struct FetchStart: AwaitAction { 56 | var target: Reducer = R 57 | var autoLoad = false 58 | 59 | func execute(in store: Store) async { 60 | let result: APIResult = await APIService.shared 61 | .htmlGet(endpoint: .myFollowing) 62 | dispatch(FetchDone(result: result)) 63 | } 64 | } 65 | 66 | struct FetchDone: Action { 67 | var target: Reducer = R 68 | let result: APIResult 69 | } 70 | 71 | struct LoadMoreStart: AwaitAction { 72 | var target: Reducer = R 73 | 74 | func execute(in store: Store) async { 75 | let state = store.appState.myFollowState 76 | let params = ["p": state.updatableState.willLoadPage.string] 77 | let result: APIResult = await APIService.shared 78 | .htmlGet(endpoint: .myFollowing, params) 79 | dispatch(LoadMoreDone(result: result)) 80 | } 81 | } 82 | 83 | struct LoadMoreDone: Action { 84 | var target: Reducer = R 85 | let result: APIResult 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/MyRecentReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyRecentReducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func myRecentStateReducer(_ state: MyRecentState, _ action: Action) -> (MyRecentState, Action?) { 12 | var state = state 13 | 14 | switch action { 15 | case let action as MyRecentActions.LoadDataStart: 16 | guard !state.loading else { break } 17 | state.loading = true 18 | case let action as MyRecentActions.LoadDataDone: 19 | state.loading = false 20 | state.records = action.result 21 | case let action as MyRecentActions.RecordAction: 22 | break 23 | default: 24 | break 25 | } 26 | return (state, action) 27 | } 28 | 29 | struct MyRecentActions { 30 | static let R: Reducer = .myrecent 31 | 32 | struct LoadDataStart: AwaitAction { 33 | var target: Reducer = R 34 | 35 | func execute(in store: Store) async { 36 | let result: [MyRecentState.Record]? = readRecordsSyncly() 37 | dispatch(LoadDataDone(result: result)) 38 | } 39 | } 40 | 41 | private static func readRecordsSyncly() -> [MyRecentState.Record]? { 42 | var records: [MyRecentState.Record]? = nil 43 | do { 44 | let data = Persist.read(key: MyRecentState.RECORD_KEY) 45 | if let data = data { 46 | records = try JSONDecoder().decode([MyRecentState.Record].self, from: data) 47 | records = records?.sorted(by: >) 48 | } 49 | } catch { 50 | log("read records failed") 51 | } 52 | return records 53 | } 54 | 55 | struct LoadDataDone: Action { 56 | var target: Reducer = R 57 | let result: [MyRecentState.Record]? 58 | } 59 | 60 | struct RecordAction: AwaitAction { 61 | var target: Reducer = R 62 | let data: FeedInfo.Item? 63 | 64 | func execute(in store: Store) async { 65 | guard let data = data else { return } 66 | let newRecord: MyRecentState.Record = 67 | MyRecentState.Record(id: data.id, 68 | title: data.title, 69 | avatar: data.avatar, 70 | userName: data.userName, 71 | nodeName: data.nodeName, 72 | nodeId: data.nodeId, 73 | replyNum: data.replyNum) 74 | var records: [MyRecentState.Record] = readRecordsSyncly() ?? [] 75 | var isAlreadyExist = false 76 | for (index, item) in records.enumerated() { 77 | if item == newRecord { 78 | isAlreadyExist = true 79 | records[index] = newRecord 80 | break; 81 | } 82 | } 83 | if !isAlreadyExist { 84 | // check whether count >=max_capacity 85 | let max_capacity = 50 86 | if records.count >= max_capacity { 87 | // delete the oldest one 88 | records = records.sorted(by: > ) 89 | records.remove(at: max_capacity - 1) 90 | } 91 | records.append(newRecord) 92 | } 93 | // Persis to disk 94 | do { 95 | let jsonData = try JSONEncoder().encode(records) 96 | Persist.save(value: jsonData, forkey: MyRecentState.RECORD_KEY) 97 | log("Record a new item: \(newRecord)") 98 | } catch { 99 | log("Save record: \(newRecord) failed") 100 | } 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/SearchStateReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchStateReducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func searchStateReducer(_ state: SearchState, _ action: Action?) -> (SearchState, Action?) { 12 | var state = state 13 | var updatable = state.updatable 14 | var followingAction = action 15 | switch action { 16 | case let action as SearchActions.Start: 17 | guard !updatable.refreshing else { break } 18 | guard state.keyword.notEmpty() else { 19 | followingAction = nil 20 | break 21 | } 22 | updatable.refreshing = true 23 | updatable.showLoadingView = true 24 | updatable.willLoadPage = 1 25 | case let action as SearchActions.Done: 26 | updatable.refreshing = false 27 | updatable.showLoadingView = false 28 | if case let .success(result) = action.result { 29 | state.model = result! 30 | updatable.willLoadPage = 2 31 | let totalPage: Int = Int(ceil(Double(result!.total / 10))) 32 | updatable.hasMoreData = updatable.willLoadPage <= totalPage 33 | } else { 34 | // failed 35 | } 36 | case let action as SearchActions.LoadMoreStart: 37 | guard !updatable.loadingMore else { break } 38 | guard updatable.hasMoreData else { 39 | followingAction = nil 40 | break 41 | } 42 | updatable.loadingMore = true 43 | case let action as SearchActions.LoadMoreDone: 44 | updatable.loadingMore = false 45 | if case let .success(result) = action.result { 46 | updatable.willLoadPage += 1 47 | let totalPage: Int = Int(ceil(Double(result!.total / 10))) 48 | updatable.hasMoreData = updatable.willLoadPage <= totalPage 49 | if let hints = result?.hits { 50 | state.model!.hits.append(contentsOf: hints) 51 | } 52 | } else { 53 | // failed 54 | } 55 | break 56 | default: 57 | break 58 | } 59 | state.updatable = updatable 60 | return (state, followingAction) 61 | } 62 | 63 | struct SearchActions { 64 | static let R: Reducer = .search 65 | 66 | struct Start: AwaitAction { 67 | var target: Reducer = R 68 | 69 | func execute(in store: Store) async { 70 | let result = await SearchActions.loadData(in: store) 71 | dispatch(Done(result: result)) 72 | } 73 | } 74 | 75 | struct Done: Action { 76 | var target: Reducer = R 77 | let result: APIResult 78 | } 79 | 80 | struct LoadMoreStart: AwaitAction { 81 | var target: Reducer = R 82 | 83 | func execute(in store: Store) async { 84 | let result = await SearchActions.loadData(in: store) 85 | dispatch(LoadMoreDone(result: result)) 86 | } 87 | } 88 | 89 | struct LoadMoreDone: Action { 90 | var target: Reducer = R 91 | let result: APIResult 92 | } 93 | 94 | 95 | private static func loadData(in store: Store, loadMore: Bool = false) async -> APIResult { 96 | let state = store.appState.searchState 97 | var params = Params() 98 | params["from"] = ((state.updatable.willLoadPage - 1) * 10).string 99 | params["q"] = state.keyword 100 | params["sort"] = state.sortWay 101 | let result: APIResult = await APIService.shared 102 | .jsonGet(endpoint: .search, params) 103 | return result 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/SettingReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingReducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func settingStateReducer(_ state: SettingState, _ action: Action) -> (SettingState, Action?) { 12 | return (SettingState(), nil) 13 | } 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/TagDetailReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagDetailReducer.swift 3 | // TagDetailReducer 4 | // 5 | // Created by ghui on 2021/9/15. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func tagDetailStateReducer(_ states: TagDetailStates, _ action: Action) -> (TagDetailStates, Action?) { 12 | guard action.id != .default else { 13 | fatalError("action in TagDetail must have id") 14 | } 15 | let id = action.id 16 | var states = states 17 | var state = states[id]! 18 | var followingAction: Action? 19 | switch action { 20 | case let action as TagDetailActions.LoadMore.Start: 21 | guard !state.loadingMore else { break } 22 | guard state.hasMoreData else { break } 23 | state.showProgressView = action.autoLoad 24 | state.hasLoadedOnce = true 25 | state.loadingMore = true 26 | break; 27 | case let action as TagDetailActions.LoadMore.Done: 28 | state.loadingMore = false 29 | state.showProgressView = false 30 | if case let .success(result) = action.result { 31 | state.willLoadPage += 1 32 | state.hasMoreData = state.willLoadPage <= result?.totalPage ?? 1 33 | if state.willLoadPage == 2 { 34 | state.model = result! 35 | } else { 36 | let newItems = result!.topics 37 | state.model.topics.append(contentsOf: newItems) 38 | } 39 | } else { 40 | // failed 41 | } 42 | case let action as TagDetailActions.StarNodeDone: 43 | if action.success { 44 | Toast.show(action.originalStared ? "取消成功" : "收藏成功") 45 | state.model.hasStared = !action.originalStared 46 | } else { 47 | Toast.show(action.originalStared ? "取消失败" : "收藏失败") 48 | } 49 | break; 50 | default: 51 | break 52 | } 53 | states[id] = state 54 | return (states, followingAction) 55 | } 56 | 57 | 58 | struct TagDetailActions { 59 | static let R: Reducer = .tagdetail 60 | 61 | struct LoadMore { 62 | struct Start: AwaitAction { 63 | var target: Reducer = R 64 | var id: String 65 | let tagId: String? 66 | var willLoadPage: Int = 1 67 | var autoLoad: Bool = false 68 | 69 | func execute(in store: Store) async { 70 | let result: APIResult = await APIService.shared 71 | .htmlGet(endpoint: .tagDetail(tagId: tagId ?? .default), ["p" : willLoadPage.string]) 72 | dispatch(LoadMore.Done(id: id, result: result)) 73 | } 74 | } 75 | 76 | struct Done: Action { 77 | var target: Reducer = R 78 | var id: String 79 | let result: APIResult 80 | } 81 | } 82 | 83 | struct StarNode: AwaitAction { 84 | var target: Reducer = R 85 | let id: String 86 | 87 | func execute(in store: Store) async { 88 | let state = store.appState.tagDetailStates[id] 89 | let originalStared = state?.model.hasStared ?? false 90 | Toast.show(originalStared ? "取消中" : "收藏中") 91 | let result: APIResult = await APIService.shared 92 | .htmlGet(endpoint: .general(url: state?.model.starLink ?? .empty), requestHeaders: Headers.TINY_REFERER) 93 | var success: Bool = false 94 | if case .success(_) = result { 95 | success = true 96 | } 97 | dispatch(StarNodeDone(id: id, success: success, originalStared: originalStared)) 98 | } 99 | } 100 | 101 | struct StarNodeDone: Action { 102 | var target: Reducer = R 103 | let id: String 104 | let success: Bool 105 | let originalStared: Bool 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/UserDetailReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDetailReducer.swift 3 | // UserDetailReducer 4 | // 5 | // Created by ghui on 2021/9/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func userDetailReducer(_ states: UserDetailStates, _ action: Action) -> (UserDetailStates, Action?) { 12 | guard action.id != .default else { 13 | fatalError("action in UserDetail must have id") 14 | } 15 | let id = action.id 16 | var states = states 17 | var state = states[id]! 18 | var followingAction: Action? 19 | switch action { 20 | case let action as UserDetailActions.FetchData.Start: 21 | guard !state.refreshing else { break } 22 | state.showProgressView = action.autoLoad 23 | state.hasLoadedOnce = true 24 | state.refreshing = true 25 | case let action as UserDetailActions.FetchData.Done: 26 | state.refreshing = false 27 | state.showProgressView = false 28 | if case let .success(result) = action.result { 29 | state.model = result! 30 | } else { 31 | // load failed 32 | } 33 | case let action as OnAppearChangeAction: 34 | // FIXME: consider multi instances 35 | if action.isAppear { 36 | state.refCounts += 1 37 | } else { 38 | state.refCounts -= 1 39 | } 40 | case let action as InstanceDestoryAction: 41 | if state.refCounts == 0 { 42 | state.reseted = true 43 | } 44 | case let action as UserDetailActions.FollowDone: 45 | if case let .success(result) = action.result { 46 | state.model = result! 47 | Toast.show(action.originalFollowed ? "取消成功" : "关注成功") 48 | } else { 49 | Toast.show(action.originalFollowed ? "取消失败" : "关注失败") 50 | } 51 | case let action as UserDetailActions.BlockUserDone: 52 | if case let .success(result) = action.result { 53 | state.model = result! 54 | Toast.show(action.originalBlocked ? "取消成功" : "屏蔽成功") 55 | } else { 56 | Toast.show(action.originalBlocked ? "取消失败" : "屏蔽失败") 57 | } 58 | break 59 | default: 60 | break 61 | } 62 | if state.reseted { 63 | states.removeValue(forKey: id) 64 | } else { 65 | states[id] = state 66 | } 67 | return (states, followingAction) 68 | } 69 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/Reducers/UserFeedReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserFeedReducer.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func userFeedStateReducer(_ states: UserFeedStates, _ action: Action) -> (UserFeedStates, Action?) { 12 | guard action.id != .default else { 13 | fatalError("action in FeedDetail must have id") 14 | return (states, action) 15 | } 16 | let id = action.id 17 | var states = states 18 | var state = states[id]! 19 | var followingAction: Action? 20 | switch action { 21 | case let action as UserFeedActions.FetchStart: 22 | guard !state.updatableState.refreshing else { break } 23 | state.updatableState.showLoadingView = action.autoLoad 24 | state.hasLoadedOnce = true 25 | state.updatableState.refreshing = true 26 | state.updatableState.hasMoreData = true 27 | case let action as UserFeedActions.FetchDone: 28 | state.updatableState.refreshing = false 29 | state.updatableState.showLoadingView = false 30 | if case let .success(model) = action.result { 31 | state.model = model! 32 | state.updatableState.willLoadPage = 2 33 | } else { 34 | // failed 35 | } 36 | case let action as UserFeedActions.LoadMoreStart: 37 | guard !state.updatableState.refreshing else { break } 38 | guard !state.updatableState.loadingMore else { break } 39 | state.updatableState.loadingMore = true 40 | case let action as UserFeedActions.LoadMoreDone: 41 | state.updatableState.loadingMore = false 42 | if case let .success(model) = action.result { 43 | let model = model! 44 | state.updatableState.willLoadPage += 1 45 | state.updatableState.hasMoreData = state.updatableState.willLoadPage <= model.totalPage 46 | state.model.items.append(contentsOf: model.items) 47 | } else { 48 | state.updatableState.hasMoreData = true 49 | } 50 | default: 51 | break 52 | } 53 | states[id] = state 54 | return (states, action) 55 | } 56 | 57 | struct UserFeedActions { 58 | private static var R: Reducer = .userfeed 59 | 60 | struct FetchStart: AwaitAction { 61 | var target: Reducer = R 62 | let id: String 63 | let userId: String 64 | var autoLoad = false 65 | 66 | func execute(in store: Store) async { 67 | let result: APIResult = await APIService.shared 68 | .htmlGet(endpoint: .topics(userName: userId)) 69 | dispatch(FetchDone(id: id, result: result)) 70 | } 71 | } 72 | 73 | struct FetchDone: Action { 74 | var target: Reducer = R 75 | var id: String 76 | let result: APIResult 77 | } 78 | 79 | struct LoadMoreStart: AwaitAction { 80 | var target: Reducer = R 81 | let id: String 82 | let userId: String 83 | 84 | func execute(in store: Store) async { 85 | let state = store.appState.userFeedStates[id]! 86 | let params = ["p": state.updatableState.willLoadPage.string] 87 | let result: APIResult = await APIService.shared 88 | .htmlGet(endpoint: .topics(userName: userId), params) 89 | dispatch(LoadMoreDone(id: id, result: result)) 90 | } 91 | } 92 | 93 | struct LoadMoreDone: Action { 94 | var target: Reducer = R 95 | let id: String 96 | let result: APIResult 97 | } 98 | 99 | } 100 | 101 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // AppState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct AppState: FluxState { 12 | var globalState = GlobalState() 13 | var loginState = LoginState() 14 | var feedState = FeedState() 15 | var feedDetailStates: FeedDetailStates = [:] 16 | var exploreState = ExploreState() 17 | var messageState = MessageState() 18 | var meState = MeState() 19 | var userDetailStates: UserDetailStates = [:] 20 | var tagDetailStates: TagDetailStates = [:] 21 | var userFeedStates: UserFeedStates = [:] 22 | var myFavoriteState = MyFavoriteState() 23 | var myFollowState = MyFollowState() 24 | var myRecentState = MyRecentState() 25 | var settingState = SettingState() 26 | var createTopicState = CreateTopicState() 27 | var searchState = SearchState() 28 | } 29 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/CreateTopicState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateTopicState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/21. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftSoup 11 | 12 | struct CreateTopicState: FluxState { 13 | var isLoading = false 14 | var pageInfo: CreatePageInfo? 15 | var sectionNodes: SectionNodes? 16 | 17 | var title: String = .empty 18 | var content: String = .empty 19 | var selectedNode: Node? = nil 20 | var retried: Bool = false 21 | var posting = false 22 | var createResultInfo: CreateResultInfo? = nil 23 | 24 | mutating func reset() { 25 | self = CreateTopicState() 26 | } 27 | } 28 | 29 | typealias SectionNodes = [SectionNode] 30 | struct SectionNode: Identifiable { 31 | var id: String { name } 32 | var name: String 33 | var nodes: Nodes 34 | } 35 | 36 | struct CreatePageInfo: BaseModel { 37 | var once: String 38 | var problem: Problem? 39 | 40 | struct Problem: HtmlParsable { 41 | var title: String 42 | var tips: [String] = [] 43 | 44 | init?(from html: Element?) { 45 | guard let root = html else { return nil } 46 | title = root.value(.ownText) 47 | for e in root.pickAll("ul li") { 48 | tips.append(e.value()) 49 | } 50 | } 51 | 52 | func noProblem() -> Bool { 53 | return tips.isEmpty 54 | } 55 | } 56 | 57 | init?(from html: Element?) { 58 | guard let root = html?.pickOne("div#Wrapper") else { return nil } 59 | once = root.pick("input[name=once]", .value) 60 | let e = root.pickOne("div.problem") 61 | problem = Problem(from: e) 62 | } 63 | 64 | 65 | } 66 | 67 | struct CreateResultInfo: BaseModel { 68 | // TODO: create topic review page 69 | var id: String = .empty 70 | init?(from html: Element?) { 71 | guard let root = html else { return } 72 | self.id = parseFeedId(root.pick("div.cell.topic_content.markdown_body h1 a", .href)) 73 | } 74 | 75 | func isValid() -> Bool { 76 | self.id.notEmpty() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/ExploreState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreState.swift 3 | // ExploreState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ExploreState: FluxState { 12 | var showProgressView: Bool = false 13 | var hasLoadedOnce = false 14 | var refreshing: Bool = false 15 | var exploreInfo: ExploreInfo = ExploreInfo() 16 | } 17 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/FeedDetailState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedDetailState.swift 3 | // FeedDetailState 4 | // 5 | // Created by ghui on 2021/9/4. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct FeedDetailState: FluxState { 12 | var refCounts = 0 13 | var reseted: Bool = false 14 | var hasLoadedOnce = false 15 | var showProgressView: Bool = false 16 | var refreshing = false 17 | var loadingMore = false 18 | var willLoadPage = 0 19 | var hasMoreData = true 20 | var model: FeedDetailInfo = FeedDetailInfo() 21 | var ignored: Bool = false 22 | var replyContent: String = .empty 23 | } 24 | 25 | typealias FeedDetailStates=[String : FeedDetailState] 26 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/FeedState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedState.swift 3 | // FeedState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct FeedState: FluxState { 12 | var hasLoadedOnce = false 13 | var showProgressView: Bool = false 14 | var refreshing: Bool = false 15 | var loadingMore: Bool = false 16 | var willLoadPage: Int = 0 17 | var hasMoreData: Bool = true 18 | var feedInfo: FeedInfo = FeedInfo() 19 | } 20 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/FluxState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseState.swift 3 | // BaseState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol FluxState{ 12 | mutating func reset() 13 | } 14 | 15 | extension FluxState { 16 | mutating func reset() { 17 | 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/GlobalState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalState.swift 3 | // GlobalState 4 | // 5 | // Created by ghui on 2021/9/12. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct GlobalState: FluxState { 13 | var selectedTab: TabId = .feed 14 | var lastSelectedTab: TabId = .none 15 | var scrollTopTab: TabId = .none 16 | var toast = Toast() 17 | 18 | static var account: AccountInfo? { 19 | AccountState.getAccount() 20 | } 21 | 22 | static var hasSignIn: Bool { 23 | AccountState.hasSignIn() 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/LoginState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/23. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct LoginState: FluxState { 13 | var loading = false 14 | var loginParams: LoginParams? 15 | var captchaUrl: String = .empty 16 | var logining = false 17 | var username: String = .empty 18 | var password: String = .empty 19 | var captcha: String = .empty 20 | var dismiss = false 21 | var toast = Toast() 22 | var problemHtml: String? = .empty 23 | 24 | var showLoginView = false 25 | var showAlert: Bool = false 26 | var showTwoStepDialog = false 27 | var twoFAonce: String = .empty 28 | } 29 | 30 | struct LoginParams: BaseModel { 31 | var rawData: String? 32 | // input[type=text][autocorrect=off], name 33 | var nameParam: String = .default 34 | // input[type=password], name 35 | var pswParam: String = .default 36 | // input[name=once], value 37 | var once: String = .default 38 | // input[placeholder*=验证码], name 39 | var captchaParam: String = .default 40 | // div.problem, inner_html 41 | var problem: String? 42 | 43 | init() {} 44 | init(from html: Element?) { 45 | guard let root = html else { return } 46 | nameParam = root.pick("input.sl[type=text]", .name) 47 | pswParam = root.pick("input[type=password]", .name) 48 | once = root.pick("input[name=once]", .value) 49 | captchaParam = root.pick("input[placeholder*=验证码]", .name) 50 | problem = root.pick("div.problem", .innerHtml) 51 | } 52 | 53 | func isValid() -> Bool { 54 | return notEmpty(nameParam, pswParam, once) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/MeState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeState.swift 3 | // MeState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct MeState: FluxState { 12 | // var showLoginView = false 13 | } 14 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/MessageState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageState.swift 3 | // MessageState 4 | // 5 | // Created by ghui on 2021/8/9. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct MessageState: FluxState { 13 | var hasLoadedOnce = false 14 | var updatableState: UpdatableState = UpdatableState() 15 | var model = MessageInfo() 16 | } 17 | 18 | struct MessageInfo: BaseModel { 19 | var totalPage: Int = 0 20 | var items: [Item] = [] 21 | 22 | struct Item: HtmlItemModel { 23 | var id: String = UUID().uuidString 24 | var feedId: String 25 | var username: String = .default 26 | var avatar: String = .default 27 | var title: String = .default 28 | var link: String = .default 29 | var content: String = .default 30 | var time: String = .default 31 | 32 | init?(from html: Element?) { 33 | guard let root = html else { return nil } 34 | username = root.pick("a[href^=/member/] strong") 35 | avatar = parseAvatar(root.pick("a[href^=/member/] img", .src)) 36 | title = root.pick("span.fade") 37 | link = root.pick("a[href^=/t/]", .href) 38 | feedId = parseFeedId(link) 39 | content = root.pick("div.payload", .innerHtml) 40 | .remove("\n") 41 | time = root.pick("span.snow") 42 | } 43 | } 44 | 45 | init() {} 46 | init(from html: Element?) { 47 | guard let root = html else { return } 48 | let lastNormalpage = root.pick("div.box a.page_normal", at: .last).int 49 | let currentPage = root.pick("div.box a.page_current").int 50 | totalPage = max(lastNormalpage, currentPage) 51 | let elements = root.pickAll("div.cell[id^=n_]") 52 | for e in elements { 53 | let item = Item(from: e) 54 | if let item = item { 55 | items.append(item) 56 | } 57 | } 58 | log("items.count: \(items.count)") 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/MyFollowState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyFollowState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | struct MyFollowState: FluxState { 13 | var updatableState = UpdatableState() 14 | var model: MyFollowInfo? = nil 15 | } 16 | 17 | struct MyFollowInfo: BaseModel { 18 | var totalPage: Int = 0 19 | var items: [Item] = [] 20 | 21 | struct Item: FeedItemProtocol { 22 | 23 | var id: String = .default 24 | var avatar: String? 25 | var userName: String? 26 | var replyUpdate: String? 27 | var title: String? 28 | var replyNum: String? = 0.string 29 | var nodeName: String? 30 | var nodeId: String? 31 | 32 | init(id: String, title: String?, avatar: String?) { 33 | self.id = id 34 | self.title = title 35 | self.avatar = avatar 36 | } 37 | 38 | init?(from html: Element?) { 39 | guard let root = html else { return nil } 40 | id = parseFeedId(root.pick("span.item_title a[href^=/t/]", .href)) 41 | avatar = root.pick("img.avatar", .src) 42 | userName = root.pick("strong a[href^=/member/]") 43 | title = root.pick("span.item_title a[href^=/t/]") 44 | replyNum = root.pick("a[class^=count_]") 45 | nodeName = root.pick("a.node") 46 | nodeId = root.pick("a.node", .href) 47 | .segment(separatedBy: "/") 48 | let timeReplier = root.pick("span.topic_info") 49 | replyUpdate = timeReplier.segment(separatedBy: "•", at: 2) 50 | let replier = timeReplier.segment(separatedBy: "•", at: 3) 51 | .segment(separatedBy: " ") 52 | replyUpdate!.append("\(replier) 回复了") 53 | } 54 | } 55 | 56 | init?(from html: Element?) { 57 | guard let root = html?.pickOne("div#Wrapper") else { return nil } 58 | totalPage = root.pick("div.inner strong.fade") 59 | .segment(separatedBy: "/").int 60 | let es = root.pickAll("div.cell.item") 61 | for e in es { 62 | guard let item = Item(from: e) else { continue } 63 | items.append(item) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/MyRecentState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryInfo.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct MyRecentState: FluxState { 12 | static let RECORD_KEY = "app.v2er.record" 13 | var loading = false 14 | var records: [Record]? 15 | 16 | struct Record: FeedItemProtocol, Codable, Comparable { 17 | var timestamp: Int64 = Date.currentTimeStamp 18 | var id: String 19 | var title: String? 20 | var avatar: String? 21 | var userName: String? 22 | var replyUpdate: String? 23 | var nodeName: String? 24 | var nodeId: String? 25 | var replyNum: String? 26 | 27 | init(id: String, title: String?, avatar: String?) { 28 | self.init(id: id, title: title, avatar: avatar, userName: .empty) 29 | } 30 | 31 | init(id: String, 32 | title: String?, 33 | avatar: String?, 34 | userName: String? = .empty, 35 | replyUpdate: String? = .empty, 36 | nodeName: String? = .empty, 37 | nodeId: String? = .empty, 38 | replyNum: String? = .empty 39 | ) { 40 | self.id = id 41 | self.title = title 42 | self.avatar = avatar 43 | self.userName = userName 44 | self.replyUpdate = replyUpdate 45 | self.nodeName = nodeName 46 | self.nodeId = nodeId 47 | self.replyNum = replyNum 48 | } 49 | 50 | static func < (lhs: MyRecentState.Record, rhs: MyRecentState.Record) -> Bool { 51 | lhs.timestamp < rhs.timestamp 52 | } 53 | 54 | static func == (lhs: MyRecentState.Record, rhs: MyRecentState.Record) -> Bool { 55 | lhs.id == rhs.id 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/SearchState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct SearchState: FluxState { 12 | var updatable = UpdatableState() 13 | var keyword: String = .empty 14 | var sortWay: String = "sumup" // created 15 | 16 | var model: Model? 17 | 18 | struct Model: Codable { 19 | var total: Int 20 | var hits: [Hit] = [] 21 | 22 | struct Hit: Codable, Identifiable { 23 | var source: Source 24 | var id: String { 25 | source.id.string 26 | } 27 | 28 | enum CodingKeys: String, CodingKey { 29 | case source = "_source" 30 | } 31 | 32 | struct Source: Codable { 33 | var id: Int 34 | var title: String 35 | var content: String 36 | // node 37 | var nodeId: Int 38 | // replies 39 | var replyNum: Int 40 | var created: String 41 | // member 42 | var creator: String 43 | 44 | enum CodingKeys: String, CodingKey { 45 | case id 46 | case title 47 | case content 48 | case nodeId = "node" 49 | case replyNum = "replies" 50 | case created 51 | case creator = "member" 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/SettingState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct SettingState: FluxState { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/TagDetailState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagDetailState.swift 3 | // TagDetailState 4 | // 5 | // Created by ghui on 2021/9/15. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct TagDetailState: FluxState { 12 | var refCounts = 0 13 | var reseted: Bool = false 14 | var hasLoadedOnce = false 15 | var showProgressView: Bool = true 16 | var loadingMore = false 17 | var willLoadPage = 1 18 | var hasMoreData = true 19 | var model: TagDetailInfo = TagDetailInfo() 20 | } 21 | 22 | typealias TagDetailStates = [String : TagDetailState] 23 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/UpdatableState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdatableState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/9/30. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct UpdatableState { 12 | var refreshing = false 13 | var loadingMore = false 14 | var hasLoadedOnce = false 15 | var willLoadPage = 0 16 | var hasMoreData = true 17 | var showLoadingView = false 18 | var scrollToTop: Int = 0 19 | } 20 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/UserDetailState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDetailState.swift 3 | // UserDetailState 4 | // 5 | // Created by ghui on 2021/9/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct UserDetailState: FluxState { 12 | var refCounts = 0 13 | var reseted = false 14 | var refreshing = false 15 | var hasLoadedOnce = false 16 | var showProgressView = false 17 | var model = UserDetailInfo() 18 | } 19 | 20 | typealias UserDetailStates=[String : UserDetailState] 21 | -------------------------------------------------------------------------------- /V2er/State/DataFlow/State/UserFeedState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserFeedState.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/5. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | typealias UserFeedStates=[String : UserFeedState] 13 | 14 | struct UserFeedState: FluxState { 15 | var hasLoadedOnce = false 16 | var updatableState = UpdatableState() 17 | var model = UserFeedInfo() 18 | } 19 | 20 | struct UserFeedInfo: BaseModel { 21 | var totalPage: Int = 0 22 | var items: [Item] = [] 23 | 24 | struct Item: HtmlItemModel { 25 | var id: String = .default 26 | var title: String = .default 27 | var userName: String = .default 28 | var tag: String = .default 29 | var tagId: String = .default 30 | var replyUpdate: String = .default 31 | var replyNum: String = 0.string 32 | 33 | init(from html: Element?) { 34 | guard let root = html else { return } 35 | id = parseFeedId(root.pick("a.topic-link", .href)) 36 | title = root.pick("span.item_title") 37 | userName = root.pick("span.small.fade strong a") 38 | tag = root.pick("a.node") 39 | tagId = root.pick("a.node", .href) 40 | .segment(separatedBy: "/") 41 | let timeReplier = root.pick("span.small.fade", at: 1, .text) 42 | if timeReplier.contains("来自") { 43 | let time = timeReplier.segment(separatedBy: "•", at: .first) 44 | .trim() 45 | let replier = timeReplier.segment(separatedBy: "来自").trim() 46 | replyUpdate = time.appending(" \(replier) ") 47 | .appending("回复了") 48 | } else { 49 | replyUpdate = timeReplier 50 | } 51 | replyNum = root.pick("a.count_orange", default: 0.string) 52 | } 53 | 54 | } 55 | 56 | init() {} 57 | init(from html: Element?) { 58 | guard let root = html else { return } 59 | totalPage = root.pick("div.inner strong.fade") 60 | .segment(separatedBy: "/").int 61 | let es = root.pickAll("div.content div.cell.item") 62 | for e in es { 63 | let item = Item(from: e) 64 | items.append(item) 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /V2er/State/Networking/Headers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Headers.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/11/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Headers { 12 | static let REFERER: String = "Referer" 13 | static let TINY_REFERER = [REFERER : Endpoint.dailyMission.url.absoluteString] 14 | 15 | static func topicReferer(_ topicId: String) -> [String : String] { 16 | [REFERER : APIService.baseUrlString + "/t/\(topicId)"] 17 | } 18 | 19 | static func userReferer(_ username: String) -> [String : String] { 20 | [REFERER : APIService.baseUrlString + "/member/\(username)"] 21 | } 22 | 23 | static func refer(url: String) -> [String : String] { 24 | [REFERER : url] 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /V2er/State/Networking/NetworkException.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkException.swift 3 | // NetworkException 4 | // 5 | // Created by ghui on 2021/8/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct NetworkException: Error { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /V2er/State/Networking/SwiftSoupExtention.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParseUtils.swift 3 | // ParseUtils 4 | // 5 | // Created by ghui on 2021/8/18. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftSoup 11 | 12 | 13 | public extension Element { 14 | func pick(_ selector: String, at index:Int = 0, 15 | _ attr: HtmlAttr = .text, regex: String? = nil, `default`: String = .empty) -> String { 16 | let es: Elements = pickAll(selector) 17 | let index = min(index, es.count - 1) 18 | let e : Element? = es[safe: index] 19 | guard let e = e else { return `default` } 20 | let result: String? 21 | if attr == .text { 22 | result = try? e.text() 23 | } else if attr == .ownText { 24 | result = e.ownText() 25 | } else if attr == .innerHtml { 26 | result = try? e.html() 27 | } else { 28 | result = try? e.attr(attr.value) 29 | } 30 | // TODO use reg 31 | return result.isEmpty ? `default` : result! 32 | } 33 | 34 | func pickAll(_ selector: String) -> Elements { 35 | if let result = try? self.select(selector) { 36 | return result 37 | } 38 | return Elements() 39 | } 40 | 41 | func pickOne(_ selector: String, at index:Int = 0) -> Element? { 42 | if let result = pickAll(selector)[safe: index] { 43 | return result 44 | } 45 | return nil 46 | } 47 | 48 | func value(_ attr: HtmlAttr = .text) -> String { 49 | let result: String? 50 | if attr == .text { 51 | result = try? self.text() 52 | } else if attr == .ownText { 53 | result = self.ownText() 54 | } else if attr == .html { 55 | result = try? self.outerHtml() 56 | } else if attr == .innerHtml { 57 | result = try? self.html() 58 | } else { 59 | result = try? self.attr(attr.value) 60 | } 61 | return result ?? .default 62 | } 63 | 64 | @discardableResult func remove(selector: String) -> Element { 65 | try? pickAll(selector).remove() 66 | return self 67 | } 68 | } 69 | 70 | public enum HtmlAttr: String { 71 | case text = "text" 72 | case ownText = "ownText" 73 | case href = "href" 74 | case src = "src" 75 | case value = "value" 76 | case html = "html" 77 | case innerHtml = "inner_html" 78 | case content = "content" 79 | case onclick = "onclick" 80 | case id = "id" 81 | case alt = "alt" 82 | case name = "name" 83 | 84 | var value: String { 85 | get { self.rawValue } 86 | } 87 | } 88 | 89 | 90 | -------------------------------------------------------------------------------- /V2er/State/Networking/UA.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UA.swift 3 | // UA 4 | // 5 | // Created by ghui on 2021/8/24. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum UA: String { 12 | case wap 13 | case web 14 | 15 | static let key = "user-agent" 16 | 17 | func value() -> String { 18 | let value: String 19 | switch self { 20 | case .wap: 21 | value = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Mobile/15E148 Safari/604.1" 22 | // value = "Mozilla/5.0 (Linux; Android 9.0; V2er Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36" 23 | break 24 | case .web: 25 | value = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36 Edg/92.0.902.78" 26 | } 27 | return value 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /V2er/View/Explore/ExplorePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExplorePage.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/5/25. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ExplorePage: BaseHomePageView { 12 | @EnvironmentObject private var store: Store 13 | var bindingState: Binding { 14 | $store.appState.exploreState 15 | } 16 | var selecedTab: TabId 17 | 18 | var isSelected: Bool { 19 | let selected = selecedTab == .explore 20 | if selected && !state.hasLoadedOnce { 21 | dispatch(ExploreActions.FetchData.Start(autoLoad: true)) 22 | } 23 | return selected 24 | } 25 | 26 | var scrollToTop: Bool { 27 | if store.appState.globalState.scrollTopTab == .explore { 28 | store.appState.globalState.scrollTopTab = .none 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | var body: some View { 35 | let todayHotList = VStack(alignment: .leading, spacing: 0) { 36 | SectionTitleView("今日热议") 37 | .padding(.horizontal, 10) 38 | .background(Color.itemBg) 39 | ForEach(state.exploreInfo.dailyHotInfo) { item in 40 | HStack(spacing: 12) { 41 | AvatarView(url: item.avatar, size: 30) 42 | // .to { UserDetailPage(userId: item.member) } 43 | Text(item.title) 44 | .foregroundColor(.bodyText) 45 | .lineLimit(2) 46 | .greedyWidth(.leading) 47 | } 48 | .padding(.vertical, 12) 49 | .padding(.horizontal, 10) 50 | .background(Color.itemBg) 51 | .divider() 52 | .to { FeedDetailPage(initData: FeedInfo.Item(id: item.id)) } 53 | } 54 | } 55 | 56 | let hotNodesItem = VStack(alignment: .leading, spacing: 0) { 57 | SectionTitleView("最热节点") 58 | FlowStack(data: state.exploreInfo.hottestNodeInfo) { node in 59 | NodeView(id: node.id, name: node.name) 60 | } 61 | } 62 | 63 | let newlyAddedItem = VStack(alignment: .leading, spacing: 0) { 64 | SectionTitleView("新增节点") 65 | FlowStack(data: state.exploreInfo.recentNodeInfo) { node in 66 | NodeView(id: node.id, name: node.name) 67 | } 68 | } 69 | 70 | let navNodesItem = 71 | VStack(spacing: 0) { 72 | SectionTitleView("节点导航") 73 | ForEach(state.exploreInfo.nodeNavInfo) { 74 | NodeNavItemView(data: $0) 75 | } 76 | } 77 | 78 | VStack(spacing: 0) { 79 | todayHotList 80 | Group { 81 | hotNodesItem 82 | newlyAddedItem 83 | navNodesItem 84 | } 85 | .padding(.horizontal, 10) 86 | .background(Color.itemBg) 87 | } 88 | .hide(state.refreshing) 89 | .updatable(autoRefresh: state.showProgressView, scrollTop(tab: .explore)) { 90 | await run(action: ExploreActions.FetchData.Start()) 91 | } 92 | .background(Color.bgColor) 93 | .hide(!isSelected) 94 | } 95 | } 96 | 97 | 98 | struct NodeNavItemView: View { 99 | 100 | let data: ExploreInfo.NodeNavItem 101 | 102 | var body: some View { 103 | VStack(alignment: .leading, spacing: 0) { 104 | SectionTitleView(data.category, style: .small) 105 | FlowStack(data: data.nodes) { node in 106 | NodeView(id: node.id, name: node.name) 107 | } 108 | } 109 | } 110 | 111 | } 112 | 113 | //fileprivate struct 114 | 115 | struct ExplorePage_Previews: PreviewProvider { 116 | static var selected = TabId.explore 117 | 118 | static var previews: some View { 119 | ExplorePage(selecedTab: selected) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /V2er/View/Explore/SwiftUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // SwiftUIView 4 | // 5 | // Created by Seth on 2021/7/24. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SwiftUIView: View { 12 | 13 | var index: Int = 0 14 | 15 | init(_ index: Int) { 16 | self.index = index 17 | if index == 1 { 18 | print("-------- init ----------") 19 | } 20 | } 21 | 22 | var body: some View { 23 | Text("Node\(index)") 24 | } 25 | } 26 | 27 | struct SwiftUIView_Previews: PreviewProvider { 28 | static var previews: some View { 29 | SwiftUIView(0) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /V2er/View/Feed/FeedItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsItemView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/4. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedItemView: View { 12 | let data: Data 13 | 14 | var body: some View { 15 | VStack(spacing: 0) { 16 | HStack(alignment: .top) { 17 | NavigationLink(destination: UserDetailPage(userId: data.userName ?? .empty)) { 18 | AvatarView(url: data.avatar) 19 | } 20 | VStack(alignment: .leading, spacing: 2) { 21 | Text(data.userName.safe) 22 | .font(.footnote) 23 | Text(data.replyUpdate.safe) 24 | .font(.caption2) 25 | } 26 | .lineLimit(1) 27 | .foregroundColor(Color.tintColor) 28 | Spacer() 29 | NodeView(id: data.nodeId.safe, name: data.nodeName.safe) 30 | } 31 | Text(data.title.safe) 32 | // .fontWeight(.medium) 33 | .foregroundColor(.bodyText) 34 | .greedyWidth(.leading) 35 | .lineLimit(2) 36 | .padding(.top, 6) 37 | .padding(.vertical, 4) 38 | Text("评论\(data.replyNum.safe)") 39 | .font(.footnote) 40 | .greedyWidth(.trailing) 41 | } 42 | .padding(12) 43 | .background(Color.itemBg) 44 | .divider() 45 | 46 | } 47 | } 48 | 49 | 50 | protocol FeedItemProtocol: Identifiable { 51 | var id: String { get } 52 | var title: String? { get } 53 | var avatar: String? { get } 54 | var userName: String? { get } 55 | var replyUpdate: String? { get } 56 | var nodeName: String? { get } 57 | var nodeId: String? { get } 58 | var replyNum: String? { get } 59 | 60 | init(id: String, title: String?, avatar: String?) 61 | 62 | 63 | } 64 | 65 | //struct NewsItemView_Previews: PreviewProvider { 66 | // static var previews: some View { 67 | // Text("Default Text") 68 | // .greedyWidth(.leading) 69 | // .lineLimit(2) 70 | // .padding(.vertical, 3) 71 | // } 72 | //} 73 | -------------------------------------------------------------------------------- /V2er/View/Feed/FeedPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Home.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/5/25. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedPage: BaseHomePageView { 12 | @EnvironmentObject private var store: Store 13 | var bindingState: Binding { 14 | $store.appState.feedState 15 | } 16 | var selecedTab: TabId 17 | 18 | var isSelected: Bool { 19 | let selected = selecedTab == .feed 20 | if selected && !state.hasLoadedOnce { 21 | dispatch(FeedActions.FetchData.Start(autoLoad: true)) 22 | } 23 | return selected 24 | } 25 | 26 | var body: some View { 27 | contentView 28 | .hide(!isSelected) 29 | .onAppear { 30 | log("FeedPage.onAppear") 31 | } 32 | } 33 | 34 | @ViewBuilder 35 | private var contentView: some View { 36 | LazyVStack(spacing: 0) { 37 | ForEach(state.feedInfo.items) { item in 38 | NavigationLink(destination: FeedDetailPage(initData: item)) { 39 | FeedItemView(data: item) 40 | } 41 | } 42 | } 43 | .updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, scrollTop(tab: .feed)) { 44 | await run(action: FeedActions.FetchData.Start()) 45 | } loadMore: { 46 | await run(action: FeedActions.LoadMore.Start(state.willLoadPage)) 47 | } 48 | .background(Color.bgColor) 49 | } 50 | 51 | } 52 | 53 | struct HomePage_Previews: PreviewProvider { 54 | static var selected = TabId.feed 55 | 56 | static var previews: some View { 57 | FeedPage(selecedTab: selected) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /V2er/View/FeedDetail/AuthorInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorInfoView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/7. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AuthorInfoView: View { 12 | var initData: FeedInfo.Item? = nil 13 | var data: FeedDetailInfo.HeaderInfo? = nil 14 | 15 | private var title: String { 16 | data?.title ?? initData?.title ?? .default 17 | } 18 | 19 | private var tag: String { 20 | data?.nodeName ?? initData?.nodeName ?? .default 21 | } 22 | 23 | private var tagId: String { 24 | data?.nodeId ?? initData?.nodeId ?? .default 25 | } 26 | 27 | private var userName: String { 28 | data?.userName ?? initData?.userName ?? .default 29 | } 30 | 31 | private var avatar: String { 32 | initData?.avatar ?? data?.avatar ?? .default 33 | } 34 | 35 | private var timeAndClickedNum: String { 36 | data?.replyUpdate ?? .default 37 | } 38 | 39 | private var replyNum: String { 40 | var result = (data?.replyNum ?? initData?.replyNum ?? .default) 41 | if result.notEmpty() { 42 | result = "评论\(result) " 43 | } 44 | return result 45 | } 46 | 47 | var body: some View { 48 | VStack(spacing: 0) { 49 | HStack(alignment: .top) { 50 | AvatarView(url: avatar, size: 38) 51 | .to { UserDetailPage(userId: data?.userName ?? .empty) } 52 | VStack(alignment: .leading, spacing: 5) { 53 | Text(userName) 54 | .lineLimit(1) 55 | Text(replyNum + timeAndClickedNum) 56 | .lineLimit(1) 57 | .font(.caption2) 58 | } 59 | Spacer() 60 | NavigationLink(destination: TagDetailPage(tag: tag, tagId: tagId)) { 61 | Text(tag) 62 | .font(.footnote) 63 | .foregroundColor(.black) 64 | .lineLimit(1) 65 | .padding(.horizontal, 14) 66 | .padding(.vertical, 8) 67 | .background(Color.lightGray) 68 | } 69 | } 70 | Text(title) 71 | .font(.headline) 72 | .foregroundColor(.bodyText) 73 | .greedyWidth(.leading) 74 | .padding(.top, 10) 75 | .debug() 76 | } 77 | .padding(10) 78 | .background(Color.itemBg) 79 | } 80 | } 81 | 82 | //struct AuthorInfoView_Previews: PreviewProvider { 83 | // static var previews: some View { 84 | // AuthorInfoView() 85 | // } 86 | //} 87 | -------------------------------------------------------------------------------- /V2er/View/FeedDetail/NewsContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsContentView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewsContentView: View { 12 | var contentInfo: FeedDetailInfo.ContentInfo? 13 | @Binding var rendered: Bool 14 | 15 | init(_ contentInfo: FeedDetailInfo.ContentInfo?, rendered: Binding) { 16 | self.contentInfo = contentInfo 17 | self._rendered = rendered 18 | } 19 | 20 | var body: some View { 21 | VStack(spacing: 0) { 22 | Divider() 23 | HtmlView(html: contentInfo?.html, imgs: contentInfo?.imgs ?? [], rendered: $rendered) 24 | Divider() 25 | } 26 | } 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /V2er/View/FeedDetail/ReplyItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReplyListView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Atributika 11 | 12 | 13 | struct ReplyItemView: View { 14 | var info: FeedDetailInfo.ReplyInfo.Item 15 | 16 | var body: some View { 17 | HStack(alignment: .top) { 18 | VStack(spacing: 0) { 19 | AvatarView(url: info.avatar, size: 36) 20 | .to { UserDetailPage(userId: info.userName) } 21 | Text("楼主") 22 | .font(.system(size: 8)) 23 | .padding(.horizontal, 4) 24 | .padding(.vertical, 2) 25 | .cornerBorder(radius: 3, borderWidth: 0.8, color: .black) 26 | .padding(.top, 2) 27 | .hide(!info.isOwner) 28 | } 29 | VStack(alignment: .leading, spacing: 8) { 30 | HStack { 31 | VStack (alignment: .leading, spacing: 4) { 32 | Text(info.userName) 33 | Text(info.time) 34 | .font(.caption2) 35 | } 36 | Spacer() 37 | // Image(systemName: "heart") 38 | } 39 | RichText { info.content } 40 | Text("\(info.floor)楼") 41 | .font(.footnote) 42 | .foregroundColor(Color.tintColor) 43 | Divider() 44 | .padding(.vertical, 6) 45 | } 46 | } 47 | .padding(.horizontal, 12) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /V2er/View/Login/TwoStepLoginPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwoStepLoginPage.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/12/12. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TwoStepLoginPage: View { 12 | @State var twoStepCode: String = .empty 13 | 14 | var body: some View { 15 | ZStack { 16 | Color.dim 17 | .opacity(0.8) 18 | .ignoresSafeArea() 19 | VStack(spacing: 16) { 20 | Text("两步验证") 21 | .font(.subheadline) 22 | TextField("2FA码", text: $twoStepCode) 23 | .padding(.vertical, 6) 24 | .padding(.horizontal) 25 | .background(Color.white.opacity(0.8)) 26 | .cornerBorder(radius: 8) 27 | HStack(spacing: 16) { 28 | Spacer() 29 | Button { 30 | dispatch(LoginActions.TwoStepLoginCancel()) 31 | } label: { Text("取消").opacity(0.8) } 32 | Button { 33 | dispatch(LoginActions.TwoStepLogin(input: twoStepCode)) 34 | } label: { Text("确定") } 35 | .disabled(twoStepCode.isEmpty) 36 | } 37 | .foregroundColor(.bodyText) 38 | } 39 | .padding(20) 40 | .visualBlur() 41 | .cornerBorder(radius: 20, borderWidth: 0) 42 | .padding(50) 43 | } 44 | 45 | } 46 | } 47 | 48 | struct TwoStepLoginPage_Previews: PreviewProvider { 49 | static var previews: some View { 50 | TwoStepLoginPage() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /V2er/View/MainPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/5/23. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MainPage: StateView { 12 | @EnvironmentObject private var store: Store 13 | 14 | var bindingState: Binding { 15 | $store.appState.globalState 16 | } 17 | var selectedTab: Binding { 18 | bindingState.selectedTab 19 | } 20 | 21 | var unReadNums: Int { 22 | store.appState.feedState.feedInfo.unReadNums 23 | } 24 | 25 | var body: some View { 26 | NavigationView { 27 | ZStack { 28 | FeedPage(selecedTab: state.selectedTab) 29 | ExplorePage(selecedTab: state.selectedTab) 30 | MessagePage(selecedTab: state.selectedTab) 31 | MePage(selecedTab: state.selectedTab) 32 | } 33 | .safeAreaInset(edge: .top, spacing: 0) { 34 | TopBar(selectedTab: state.selectedTab) 35 | } 36 | .safeAreaInset(edge: .bottom, spacing: 0) { 37 | TabBar(unReadNums) 38 | } 39 | .ignoresSafeArea(.container) 40 | .navigationBarHidden(true) 41 | } 42 | } 43 | 44 | } 45 | 46 | 47 | //struct MainPage_Previews: PreviewProvider { 48 | //// @State static var selecedTab: TabId = TabId.me 49 | // 50 | // static var previews: some View { 51 | // MainPage() 52 | // .environmentObject(Store.shared) 53 | // } 54 | //} 55 | -------------------------------------------------------------------------------- /V2er/View/Me/MePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // Created by Seth on 2020/5/25. 4 | // Copyright © 2020 lessmore.io. All rights reser 5 | // MePage.swift 6 | // V2erved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MePage: BaseHomePageView { 12 | @EnvironmentObject private var store: Store 13 | var bindingState: Binding { 14 | $store.appState.meState 15 | } 16 | var selecedTab: TabId 17 | var isSelected: Bool { 18 | let selected = selecedTab == .me 19 | return selected 20 | } 21 | 22 | var body: some View { 23 | ScrollView { 24 | VStack(spacing: 0) { 25 | topBannerView 26 | sectionViews 27 | } 28 | } 29 | .background(Color.bgColor) 30 | .overlay { 31 | if !AccountState.hasSignIn() { 32 | VStack { 33 | Text("登录查看更多") 34 | .foregroundColor(.white) 35 | .font(.title2) 36 | Button { 37 | dispatch(LoginActions.ShowLoginPageAction()) 38 | } label: { 39 | Text("登录") 40 | .font(.headline) 41 | .foregroundColor(.white) 42 | .padding() 43 | .padding(.horizontal, 50) 44 | .background(Color.black) 45 | .cornerRadius(15) 46 | } 47 | } 48 | .greedyFrame() 49 | .background(Color.dim) 50 | } 51 | } 52 | .hide(selecedTab != .me) 53 | } 54 | 55 | @ViewBuilder 56 | private var topBannerView: some View { 57 | HStack(spacing: 10) { 58 | AvatarView(url: AccountState.avatarUrl, size: 60) 59 | HStack { 60 | VStack(alignment: .leading, spacing: 6) { 61 | Text(AccountState.userName) 62 | .font(.headline) 63 | Text("") 64 | .font(.footnote) 65 | } 66 | Spacer() 67 | } 68 | HStack { 69 | Text("个人主页") 70 | .font(.subheadline) 71 | .foregroundColor(Color.bodyText) 72 | Image(systemName: "chevron.right") 73 | .font(.body.weight(.regular)) 74 | .foregroundColor(Color.gray) 75 | } 76 | } 77 | .padding(.horizontal, 12) 78 | .padding(.vertical, 16) 79 | .background(.white) 80 | .padding(.bottom, 8) 81 | .to { 82 | UserDetailPage(userId: AccountState.userName) 83 | } 84 | } 85 | 86 | @ViewBuilder 87 | private var sectionViews: some View { 88 | VStack(spacing: 0) { 89 | SectionItemView("发贴", icon: "pencil", showDivider: false) 90 | .padding(.bottom, 8) 91 | .to { 92 | CreateTopicPage() 93 | .transition(.move(edge: .bottom)) 94 | } 95 | SectionItemView("主题", icon: "paperplane") 96 | .to { UserFeedPage(userId: AccountState.userName) } 97 | SectionItemView("收藏", icon: "bookmark") 98 | .to { MyFavoritePage() } 99 | SectionItemView("关注", icon: "heart") 100 | .to { MyFollowPage() } 101 | SectionItemView("最近浏览", icon: "clock", showDivider: false) 102 | .to { MyRecentPage() } 103 | SectionItemView("设置", icon: "gearshape", showDivider: false) 104 | .padding(.top, 8) 105 | .to { SettingsPage() } 106 | } 107 | } 108 | 109 | } 110 | 111 | 112 | struct AccountPage_Previews: PreviewProvider { 113 | static var selected = TabId.me 114 | 115 | static var previews: some View { 116 | MePage(selecedTab: selected) 117 | .environmentObject(Store.shared) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /V2er/View/Me/MyFavoritePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StarPage.swift 3 | // StarPage 4 | // 5 | // Created by Seth on 2021/8/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MyFavoritePage: StateView { 12 | @EnvironmentObject private var store: Store 13 | @State private var selectedTab: Int = 0 14 | 15 | var bindingState: Binding { 16 | return $store.appState.myFavoriteState 17 | } 18 | 19 | var body: some View { 20 | contentView 21 | .navigatable() 22 | } 23 | 24 | @ViewBuilder 25 | private var contentView: some View { 26 | TabView(selection: $selectedTab) { 27 | feedView 28 | .tag(0) 29 | nodeView 30 | .tag(1) 31 | } 32 | .tabViewStyle(.page) 33 | .padding(.horizontal, 10) 34 | .debug() 35 | .safeAreaInset(edge: .top, spacing: 0) { navBar } 36 | .ignoresSafeArea(.container) 37 | .navigationBarHidden(true) 38 | } 39 | 40 | @ViewBuilder 41 | private var navBar: some View { 42 | NavbarTitleView { 43 | Picker("收藏", selection: $selectedTab) { 44 | Text("主题") 45 | .tag(0) 46 | Text("节点") 47 | .tag(1) 48 | } 49 | .font(.headline) 50 | .pickerStyle(.segmented) 51 | .frame(maxWidth: 200) 52 | } 53 | } 54 | 55 | @ViewBuilder 56 | private var feedView: some View { 57 | LazyVStack(spacing: 0) { 58 | ForEach(state.feedState.model?.items ?? []) { item in 59 | NavigationLink { 60 | FeedDetailPage(id: item.id) 61 | } label: { 62 | FeedItemView(data: item) 63 | } 64 | } 65 | } 66 | .padding(.top, 30) 67 | .onAppear { 68 | dispatch(MyFavoriteActions.FetchFeedStart(autoLoad: !state.feedState.updatable.hasLoadedOnce)) 69 | } 70 | .updatable(state.feedState.updatable) { 71 | await run(action: MyFavoriteActions.FetchFeedStart()) 72 | } loadMore: { 73 | await run(action: MyFavoriteActions.LoadMoreFeedStart()) 74 | } 75 | } 76 | 77 | @ViewBuilder 78 | private var nodeView: some View { 79 | let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 3) 80 | LazyVGrid(columns: columns) { 81 | ForEach(state.nodeState.model?.items ?? []) { item in 82 | NavigationLink { 83 | TagDetailPage(tagId: item.id) 84 | } label: { 85 | VStack { 86 | AvatarView(url: item.img) 87 | Text(item.name) 88 | Text(item.topicNum) 89 | .font(.footnote) 90 | } 91 | .lineLimit(1) 92 | } 93 | } 94 | } 95 | .padding(.top, 30) 96 | .onAppear { 97 | dispatch(MyFavoriteActions.FetchNodeStart(autoLoad: !state.nodeState.updatable.hasLoadedOnce)) 98 | } 99 | .updatable(state.nodeState.updatable) { 100 | await run(action: MyFavoriteActions.FetchNodeStart()) 101 | } 102 | } 103 | 104 | } 105 | 106 | struct StarPage_Previews: PreviewProvider { 107 | static var previews: some View { 108 | MyFavoritePage() 109 | .environmentObject(Store.shared) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /V2er/View/Me/MyFollowPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpecailCarePage.swift 3 | // SpecailCarePage 4 | // 5 | // Created by Seth on 2021/8/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MyFollowPage: StateView { 12 | @EnvironmentObject private var store: Store 13 | 14 | var bindingState: Binding { 15 | return $store.appState.myFollowState 16 | } 17 | 18 | var body: some View { 19 | contentView 20 | .updatable(state.updatableState) { 21 | await run(action: MyFollowActions.FetchStart(autoLoad: false)) 22 | } loadMore: { 23 | await run(action: MyFollowActions.LoadMoreStart()) 24 | } 25 | .onAppear { 26 | dispatch(MyFollowActions.FetchStart(autoLoad: !state.updatableState.hasLoadedOnce)) 27 | } 28 | .navBar("我的关注") 29 | } 30 | 31 | @ViewBuilder 32 | private var contentView: some View { 33 | LazyVStack(spacing: 0) { 34 | ForEach(state.model?.items ?? []) { item in 35 | NavigationLink { 36 | FeedDetailPage(id: item.id) 37 | } label: { 38 | FeedItemView(data: item) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | struct SpecailCarePage_Previews: PreviewProvider { 46 | static var previews: some View { 47 | MyFollowPage() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /V2er/View/Me/MyRecentPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryPage.swift 3 | // HistoryPage 4 | // 5 | // Created by Seth on 2021/8/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MyRecentPage: StateView { 12 | @EnvironmentObject private var store: Store 13 | 14 | var bindingState: Binding { 15 | return $store.appState.myRecentState 16 | } 17 | 18 | var body: some View { 19 | contentView 20 | .onAppear { 21 | dispatch(MyRecentActions.LoadDataStart()) 22 | } 23 | } 24 | 25 | @ViewBuilder 26 | private var contentView: some View { 27 | ScrollView { 28 | LazyVStack(spacing: 0) { 29 | ForEach(state.records ?? []) { item in 30 | RecentItemView(data: item) 31 | .background(Color.itemBg) 32 | .to { 33 | FeedDetailPage(id: item.id) 34 | } 35 | } 36 | } 37 | } 38 | .navBar("最近浏览") 39 | } 40 | } 41 | 42 | struct RecentItemView: View { 43 | let data: Data 44 | 45 | var body: some View { 46 | VStack(spacing: 0) { 47 | HStack(alignment: .top) { 48 | NavigationLink(destination: UserDetailPage(userId: data.userName.safe)) { 49 | AvatarView(url: data.avatar) 50 | } 51 | VStack(alignment: .leading, spacing: 5) { 52 | Text(data.userName.safe) 53 | .lineLimit(1) 54 | Text(data.replyNum.safe) 55 | .lineLimit(1) 56 | .font(.footnote) 57 | .foregroundColor(Color.tintColor) 58 | } 59 | Spacer() 60 | NodeView(id: data.nodeId.safe, name: data.nodeName.safe) 61 | } 62 | Text(data.title.safe) 63 | .greedyWidth(.leading) 64 | .lineLimit(2) 65 | .padding(.vertical, 3) 66 | } 67 | .padding(12) 68 | .background(Color.almostClear) 69 | .divider() 70 | } 71 | } 72 | 73 | 74 | struct HistoryPage_Previews: PreviewProvider { 75 | static var previews: some View { 76 | MyRecentPage() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /V2er/View/Me/NodeChooserPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeChooserPage.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/17. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NodeChooserPage: View { 12 | @State var filterText: String = .empty 13 | @State private var isEditing = false 14 | var nodes: SectionNodes? 15 | @Binding var selectedNode: Node? 16 | @Environment(\.dismiss) var dismiss 17 | @FocusState private var focused: Bool 18 | 19 | var body: some View { 20 | contentView 21 | } 22 | 23 | var filterNodes: SectionNodes? { 24 | var hotSection = SectionNode(name: "热门节点", 25 | nodes: nodes?[0].nodes.filter { filterText.isEmpty || $0.text.contains(filterText) } ?? []) 26 | var allSection = SectionNode(name: "其它节点", 27 | nodes: nodes?[1].nodes.filter { filterText.isEmpty || $0.text.contains(filterText) } ?? []) 28 | return [hotSection, allSection] 29 | } 30 | 31 | @ViewBuilder 32 | private var contentView: some View { 33 | VStack(spacing: 0) { 34 | Text("选择节点") 35 | .font(.title3) 36 | .fontWeight(.semibold) 37 | .padding(.vertical, 16) 38 | searchBar 39 | Spacer() 40 | List { 41 | ForEach(filterNodes ?? [] ) { section in 42 | Section(header: Text(section.name)) { 43 | ForEach(section.nodes) { node in 44 | Button { 45 | selectedNode = node 46 | dismiss() 47 | } label: { 48 | Text(node.text) 49 | .greedyFrame(.leading) 50 | .forceClickable() 51 | } 52 | .listRowBackground(selectedNode == node ? Color.bgColor : Color.itemBg) 53 | } 54 | } 55 | } 56 | } 57 | .background(Color.bgColor) 58 | } 59 | } 60 | 61 | @ViewBuilder 62 | private var searchBar: some View { 63 | HStack { 64 | HStack { 65 | Image(systemName: "magnifyingglass") 66 | .foregroundColor(.gray) 67 | TextField("Search ...", text: $filterText) 68 | .disableAutocorrection(true) 69 | .autocapitalization(.none) 70 | .focused($focused) 71 | } 72 | .padding(7) 73 | .padding(.horizontal, 8) 74 | .background(Color(.systemGray6)) 75 | .cornerRadius(8) 76 | .padding(.horizontal, 16) 77 | .onTapGesture { 78 | withAnimation { 79 | self.isEditing = true 80 | } 81 | } 82 | if isEditing { 83 | Button { 84 | withAnimation { 85 | self.isEditing = false 86 | self.filterText = "" 87 | self.focused = false 88 | } 89 | } label: { 90 | Text("Cancel") 91 | .foregroundColor(.primary) 92 | } 93 | .padding(.trailing, 10) 94 | } 95 | } 96 | } 97 | 98 | } 99 | 100 | //struct NodeChooserPage_Previews: PreviewProvider { 101 | // private static var text: String = .empty 102 | // static var previews: some View { 103 | // NodeChooserPage(text: text) 104 | // } 105 | //} 106 | -------------------------------------------------------------------------------- /V2er/View/Me/UserFeedPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyTopicPage.swift 3 | // MyTopicPage 4 | // 5 | // Created by Seth on 2021/8/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct UserFeedPage: StateView, InstanceIdentifiable { 12 | @EnvironmentObject private var store: Store 13 | @Environment(\.presentationMode) var presentationMode: Binding 14 | @Environment(\.isPresented) private var isPresented 15 | let userId: String 16 | var instanceId: String { userId } 17 | 18 | var bindingState: Binding { 19 | if store.appState.userFeedStates[instanceId] == nil { 20 | store.appState.userFeedStates[instanceId] = UserFeedState() 21 | } 22 | return $store.appState.userFeedStates[instanceId] 23 | } 24 | 25 | var body: some View { 26 | contentView 27 | .updatable(state.updatableState) { 28 | await run(action: UserFeedActions.FetchStart(id: instanceId, userId: userId, autoLoad: false)) 29 | } loadMore: { 30 | await run(action: UserFeedActions.LoadMoreStart(id: instanceId, userId: userId)) 31 | } 32 | .onAppear { 33 | dispatch(UserFeedActions.FetchStart(id: instanceId, userId: userId, autoLoad: !state.hasLoadedOnce)) 34 | } 35 | .navBar("\(userId)的全部主题") 36 | } 37 | 38 | @ViewBuilder 39 | private var contentView: some View { 40 | LazyVStack(spacing: 0) { 41 | ForEach(state.model.items) { item in 42 | NavigationLink { 43 | FeedDetailPage(id: item.id) 44 | } label: { 45 | ItemView(data: item) 46 | } 47 | } 48 | } 49 | } 50 | 51 | struct ItemView: View { 52 | var data: UserFeedInfo.Item 53 | 54 | var body: some View { 55 | VStack(spacing: 4) { 56 | HStack(alignment: .top) { 57 | VStack(alignment: .leading, spacing: 5) { 58 | Text(data.userName) 59 | .lineLimit(1) 60 | Text(data.replyUpdate) 61 | .lineLimit(1) 62 | .font(.footnote) 63 | .greedyWidth(.leading) 64 | .foregroundColor(Color.tintColor) 65 | } 66 | Spacer() 67 | NavigationLink(destination: TagDetailPage()) { 68 | Text(data.tag) 69 | .font(.footnote) 70 | .foregroundColor(.black) 71 | .lineLimit(1) 72 | .padding(.horizontal, 14) 73 | .padding(.vertical, 8) 74 | .background(Color.lightGray) 75 | } 76 | } 77 | Text(data.title) 78 | .greedyWidth(.leading) 79 | .lineLimit(2) 80 | Text("评论\(data.replyNum)") 81 | .lineLimit(1) 82 | .font(.footnote) 83 | .greedyWidth(.trailing) 84 | } 85 | .padding(12) 86 | .divider() 87 | .background(Color.itemBg) 88 | } 89 | } 90 | 91 | } 92 | 93 | struct MyTopicPage_Previews: PreviewProvider { 94 | static var previews: some View { 95 | UserFeedPage(userId: .empty) 96 | .environmentObject(Store.shared) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /V2er/View/Message/MessagePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessagePage.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/5/25. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftSoup 11 | import Atributika 12 | 13 | struct MessagePage: BaseHomePageView { 14 | @EnvironmentObject private var store: Store 15 | var bindingState: Binding { 16 | $store.appState.messageState 17 | } 18 | var selecedTab: TabId 19 | 20 | var isSelected: Bool { 21 | let selected = selecedTab == .message 22 | if selected && !state.hasLoadedOnce { 23 | dispatch(MessageActions.FetchStart(autoLoad: true)) 24 | } 25 | return selected 26 | } 27 | 28 | var body: some View { 29 | contentView 30 | .background(Color.bgColor) 31 | .hide(!isSelected) 32 | } 33 | 34 | @ViewBuilder 35 | private var contentView: some View { 36 | LazyVStack(spacing: 0) { 37 | ForEach(state.model.items) { item in 38 | MessageItemView(item: item) 39 | } 40 | } 41 | .updatable(state.updatableState) { 42 | await run(action: MessageActions.FetchStart()) 43 | } loadMore: { 44 | await run(action: MessageActions.LoadMoreStart()) 45 | } 46 | } 47 | } 48 | 49 | struct MessageItemView: View { 50 | let item: MessageInfo.Item 51 | let quoteFont = Style.font(UIFont.prfered(.subheadline)) 52 | .foregroundColor(Color.bodyText.uiColor) 53 | 54 | var body: some View { 55 | HStack(alignment: .top, spacing: 10) { 56 | AvatarView(url: item.avatar, size: 40) 57 | .to { UserDetailPage(userId: item.username)} 58 | VStack(alignment: .leading) { 59 | Text(item.title) 60 | .greedyWidth(.leading) 61 | .background(Color.itemBg) 62 | .to { FeedDetailPage(id: item.feedId) } 63 | RichText { 64 | item.content 65 | .rich(baseStyle: quoteFont) 66 | } 67 | .debug() 68 | .padding(10) 69 | .background { 70 | HStack(spacing: 0) { 71 | Color.tintColor.opacity(0.8) 72 | .frame(width: 3) 73 | Color.lightGray 74 | } 75 | .clipCorner(1.5, corners: [.topLeft, .bottomLeft]) 76 | } 77 | .visibility(item.content.isEmpty ? .gone : .visible) 78 | } 79 | } 80 | .padding(12) 81 | .background(Color.itemBg) 82 | .divider() 83 | } 84 | 85 | } 86 | 87 | struct MessagePage_Previews: PreviewProvider { 88 | static var selected = TabId.message 89 | static var previews: some View { 90 | MessagePage(selecedTab: .message) 91 | .environmentObject(Store.shared) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /V2er/View/Settings/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AboutView: View { 12 | 13 | var body: some View { 14 | formView 15 | .navBar("关于") 16 | } 17 | 18 | @ViewBuilder 19 | private var formView: some View { 20 | ScrollView { 21 | NavigationLink { 22 | 23 | } label: { 24 | SectionItemView("FaceID") 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct AboutView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | AboutView() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /V2er/View/Settings/AppearanceSettingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSettingView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AppearanceSettingView: View { 12 | var body: some View { 13 | formView 14 | .navBar("外观设置") 15 | } 16 | 17 | @ViewBuilder 18 | private var formView: some View { 19 | ScrollView { 20 | SectionItemView("字体大小") 21 | // .to {} 22 | } 23 | } 24 | } 25 | 26 | struct AppearanceSettingView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | AppearanceSettingView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /V2er/View/Settings/BrowseSettingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseSettingView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BrowseSettingView: View { 12 | @State private var isOn: Bool = false 13 | 14 | var body: some View { 15 | formView 16 | .navBar("浏览设置") 17 | } 18 | 19 | @ViewBuilder 20 | private var formView: some View { 21 | ScrollView { 22 | SectionView("逆序浏览") { 23 | Toggle(.empty, isOn: $isOn) 24 | } 25 | } 26 | } 27 | } 28 | 29 | struct BrowseSettingView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | BrowseSettingView() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /V2er/View/Settings/FeedbackHelperView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedbackHelperView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FeedbackHelperView: View { 12 | var body: some View { 13 | formView 14 | .navBar("帮助与反馈") 15 | } 16 | 17 | @ViewBuilder 18 | private var formView: some View { 19 | ScrollView { 20 | NavigationLink { 21 | 22 | } label: { 23 | SectionItemView("FaceID") 24 | } 25 | } 26 | } 27 | } 28 | 29 | struct FeedbackHelperView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | FeedbackHelperView() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /V2er/View/Settings/OtherSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OtherSettingsView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Kingfisher 11 | 12 | struct OtherSettingsView: View { 13 | @State var sizeM: CGFloat = 0 14 | 15 | var body: some View { 16 | formView 17 | .navBar("其他设置") 18 | .task { 19 | ImageCache.default.calculateDiskStorageSize { result in 20 | switch result { 21 | case .success(let size): 22 | sizeM = CGFloat(size) / 1024 / 1024 23 | log("Disk cache size: \(sizeM)MB") 24 | case .failure(let error): 25 | print(error) 26 | } 27 | } 28 | } 29 | } 30 | 31 | @ViewBuilder 32 | private var formView: some View { 33 | ScrollView { 34 | Button { 35 | ImageCache.default.clearDiskCache { 36 | sizeM = 0 37 | Toast.show("缓存清理完成") 38 | } 39 | } label: { 40 | SectionView("缓存") { 41 | HStack { 42 | let size = String(format: "%.2f", sizeM) 43 | Text("\(size)MB") 44 | .font(.footnote) 45 | .foregroundColor(Color.tintColor) 46 | Image(systemName: "chevron.right") 47 | .font(.body.weight(.regular)) 48 | .foregroundColor(.gray) 49 | .padding(.trailing, 16) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | struct OtherSettingsView_Previews: PreviewProvider { 58 | static var previews: some View { 59 | OtherSettingsView() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /V2er/View/Settings/SettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsPage.swift 3 | // SettingsPage 4 | // 5 | // Created by Seth on 2021/8/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SettingsPage: View { 12 | @Environment(\.dismiss) var dismiss 13 | @State private var showingAlert = false 14 | @State var logingOut: Bool = false 15 | 16 | var body: some View { 17 | formView 18 | .navBar("设置") 19 | } 20 | 21 | @ViewBuilder 22 | private var formView: some View { 23 | ScrollView { 24 | VStack(spacing: 0) { 25 | SectionItemView("通用设置", showDivider: false) 26 | .padding(.top, 8) 27 | .to { OtherSettingsView() } 28 | 29 | SectionItemView("帮助与反馈") 30 | .padding(.top, 8) 31 | .to { 32 | WebBrowserView(url: "https://www.v2ex.com/help") 33 | } 34 | SectionItemView("源码开放") 35 | .to { 36 | WebBrowserView(url: "https://github.com/v2er-app") 37 | } 38 | 39 | SectionView("关于") { 40 | HStack { 41 | Text("版本1.0.0") 42 | .font(.footnote) 43 | .foregroundColor(Color.tintColor) 44 | Image(systemName: "chevron.right") 45 | .font(.body.weight(.regular)) 46 | .foregroundColor(.gray) 47 | .padding(.trailing, 16) 48 | } 49 | } 50 | .to { 51 | WebBrowserView(url: "https://v2er.app") 52 | } 53 | 54 | Button { 55 | // "https://github.com/v2er-app".openURL() 56 | showingAlert = true 57 | } label: { 58 | SectionItemView("账号注销") 59 | .padding(.top, 8) 60 | } 61 | 62 | // Button { 63 | // // go to app store 64 | // } label: { 65 | // SectionItemView("给V2er评分", showDivider: false) 66 | // } 67 | // .hide() 68 | 69 | Button { 70 | // go to app store 71 | withAnimation { 72 | logingOut = true 73 | } 74 | } label: { 75 | SectionItemView("退出登录", showDivider: false) 76 | .foregroundColor(.red) 77 | } 78 | .confirmationDialog( 79 | "登出吗?", 80 | isPresented: $logingOut, 81 | titleVisibility: .visible 82 | ) { 83 | Button("确定", role: .destructive) { 84 | withAnimation { 85 | logingOut = false 86 | } 87 | AccountState.deleteAccount() 88 | Toast.show("已登出") 89 | dismiss() 90 | } 91 | } 92 | 93 | } 94 | .alert(String("账号注销"), isPresented: $showingAlert) { 95 | Button("确定", role: .cancel) { } 96 | } message: { 97 | Text("V2er作为V2EX的第三方客户端无法提供账号注销功能,若你想注销账号可访问V2EX官方网站: https://www.v2ex.com/help, 或联系V2EX团队: support@v2ex.com") 98 | } 99 | } 100 | } 101 | } 102 | 103 | struct SettingsPage_Previews: PreviewProvider { 104 | static var previews: some View { 105 | SettingsPage() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /V2er/View/StateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateView.swift 3 | // StateView 4 | // 5 | // Created by ghui on 2021/8/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | protocol StateView: View { 12 | associatedtype ViewState: FluxState 13 | 14 | var state: ViewState { get } 15 | var bindingState: Binding { get } 16 | } 17 | 18 | extension StateView { 19 | var state: ViewState { 20 | bindingState.raw 21 | } 22 | } 23 | 24 | protocol BasePageView: StateView {} 25 | 26 | protocol BaseHomePageView: BasePageView {} 27 | 28 | extension BaseHomePageView { 29 | func scrollTop(tab: TabId) -> Int { 30 | if Store.shared.appState.globalState.scrollTopTab == tab { 31 | Store.shared.appState.globalState.scrollTopTab = .none 32 | return Int.random(in: 0...Int.max) 33 | } 34 | return 0 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /V2er/View/Syles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // styles.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OvalTextFieldStyle: TextFieldStyle { 12 | func _body(configuration: TextField) -> some View { 13 | let bg = LinearGradient(gradient: Gradient(colors: [Color.orange, Color.orange]), startPoint: .topLeading, endPoint: .bottomTrailing) 14 | configuration 15 | .padding(.horizontal, 10) 16 | .padding(.vertical, 8) 17 | .background(Color.lightGray) 18 | .cornerRadius(20) 19 | .foregroundColor(.bodyText) 20 | } 21 | } 22 | 23 | struct styles_Previews: PreviewProvider { 24 | @State private static var name: String = "" 25 | 26 | static var previews: some View { 27 | TextField("Name1:", text: $name) 28 | .textFieldStyle(OvalTextFieldStyle()) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /V2er/View/WebBrowserView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebBrowserView.swift 3 | // V2er 4 | // 5 | // Created by GARY on 2023/4/1. 6 | // Copyright © 2023 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WebView 11 | 12 | struct WebBrowserView: View { 13 | @StateObject var webViewStore = WebViewStore() 14 | let url: String 15 | 16 | var body: some View { 17 | WebView(webView: webViewStore.webView) 18 | .navigationBarTitle(Text(verbatim: webViewStore.title ?? ""), displayMode: .inline) 19 | .navigationBarItems(trailing: HStack { 20 | Button { 21 | webViewStore.webView.goBack() 22 | } label: { 23 | Image(systemName: "chevron.left") 24 | .imageScale(.large) 25 | .aspectRatio(contentMode: .fit) 26 | .frame(width: 32, height: 32) 27 | } 28 | .disabled(!webViewStore.canGoBack) 29 | 30 | Button { 31 | webViewStore.webView.goForward() 32 | } label: { 33 | Image(systemName: "chevron.right") 34 | .imageScale(.large) 35 | .aspectRatio(contentMode: .fit) 36 | .frame(width: 32, height: 32) 37 | } 38 | .disabled(!webViewStore.canGoForward) 39 | }) 40 | .onAppear { 41 | self.webViewStore.webView.load(URLRequest(url: URL(string: self.url)!)) 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /V2er/View/Widget/AvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AvatarView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Kingfisher 11 | 12 | struct AvatarView: View { 13 | var url: String? = "" 14 | var size: CGFloat = 30.0 15 | 16 | var body: some View { 17 | KFImage.url(URL(string: url ?? .default)) 18 | // .loadDiskFileSynchronously() 19 | .placeholder { Color.lightGray.frame(width: size, height: size) } 20 | .fade(duration: 0.25) 21 | .resizable() 22 | .aspectRatio(contentMode: .fill) 23 | .frame(width: size, height: size) 24 | .cornerBorder() 25 | } 26 | } 27 | 28 | //struct AvatarView_Previews: PreviewProvider { 29 | // static var previews: some View { 30 | // AvatarView(size: 48) 31 | // } 32 | //} 33 | -------------------------------------------------------------------------------- /V2er/View/Widget/FlowStack.swift: -------------------------------------------------------------------------------- 1 | // Forked from https://github.com/globulus/swiftui-flow-layout 2 | // FlowStack.swift 3 | // FlowStack 4 | // 5 | // Created by Seth on 2021/7/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public struct FlowStack: View { 12 | let mode: Mode 13 | let data: [T] 14 | let horizontalSpace: CGFloat 15 | let verticalSpace: CGFloat 16 | let viewMapping: (T) -> V 17 | @State private var totalHeight: CGFloat 18 | 19 | public init(mode: Mode = .scrollable, data: [T], 20 | horizontalSpace: CGFloat = 4, 21 | verticalSpace: CGFloat = 4, 22 | viewMapping: @escaping (T) -> V) { 23 | self.mode = mode 24 | self.horizontalSpace = horizontalSpace 25 | self.verticalSpace = verticalSpace 26 | self.data = data 27 | self.viewMapping = viewMapping 28 | _totalHeight = State(initialValue: (mode == .scrollable) ? .zero : .infinity) 29 | } 30 | 31 | public var body: some View { 32 | let stack = VStack { 33 | GeometryReader { geometry in 34 | self.content(in: geometry) 35 | } 36 | } 37 | return Group { 38 | if mode == .scrollable { 39 | stack.frame(height: totalHeight) 40 | } else { 41 | stack.frame(maxHeight: totalHeight) 42 | } 43 | } 44 | } 45 | 46 | private func content(in geometry: GeometryProxy) -> some View { 47 | var width = CGFloat.zero 48 | var height = CGFloat.zero 49 | return ZStack(alignment: .topLeading) { 50 | ForEach(self.data, id: \.self) { item in 51 | self.viewMapping(item) 52 | .padding(.horizontal, self.horizontalSpace) 53 | .padding(.vertical, self.verticalSpace) 54 | .alignmentGuide(.leading, computeValue: { d in 55 | if (abs(width - d.width) > geometry.size.width) { 56 | width = 0 57 | height -= d.height 58 | } 59 | let result = width 60 | if item == self.data.last { 61 | width = 0 62 | } else { 63 | width -= d.width 64 | } 65 | return result 66 | }) 67 | .alignmentGuide(.top, computeValue: { d in 68 | let result = height 69 | if item == self.data.last { 70 | height = 0 71 | } 72 | return result 73 | }) 74 | } 75 | } 76 | .background(viewHeightReader($totalHeight)) 77 | } 78 | 79 | private func viewHeightReader(_ binding: Binding) -> some View { 80 | return GeometryReader { geo -> Color in 81 | DispatchQueue.main.async { 82 | binding.wrappedValue = geo.frame(in: .local).size.height 83 | } 84 | return .clear 85 | } 86 | } 87 | 88 | public enum Mode { 89 | case scrollable, vstack 90 | } 91 | } 92 | 93 | struct FlowStack_Previews: PreviewProvider { 94 | static var previews: some View { 95 | FlowStack(data: ["问与答1", "问与答2", "99", 96 | "问与答333", "问与答4", "问与答55", 97 | "问与答6", "问与答77"]) 98 | { 99 | Text($0) 100 | .font(.footnote) 101 | .foregroundColor(.black) 102 | .lineLimit(1) 103 | .padding(.horizontal, 14) 104 | .padding(.vertical, 8) 105 | .background(Color.lightGray) 106 | .padding(.horizontal, 5) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /V2er/View/Widget/NodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/10. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NodeView: View { 12 | let id: String 13 | let name: String 14 | let img: String? 15 | 16 | init(id: String, name: String, img: String = .empty) { 17 | self.id = id 18 | self.name = name 19 | self.img = img 20 | } 21 | 22 | var body: some View { 23 | NavigationLink { 24 | TagDetailPage(tagId: id) 25 | } label: { 26 | Text(name) 27 | .font(.footnote) 28 | .foregroundColor(.black) 29 | .lineLimit(1) 30 | .padding(.horizontal, 14) 31 | .padding(.vertical, 8) 32 | .background(Color.lightGray) 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /V2er/View/Widget/RichText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HtmlText.swift 3 | // HtmlText 4 | // 5 | // Created by ghui on 2021/9/6. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct RichText: UIViewRepresentable { 13 | let html: String 14 | 15 | init(_ html: String) { 16 | self.html = html 17 | } 18 | 19 | func makeUIView(context: UIViewRepresentableContext) -> UILabel { 20 | let label = UILabel() 21 | DispatchQueue.main.async { 22 | let data = Data(self.html.utf8) 23 | if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) { 24 | label.attributedText = attributedString 25 | } 26 | } 27 | return label 28 | } 29 | 30 | func updateUIView(_ uiView: UILabel, context: Context) {} 31 | } 32 | -------------------------------------------------------------------------------- /V2er/View/Widget/RichText/TestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/28. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TestView: View { 12 | var body: some View { 13 | ScrollView { 14 | VStack { 15 | SectionItemView("Show Toast").onTapGesture { 16 | Toast.show("网络错误") 17 | } 18 | } 19 | } 20 | } 21 | } 22 | 23 | struct TestView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | TestView() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /V2er/View/Widget/RichTextView/Enums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by 이웅재 on 2021/07/26. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum colorScheme: String { 11 | case light = "light" 12 | case dark = "dark" 13 | case automatic = "automatic" 14 | } 15 | 16 | public enum fontType : String { 17 | case `default` = "default" 18 | case monospaced = "monospaced" 19 | case italic = "italic" 20 | } 21 | 22 | public enum linkOpenType: String { 23 | case SFSafariView = "SFSafariView" 24 | case Safari = "Safari" 25 | case none = "none" 26 | } 27 | -------------------------------------------------------------------------------- /V2er/View/Widget/RichTextView/Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension.swift 3 | // test 4 | // 5 | // Created by 이웅재 on 2021/08/27. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichText { 11 | 12 | public func lineHeight(_ lineHeight: CGFloat) -> RichText { 13 | var result = self 14 | 15 | result.lineHeight = lineHeight 16 | return result 17 | } 18 | 19 | public func imageRadius(_ imageRadius: CGFloat) -> RichText { 20 | var result = self 21 | 22 | result.imageRadius = imageRadius 23 | return result 24 | } 25 | 26 | public func fontType(_ fontType: fontType) -> RichText { 27 | var result = self 28 | 29 | result.fontType = fontType 30 | return result 31 | } 32 | 33 | public func colorScheme(_ colorScheme: colorScheme) -> RichText { 34 | var result = self 35 | 36 | result.colorScheme = colorScheme 37 | return result 38 | } 39 | 40 | public func colorImportant(_ colorImportant: Bool) -> RichText { 41 | var result = self 42 | 43 | result.colorImportant = colorImportant 44 | return result 45 | } 46 | 47 | public func placeholder(@ViewBuilder content: () -> T) -> RichText where T : View { 48 | var result = self 49 | 50 | result.placeholder = AnyView(content()) 51 | return result 52 | } 53 | 54 | public func linkOpenType(_ linkOpenType: linkOpenType) -> RichText { 55 | var result = self 56 | 57 | result.linkOpenType = linkOpenType 58 | return result 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /V2er/View/Widget/RichTextView/RichText.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct RichText: View { 4 | @State private var dynamicHeight : CGFloat = .zero 5 | 6 | let html : String 7 | 8 | var lineHeight : CGFloat = 170 9 | var imageRadius : CGFloat = 0 10 | var fontType : fontType = .default 11 | 12 | var colorScheme : colorScheme = .automatic 13 | var colorImportant : Bool = false 14 | 15 | var placeholder: AnyView? 16 | 17 | var linkOpenType : linkOpenType = .SFSafariView 18 | 19 | public init(html: String) { 20 | self.html = html 21 | } 22 | 23 | public var body: some View { 24 | ZStack(alignment: .top){ 25 | Webview(dynamicHeight: $dynamicHeight, html: html, lineHeight: lineHeight, imageRadius: imageRadius,colorScheme: colorScheme,colorImportant: colorImportant,linkOpenType: linkOpenType) 26 | .frame(height: dynamicHeight) 27 | 28 | if self.dynamicHeight == 0 { 29 | placeholder 30 | } 31 | } 32 | } 33 | } 34 | 35 | 36 | struct RichText_Previews: PreviewProvider { 37 | static var previews: some View { 38 | RichText(html: "") 39 | } 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /V2er/View/Widget/SectionItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionItemView.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/10/14. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | fileprivate let paddingH: CGFloat = 15 12 | 13 | struct SectionItemView: View { 14 | let title: String 15 | let icon: String 16 | var showDivider: Bool = true 17 | 18 | init(_ title: String, 19 | icon: String = .empty, 20 | showDivider: Bool = true) { 21 | self.title = title 22 | self.icon = icon 23 | self.showDivider = showDivider 24 | } 25 | 26 | var body: some View { 27 | SectionView(title, icon: icon, showDivider: showDivider) { 28 | Image(systemName: "chevron.right") 29 | .font(.body.weight(.regular)) 30 | .foregroundColor(.gray) 31 | .padding(.trailing, paddingH) 32 | } 33 | } 34 | } 35 | 36 | struct SectionView: View { 37 | let content: Content 38 | let title: String 39 | var showDivider: Bool = true 40 | let icon: String 41 | 42 | init(_ title: String, 43 | icon: String = .empty, 44 | showDivider: Bool = true, 45 | @ViewBuilder content: () -> Content) { 46 | self.title = title 47 | self.icon = icon 48 | self.showDivider = showDivider 49 | self.content = content() 50 | } 51 | 52 | var body: some View { 53 | HStack { 54 | Image(systemName: icon) 55 | .font(.body.weight(.semibold)) 56 | .padding(.leading, paddingH) 57 | .padding(.trailing, icon.isEmpty ? 0 : 5) 58 | .foregroundColor(.tintColor) 59 | HStack { 60 | Text(title) 61 | Spacer() 62 | content 63 | .padding(.trailing, paddingH) 64 | } 65 | .padding(.vertical, 17) 66 | .divider(showDivider ? 0.8 : 0.0) 67 | } 68 | .background(.white) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /V2er/View/Widget/SectionTitleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionTitleView.swift 3 | // SectionTitleView 4 | // 5 | // Created by Seth on 2021/7/27. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SectionTitleView: View { 12 | var title: String = "Title" 13 | var style: Style 14 | enum Style { 15 | case normal 16 | case small 17 | } 18 | 19 | public init(_ title: String, style: Style = .normal) { 20 | self.title = title 21 | self.style = style 22 | } 23 | 24 | var body: some View { 25 | Text(title) 26 | .font(style == .normal ? .headline : .subheadline) 27 | .fontWeight(.heavy) 28 | .foregroundColor(.bodyText) 29 | .padding(.vertical, 8) 30 | .padding(.horizontal, style == .normal ? 2 : 8) 31 | .background { 32 | if style == .small { 33 | HStack (spacing: 0) { 34 | RoundedRectangle(cornerRadius: 99) 35 | .foregroundColor(.tintColor.opacity(0.9)) 36 | .padding(.vertical, 8) 37 | .frame(width: 3) 38 | Spacer() 39 | } 40 | } 41 | } 42 | .greedyWidth(.leading) 43 | .debug() 44 | } 45 | } 46 | 47 | struct SectionTitleView_Previews: PreviewProvider { 48 | static var previews: some View { 49 | SectionTitleView("Title") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /V2er/View/Widget/Toast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toast.swift 3 | // V2er 4 | // 5 | // Created by ghui on 2021/11/11. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | final class Toast { 12 | var isPresented: Bool = false 13 | var title: String = "" 14 | var icon: String = "" 15 | 16 | static func show(_ title: String, icon: String = .empty, target: Reducer = .global) { 17 | guard title.notEmpty() || icon.notEmpty() else { return } 18 | dispatch(ShowToastAction(target: target, title: title, icon: icon), .default) 19 | } 20 | 21 | static func show(_ error: APIError, target: Reducer = .global) { 22 | let title: String 23 | switch error { 24 | case .noResponse: 25 | title = "未返回数据" 26 | case .decodingError: 27 | title = "解析数据出错" 28 | case .networkError: 29 | title = "网络出错" 30 | case .invalid: 31 | title = "返回数据非法" 32 | case .generalError: 33 | title = .empty 34 | default: 35 | title = "未知错误" 36 | } 37 | show(title, target: target) 38 | } 39 | } 40 | 41 | struct DefaultToastView: View { 42 | var title: String 43 | var icon: String = .empty 44 | 45 | var body: some View { 46 | Label(title, systemImage: icon) 47 | .foregroundColor(.bodyText) 48 | .padding(.horizontal, 20) 49 | .padding(.vertical, 12) 50 | } 51 | } 52 | 53 | extension View { 54 | func toast(isPresented: Binding, 55 | paddingTop: CGFloat = 0, 56 | @ViewBuilder content: () -> Content?) -> some View { 57 | ZStack(alignment: .top) { 58 | self 59 | if isPresented.wrappedValue { 60 | content() 61 | .visualBlur(bg: .white.opacity(0.95)) 62 | .cornerRadius(99) 63 | .shadow(color: .black.opacity(0.2), radius: 1.5) 64 | .padding(.top, paddingTop) 65 | .transition(AnyTransition.move(edge: .top)) 66 | .zIndex(1) 67 | .onAppear { 68 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 69 | withAnimation { 70 | isPresented.wrappedValue = false 71 | } 72 | } 73 | } 74 | .onTapGesture { 75 | withAnimation { 76 | isPresented.wrappedValue = false 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | struct ToastView_Previews: PreviewProvider { 85 | @State static var showToast: Bool = true 86 | static var previews: some View { 87 | VStack { 88 | Spacer() 89 | Button { 90 | showToast.toggle() 91 | } label: { 92 | Text("Show/Hide") 93 | .padding() 94 | .greedyWidth() 95 | } 96 | } 97 | .background(.yellow) 98 | .greedyFrame() 99 | .ignoresSafeArea(.all) 100 | .toast(isPresented: $showToast) { 101 | DefaultToastView(title: "网络错误") 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /V2er/View/Widget/TopBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopBar.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/6/24. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TopBar: View { 12 | var selectedTab : TabId 13 | 14 | private var isHomePage: Bool { 15 | return selectedTab == .feed 16 | } 17 | 18 | private var title: String { 19 | switch selectedTab { 20 | case .feed: 21 | return "V2EX" 22 | case .explore: 23 | return "发现" 24 | case .message: 25 | return "通知" 26 | case .me: 27 | return "我" 28 | case .none: 29 | return .empty 30 | } 31 | } 32 | 33 | var body: some View { 34 | VStack(spacing: 0) { 35 | ZStack { 36 | HStack { 37 | Image(systemName: "square.grid.2x2") 38 | .foregroundColor(.primary) 39 | .font(.system(size: 22)) 40 | .padding(6) 41 | .forceClickable() 42 | .hide() 43 | // .to { TestView() } 44 | Spacer() 45 | Image(systemName: "magnifyingglass") 46 | .foregroundColor(.primary) 47 | .font(.system(size: 22)) 48 | .padding(6) 49 | .forceClickable() 50 | .to { SearchPage() } 51 | } 52 | .padding(.horizontal, 10) 53 | .padding(.vertical, 8) 54 | Text(title) 55 | .font(isHomePage ? .title2 : .headline) 56 | .foregroundColor(.primary) 57 | .fontWeight(isHomePage ? .heavy : .bold) 58 | } 59 | .padding(.top, topSafeAreaInset().top) 60 | .background(VEBlur()) 61 | 62 | Divider() 63 | .light() 64 | } 65 | .readSize { 66 | print("size: \($0))") 67 | } 68 | } 69 | } 70 | 71 | struct TopBar_Previews: PreviewProvider { 72 | // @State static var selecedTab = TabId.feed 73 | static var selecedTab = TabId.explore 74 | 75 | static var previews: some View { 76 | VStack { 77 | TopBar(selectedTab: selecedTab) 78 | Spacer() 79 | } 80 | .ignoresSafeArea(.container) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /V2er/View/Widget/Updatable/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/6/30. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | struct ActivityIndicator: UIViewRepresentable { 13 | typealias UIViewType = UIActivityIndicatorView 14 | 15 | func makeUIView(context: Context) -> UIActivityIndicatorView { 16 | return UIActivityIndicatorView() 17 | } 18 | 19 | func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) { 20 | uiView.startAnimating() 21 | } 22 | 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /V2er/View/Widget/Updatable/HeadIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeadView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/6/25. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HeadIndicatorView: View { 12 | let height: CGFloat 13 | var scrollY: CGFloat 14 | @Binding var progress: CGFloat 15 | @Binding var isRefreshing: Bool 16 | 17 | var offset: CGFloat { 18 | return isRefreshing ? (0 - scrollY) : -height 19 | } 20 | 21 | init(threshold: CGFloat, progress: Binding, scrollY: CGFloat,isRefreshing: Binding) { 22 | self.height = threshold 23 | self.scrollY = scrollY 24 | self._progress = progress 25 | self._isRefreshing = isRefreshing 26 | } 27 | 28 | var body: some View { 29 | Group { 30 | if progress == 1 || isRefreshing { 31 | ActivityIndicator() 32 | } else { 33 | Image(systemName: "arrow.down") 34 | .font(.title2.weight(.regular)) 35 | } 36 | } 37 | .frame(height: height) 38 | .offset(y: offset) 39 | 40 | } 41 | } 42 | 43 | struct HeadView_Previews: PreviewProvider { 44 | @State static var progress: CGFloat = 0.1 45 | @State static var isRefreshing = false 46 | 47 | static var previews: some View { 48 | HeadIndicatorView(threshold: 80, progress: $progress, scrollY: 0, 49 | isRefreshing: $isRefreshing) 50 | // .border(.red) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /V2er/View/Widget/Updatable/Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helper.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/4. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | public typealias RefreshAction = (() async-> Void)? 13 | public typealias LoadMoreAction = (() async-> Void)? 14 | public typealias ScrollAction = (CGFloat)->Void 15 | -------------------------------------------------------------------------------- /V2er/View/Widget/Updatable/LoadmoreIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadmoreIndicatorView.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2021/7/3. 6 | // Copyright © 2021 lessmore.io. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LoadmoreIndicatorView: View { 12 | var isLoading: Bool 13 | var hasMoreData: Bool 14 | 15 | init(isLoading: Bool, hasMoreData: Bool) { 16 | self.isLoading = isLoading 17 | self.hasMoreData = hasMoreData 18 | } 19 | 20 | var body: some View { 21 | Group { 22 | if !hasMoreData { 23 | Text("No more data") 24 | .font(.callout) 25 | } else if isLoading { 26 | ActivityIndicator() 27 | } else { 28 | // hide 29 | } 30 | } 31 | .padding() 32 | } 33 | } 34 | 35 | struct LoadmoreIndicatorView_Previews: PreviewProvider { 36 | static var isloading = true 37 | static var hasMoreData = true 38 | 39 | static var previews: some View { 40 | LoadmoreIndicatorView(isLoading: isloading, hasMoreData: hasMoreData) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /V2er/View/Widget/VEBlur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectBlur.swift 3 | // V2er 4 | // 5 | // Created by Seth on 2020/6/15. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | //import Foundation 10 | import SwiftUI 11 | 12 | struct VEBlur: UIViewRepresentable { 13 | var style: UIBlurEffect.Style = .systemUltraThinMaterial 14 | var bg: Color = .clear 15 | 16 | func makeUIView(context: Context) -> UIVisualEffectView { 17 | let effectView = UIVisualEffectView(effect: UIBlurEffect(style: style)) 18 | effectView.backgroundColor = bg.uiColor 19 | return effectView 20 | } 21 | 22 | func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 23 | uiView.effect = UIBlurEffect(style: style) 24 | } 25 | 26 | // mark: bug here 27 | // var blurStyle: UIBlurEffect.Style = .systemThinMaterial 28 | // var vibrancyStyle: UIVibrancyEffectStyle = .label 29 | // 30 | // func makeUIView(context: Context) -> UIVisualEffectView { 31 | // let effect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: blurStyle), style: vibrancyStyle) 32 | // let effectView = UIVisualEffectView(effect: effect) 33 | // return effectView 34 | // } 35 | // 36 | // func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 37 | // uiView.effect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: blurStyle), style: vibrancyStyle) 38 | // } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /V2er/www/email.js: -------------------------------------------------------------------------------- 1 | function decodeEmail(){"use strict";function e(e){try{if("undefined"==typeof console)return;"error"in console?console.error(e):console.log(e)}catch(e){}}function t(e){return d.innerHTML='',d.childNodes[0].getAttribute("href")||""}function r(e,t){var r=e.substr(t,2);return parseInt(r,16)}function n(n,c){for(var o="",a=r(n,c),i=c+2;i-1&&(o.href="mailto:"+n(o.href,a+l.length))}catch(i){e(i)}}function o(t){for(var r=t.querySelectorAll(u),c=0;c1. V2er是什么? 2 |
3 |

V2er是一个诞生于2017年6月30日的第三方V2EX客户端,V2er与www.v2ex.com社区无其它关系

4 |
5 |

2. 为什么开发V2er?

6 |
7 |

初衷是由于V2EX社区未提供官方APP,而且翻遍应用市场也无法找到一款满意的第三方客户端,自己开发的想法诞生于2016年中,当时自己还发了一篇[帖子](推荐一款好用的 V2EX android 客户端 - V2EX)

8 |
9 |

3. V2EX是什么?

10 |
11 |

V2EX 是创意工作者们的社区,是一个专业的技术社区。如果你是V2EX新用户,建议阅读一下About V2EXV2EX官方FAQ

12 |
13 |

4. 为什么我无法回复或发帖?

14 |
15 |

a. 新注册用户无发帖与回复等权限

16 |

b. 注册邮箱未验证

17 |

c. 账户被V2EX限制

18 |

d. 异常访问导致IP被封

19 |

e. 发帖与回复均需要消耗金币,请确认自己有足够的金币

20 |

f. 其它类似的原因

21 |
22 |

5. 打开帖子、回复帖子等直接跳转回首页?

23 |
24 |

少数帖子或节点只有部分老用户有权限查看

25 |
26 |

6. APP网络经常加载失败?

27 |
28 |

V2EX社区经常会出现302、DNS等网络错误,这种错误不是V2er本身可以解决的,出现类似问题时,建议换个时间再来

29 |
30 |

7. 回复框被NavigationBar遮挡?

31 |
32 |

部分手机可能会出现此问题,可以去设置-外观设置中开启相关选项解决

33 |
34 |

8. 点击购买无反应?

35 |
36 |

部分国产手机默认会禁止Google Play商店app在在别的应用上显示的权限,可以去系统设置-应用-Google Play商店中去开启

37 |
38 |

9. 除了Google Play外有无其它购买渠道?

39 |
40 |

无,建议可以去购买Google Play礼品卡

41 |
42 |

10. V2er有iOS版吗?

43 |
44 |

暂无,但计划开发iOS版。另V2er发布之后,苹果App Store上出现了一款同名应用非本人开发,请知悉

45 |
46 |

11. V2er代码开源吗?

47 |
48 |

V2er核心代码已于2017年10月21在Github开源,点击这里获取

49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /V2er/www/v2er.css: -------------------------------------------------------------------------------- 1 | html, body, div, p { 2 | margin: 0; 3 | padding: 0; 4 | /* font-family: "Helvetica Neue", "Luxi Sans", "DejaVu Sans", Tahoma, "Hiragino Sans GB", "Microsoft Yahei", sans-serif;*/ 5 | } 6 | 7 | body { 8 | background:transparent; 9 | } 10 | 11 | a { 12 | color:#778087 13 | /* color: #60c2d4*/ 14 | } 15 | 16 | img { 17 | display: inline; 18 | height: auto; 19 | max-width: 100%; 20 | padding: 6px; 21 | border-radius: 8px; 22 | } 23 | 24 | .topic_content{ 25 | color:#000000; 26 | word-wrap:break-word; 27 | padding-top: 5px; 28 | padding-bottom: 5px; 29 | } 30 | 31 | .dark .topic_content{ 32 | background:#111214; 33 | color:#7F8082; 34 | } 35 | 36 | div.subtle { 37 | border-left: 3px solid rgba(126, 126, 126, 0.5); 38 | border-top: 0.4px solid rgba(126, 126, 126, 0.2); 39 | background-color: rgba(250, 250, 250, 0.5); 40 | padding: 5px; 41 | line-height: 120%; 42 | text-align: left; 43 | } 44 | 45 | .dark div.subtle { 46 | background:#08090b; 47 | } 48 | 49 | blockquote { 50 | color:#555555; 51 | border-left: 3px solid rgba(126, 126, 126, 0.5); 52 | background-color: rgba(250, 250, 250, 0.5); 53 | padding: 5px; 54 | line-height: 120%; 55 | text-align: left; 56 | } 57 | 58 | .dark blockquote { 59 | background:#08090b; 60 | color:#7F8082; 61 | } 62 | 63 | div.subtle blockquote { 64 | border-left: 0px solid rgba(126, 126, 126, 0.5); 65 | display: block; 66 | } 67 | 68 | .dark div.subtle blockquote { 69 | background:#08090b; 70 | } 71 | 72 | .dark div.subtle .topic_content { 73 | background:#08090b; 74 | } 75 | 76 | div.subtle span.fade { 77 | opacity: 0.8; 78 | color: #ccc; 79 | font-size: 12px; 80 | } 81 | 82 | .embedded_video { 83 | top: 0; 84 | left: 0; 85 | width: 100%; 86 | height: 100%; 87 | min-height: 200px 88 | } 89 | 90 | pre { 91 | border: none; 92 | border-radius: 0; 93 | padding: 0; 94 | } 95 | 96 | ::selection { 97 | background: #bbbbbb; 98 | } 99 | 100 | code { 101 | font-size: 80%; 102 | } 103 | 104 | .dark code { 105 | border-style: solid; 106 | border-color: rgba(126, 126, 126, 0.5); 107 | color: #7F8082; 108 | background: none; 109 | } 110 | 111 | hr { 112 | border-top: 0.8px solid #f4f2f2; 113 | } 114 | 115 | .dark hr { 116 | border-top: 0.8px solid #202020; 117 | } 118 | -------------------------------------------------------------------------------- /V2er/www/v2er.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {injecttedContent} 16 | 17 | 18 | -------------------------------------------------------------------------------- /V2er/www/v2er.js: -------------------------------------------------------------------------------- 1 | function addClickToImg() { 2 | console.error('-----addClickToImg-----'); 3 | var imgs = document.getElementsByTagName("img"); 4 | var urls = new Array(); 5 | for (var i = 0; i < imgs.length; i++) { 6 | const index = i; 7 | urls[i] = imgs[i].getAttribute('original_src'); 8 | imgs[i].onclick = function () { 9 | // window.imagelistener.openImage(index, urls); 10 | const result = { 11 | "index": index, 12 | "imgs": urls 13 | } 14 | window.webkit.messageHandlers.iOSNative.postMessage(result); 15 | }; 16 | } 17 | } 18 | 19 | function reloadImg(url, path) { 20 | // sendConsole("reloadImg from js, url: " + url + ", path: " + path) 21 | var imgs = document.querySelectorAll("*[original_src='" + url + "']"); 22 | for (var i=0; i 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /V2erTests/V2erTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2erTests.swift 3 | // V2erTests 4 | // 5 | // Created by Seth on 2020/5/23. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import V2er 11 | 12 | class V2erTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | 13.5} 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() throws { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /V2erUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /V2erUITests/V2erUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2erUITests.swift 3 | // V2erUITests 4 | // 5 | // Created by Seth on 2020/5/23. 6 | // Copyright © 2020 lessmore.io. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class V2erUITests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use recording to get started writing UI tests. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pravicy.md: -------------------------------------------------------------------------------- 1 | 隐私政策网址(URL) 2 | 本软件尊重并保护所有使用服务用户的个人隐私权。为了给您提供更准确、更有个性化的服务,本软件会按照本隐私权政策的规定使用和披露您的个人信息。但本软件将以高度的勤勉、审慎义务对待这些信息。除本隐私权政策另有规定外,在未征得您事先许可的情况下,本软件不会将这些信息对外披露或向第三方提供。本软件会不时更新本隐私权政策。您在同意本软件服务使用协议之时,即视为您已经同意本隐私权政策全部内容。本隐私权政策属于本软件服务使用协议不可分割的一部分。 3 | 4 | 1.适用范围 5 | 6 | a)在您使用本软件网络服务,本软件自动接收并记录的您的手机上的信息,包括但不限于您的健康数据、使用的语言、访问日期和时间、软硬件特征信息及您需求的网页记录等数据; 7 | 8 | 2.信息的使用 9 | 10 | a)在获得您的数据之后,本软件会将其上传至服务器,以生成您的排行榜数据,以便您能够更好地使用服务。 11 | 12 | 3.信息披露 13 | 14 | a)本软件不会将您的信息披露给不受信任的第三方。 15 | 16 | b)根据法律的有关规定,或者行政或司法机构的要求,向第三方或者行政、司法机构披露; 17 | 18 | c)如您出现违反中国有关法律、法规或者相关规则的情况,需要向第三方披露; 19 | 20 | 4.信息存储和交换 21 | 22 | 本软件收集的有关您的信息和资料将保存在本软件及(或)其关联公司的服务器上,这些信息和资料可能传送至您所在国家、地区或本软件收集信息和资料所在地的境外并在境外被访问、存储和展示。 23 | 24 | 5.信息安全 25 | 26 | a)在使用本软件网络服务进行网上交易时,您不可避免的要向交易对方或潜在的交易对方披露自己的个人信息,如联络方式或者邮政地址。请您妥善保护自己的个人信息,仅在必要的情形下向他人提供。如您发现自己的个人信息泄密,请您立即联络本软件客服,以便本软件采取相应措施。 27 | --------------------------------------------------------------------------------