├── .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 | [](#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 | 
10 |
11 | ## Contributors
12 |
13 |
14 |
15 |
16 |
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 V2EX及V2EX官方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 |
--------------------------------------------------------------------------------