添加登录流程、会话路由和对齐参考 UI 的主 Tab 框架
@ -163,7 +163,6 @@
|
|||||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
@ -200,7 +199,6 @@
|
|||||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
|
|||||||
57
suixinkan/App/AppRouter.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// AppRouter.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 应用根页面路由。
|
||||||
|
enum AppRouter {
|
||||||
|
|
||||||
|
enum Root {
|
||||||
|
case login
|
||||||
|
case mainTab
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据登录态创建根控制器:已登录 → 主页 Tab,未登录 → 登录页。
|
||||||
|
static func makeRootViewController() -> UIViewController {
|
||||||
|
makeViewController(for: AppStore.shared.isLoggedIn ? .mainTab : .login)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换窗口根控制器。
|
||||||
|
static func setRoot(_ root: Root, on window: UIWindow?, animated: Bool = true) {
|
||||||
|
setRoot(makeViewController(for: root), on: window, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据当前登录态刷新根控制器。
|
||||||
|
static func refreshRoot(on window: UIWindow?, animated: Bool = true) {
|
||||||
|
setRoot(AppStore.shared.isLoggedIn ? .mainTab : .login, on: window, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeViewController(for root: Root) -> UIViewController {
|
||||||
|
switch root {
|
||||||
|
case .login:
|
||||||
|
return UINavigationController(rootViewController: LoginViewController())
|
||||||
|
case .mainTab:
|
||||||
|
return MainTabBarController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool) {
|
||||||
|
guard let window else { return }
|
||||||
|
|
||||||
|
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
|
||||||
|
window.rootViewController = viewController
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewController.view.addSubview(snapshot)
|
||||||
|
window.rootViewController = viewController
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.25, animations: {
|
||||||
|
snapshot.alpha = 0
|
||||||
|
}, completion: { _ in
|
||||||
|
snapshot.removeFromSuperview()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cb_privacy_selected.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/icon_cb_privacy_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
12
suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cb_privacy_unselect.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/icon_cb_privacy_unselect.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
12
suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_pwd_invisible.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/icon_pwd_invisible.png
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
12
suixinkan/Assets.xcassets/icon_pwd_visible.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_pwd_visible.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/icon_pwd_visible.imageset/icon_pwd_visible.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
23
suixinkan/Assets.xcassets/icon_scan.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_scan.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_scan@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_scan@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan.png
vendored
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 675 B |
BIN
suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1022 B |
12
suixinkan/Assets.xcassets/img_login_bg.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "img_login_bg.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/img_login_bg.imageset/img_login_bg.png
vendored
Normal file
|
After Width: | Height: | Size: 77 KiB |
23
suixinkan/Assets.xcassets/tab_data_selected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_selected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_selected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 908 B |
BIN
suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
23
suixinkan/Assets.xcassets/tab_data_unselected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_unselected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_unselected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_data_unselected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
23
suixinkan/Assets.xcassets/tab_home_selected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_selected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_selected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 867 B |
BIN
suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
23
suixinkan/Assets.xcassets/tab_home_unselected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_unselected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_unselected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_home_unselected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
23
suixinkan/Assets.xcassets/tab_order_selected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_selected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_selected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 733 B |
BIN
suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
23
suixinkan/Assets.xcassets/tab_order_unselected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_unselected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_unselected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_order_unselected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
23
suixinkan/Assets.xcassets/tab_profile_selected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_selected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_selected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 710 B |
BIN
suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
23
suixinkan/Assets.xcassets/tab_profile_unselected.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_unselected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_unselected@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "tab_profile_unselected@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
44
suixinkan/DataStore/AppStore.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// AppStore.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 本地会话存储,与 Android `AppStoreDataSource` 对齐。
|
||||||
|
/// 当前以 Token 是否非空判断登录态。
|
||||||
|
final class AppStore {
|
||||||
|
|
||||||
|
static let shared = AppStore()
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let token = "key_in_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录 Token。
|
||||||
|
var token: String {
|
||||||
|
get { defaults.string(forKey: Key.token) ?? "" }
|
||||||
|
set { defaults.set(newValue, forKey: Key.token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token 非空视为已登录。
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
!token.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存登录 Token。
|
||||||
|
func saveToken(_ token: String) {
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除登录态。
|
||||||
|
func logout() {
|
||||||
|
token = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,8 +15,6 @@
|
|||||||
<string>Default Configuration</string>
|
<string>Default Configuration</string>
|
||||||
<key>UISceneDelegateClassName</key>
|
<key>UISceneDelegateClassName</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
<key>UISceneStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
// SceneDelegate.swift
|
// SceneDelegate.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
// Created by hanqiu on 2026/7/6.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -11,42 +9,57 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
let window = UIWindow(windowScene: windowScene)
|
||||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
window.rootViewController = AppRouter.makeRootViewController()
|
||||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
window.makeKeyAndVisible()
|
||||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
self.window = window
|
||||||
guard let _ = (scene as? UIWindowScene) else { return }
|
|
||||||
|
registerNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
// Called as the scene is being released by the system.
|
NotificationCenter.default.removeObserver(self)
|
||||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
|
||||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
|
||||||
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
private func registerNotifications() {
|
||||||
// Called when the scene has moved from an inactive state to an active state.
|
NotificationCenter.default.addObserver(
|
||||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
self,
|
||||||
|
selector: #selector(handleSessionDidExpire),
|
||||||
|
name: NotificationName.sessionDidExpire,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleUserDidLogout),
|
||||||
|
name: NotificationName.userDidLogout,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleUserDidLogin),
|
||||||
|
name: NotificationName.userDidLogin,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
@objc private func handleSessionDidExpire() {
|
||||||
// Called when the scene will move from an active state to an inactive state.
|
AppStore.shared.logout()
|
||||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
AppRouter.setRoot(.login, on: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
@objc private func handleUserDidLogout() {
|
||||||
// Called as the scene transitions from the background to the foreground.
|
AppStore.shared.logout()
|
||||||
// Use this method to undo the changes made on entering the background.
|
AppRouter.setRoot(.login, on: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
@objc private func handleUserDidLogin() {
|
||||||
// Called as the scene transitions from the foreground to the background.
|
AppRouter.setRoot(.mainTab, on: window)
|
||||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
|
||||||
// to restore the scene back to its current state.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
suixinkan/Theme/AppColor.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// AppColor.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 应用主题色,与 Android `theme/Color.kt` 对齐。
|
||||||
|
enum AppColor {
|
||||||
|
|
||||||
|
static let primary = UIColor(red: 0 / 255, green: 115 / 255, blue: 255 / 255, alpha: 1)
|
||||||
|
static let text333 = UIColor(red: 51 / 255, green: 51 / 255, blue: 51 / 255, alpha: 1)
|
||||||
|
static let text666 = UIColor(red: 102 / 255, green: 102 / 255, blue: 102 / 255, alpha: 1)
|
||||||
|
static let agreementLink = UIColor(red: 32 / 255, green: 139 / 255, blue: 255 / 255, alpha: 1)
|
||||||
|
static let inputBackground = UIColor(red: 244 / 255, green: 244 / 255, blue: 244 / 255, alpha: 1)
|
||||||
|
static let secondaryButtonBackground = UIColor(red: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
|
||||||
|
static let buttonDisabled = UIColor(red: 192 / 255, green: 192 / 255, blue: 192 / 255, alpha: 1)
|
||||||
|
static let placeholder = UIColor.gray
|
||||||
|
}
|
||||||
19
suixinkan/Theme/UIColor+Hex.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// UIColor+Hex.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
|
||||||
|
/// 通过 0xRRGGBB 和透明度创建 UIColor。
|
||||||
|
convenience init(hex: UInt, alpha: CGFloat = 1.0) {
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((hex >> 16) & 0xff) / 255.0,
|
||||||
|
green: CGFloat((hex >> 8) & 0xff) / 255.0,
|
||||||
|
blue: CGFloat(hex & 0xff) / 255.0,
|
||||||
|
alpha: alpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
suixinkan/UI/Common/AgreementRowView.swift
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// AgreementRowView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 协议勾选行,对应 Android `AgreementRow`。
|
||||||
|
final class AgreementRowView: UIView, UITextViewDelegate {
|
||||||
|
|
||||||
|
private let checkButton = UIButton(type: .custom)
|
||||||
|
private let textView = UITextView()
|
||||||
|
|
||||||
|
var isChecked = false {
|
||||||
|
didSet { updateCheckboxImage() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var onCheckedChange: (() -> Void)?
|
||||||
|
var onUserAgreementTap: (() -> Void)?
|
||||||
|
var onPrivacyPolicyTap: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
checkButton.addTarget(self, action: #selector(checkButtonTapped), for: .touchUpInside)
|
||||||
|
updateCheckboxImage()
|
||||||
|
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.isScrollEnabled = false
|
||||||
|
textView.textContainerInset = .zero
|
||||||
|
textView.textContainer.lineFragmentPadding = 0
|
||||||
|
textView.delegate = self
|
||||||
|
textView.linkTextAttributes = [
|
||||||
|
.foregroundColor: AppColor.agreementLink,
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .semibold),
|
||||||
|
]
|
||||||
|
textView.attributedText = makeAgreementText()
|
||||||
|
|
||||||
|
addSubview(checkButton)
|
||||||
|
addSubview(textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
checkButton.snp.makeConstraints { make in
|
||||||
|
make.leading.top.equalToSuperview()
|
||||||
|
make.size.equalTo(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(checkButton.snp.trailing).offset(8)
|
||||||
|
make.trailing.top.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCheckboxImage() {
|
||||||
|
let imageName = isChecked ? "icon_cb_privacy_selected" : "icon_cb_privacy_unselect"
|
||||||
|
checkButton.setImage(UIImage(named: imageName), for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAgreementText() -> NSAttributedString {
|
||||||
|
let result = NSMutableAttributedString()
|
||||||
|
let normalAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: 12),
|
||||||
|
.foregroundColor: AppColor.text666,
|
||||||
|
]
|
||||||
|
let linkFont = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
|
||||||
|
result.append(NSAttributedString(string: "已阅读并同意", attributes: normalAttributes))
|
||||||
|
result.append(NSAttributedString(string: "《用户协议》", attributes: [
|
||||||
|
.font: linkFont,
|
||||||
|
.foregroundColor: AppColor.agreementLink,
|
||||||
|
.link: "tos",
|
||||||
|
]))
|
||||||
|
result.append(NSAttributedString(string: "与", attributes: normalAttributes))
|
||||||
|
result.append(NSAttributedString(string: "《隐私政策》", attributes: [
|
||||||
|
.font: linkFont,
|
||||||
|
.foregroundColor: AppColor.agreementLink,
|
||||||
|
.link: "privacy",
|
||||||
|
]))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func checkButtonTapped() {
|
||||||
|
onCheckedChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(
|
||||||
|
_ textView: UITextView,
|
||||||
|
shouldInteractWith URL: URL,
|
||||||
|
in characterRange: NSRange,
|
||||||
|
interaction: UITextItemInteraction
|
||||||
|
) -> Bool {
|
||||||
|
switch URL.absoluteString {
|
||||||
|
case "tos":
|
||||||
|
onUserAgreementTap?()
|
||||||
|
case "privacy":
|
||||||
|
onPrivacyPolicyTap?()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
59
suixinkan/UI/Common/AppButton.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// AppButton.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 主按钮,对应 Android `AppButton`。
|
||||||
|
final class AppButton: UIButton {
|
||||||
|
|
||||||
|
enum Style {
|
||||||
|
case primary
|
||||||
|
case secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private let style: Style
|
||||||
|
|
||||||
|
init(title: String, style: Style = .primary) {
|
||||||
|
self.style = style
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI(title: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isEnabled: Bool {
|
||||||
|
didSet { updateAppearance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI(title: String) {
|
||||||
|
titleLabel?.font = .systemFont(ofSize: 14, weight: .regular)
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
clipsToBounds = true
|
||||||
|
setTitle(title, for: .normal)
|
||||||
|
updateAppearance()
|
||||||
|
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(46)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAppearance() {
|
||||||
|
switch style {
|
||||||
|
case .primary:
|
||||||
|
backgroundColor = isEnabled ? AppColor.primary : AppColor.buttonDisabled
|
||||||
|
setTitleColor(.white, for: .normal)
|
||||||
|
setTitleColor(.white, for: .disabled)
|
||||||
|
case .secondary:
|
||||||
|
backgroundColor = AppColor.secondaryButtonBackground
|
||||||
|
setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
setTitleColor(AppColor.primary, for: .disabled)
|
||||||
|
alpha = isEnabled ? 1 : 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
suixinkan/UI/Common/LoginTextField.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// LoginTextField.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 登录页账号输入框,对应 Android `PhoneInputField`。
|
||||||
|
final class LoginTextField: UIView {
|
||||||
|
|
||||||
|
private let textField = UITextField()
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
get { textField.text ?? "" }
|
||||||
|
set { textField.text = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var onTextChange: ((String) -> Void)?
|
||||||
|
var onReturnKey: (() -> Void)?
|
||||||
|
|
||||||
|
init(placeholder: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI(placeholder: placeholder)
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
textField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI(placeholder: String) {
|
||||||
|
backgroundColor = AppColor.inputBackground
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
clipsToBounds = true
|
||||||
|
|
||||||
|
textField.font = .systemFont(ofSize: 14)
|
||||||
|
textField.textColor = AppColor.text333
|
||||||
|
textField.autocapitalizationType = .none
|
||||||
|
textField.autocorrectionType = .no
|
||||||
|
textField.returnKeyType = .next
|
||||||
|
textField.clearButtonMode = .whileEditing
|
||||||
|
textField.delegate = self
|
||||||
|
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: AppColor.placeholder,
|
||||||
|
.font: UIFont.systemFont(ofSize: 14),
|
||||||
|
]
|
||||||
|
textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes)
|
||||||
|
|
||||||
|
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
|
||||||
|
addSubview(textField)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(46)
|
||||||
|
}
|
||||||
|
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textDidChange() {
|
||||||
|
onTextChange?(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LoginTextField: UITextFieldDelegate {
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
onReturnKey?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
104
suixinkan/UI/Common/PasswordInputField.swift
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// PasswordInputField.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 登录页密码输入框,对应 Android `PasswordInputField`。
|
||||||
|
final class PasswordInputField: UIView {
|
||||||
|
|
||||||
|
private let textField = UITextField()
|
||||||
|
private let toggleButton = UIButton(type: .custom)
|
||||||
|
|
||||||
|
private var isPasswordVisible = false
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
get { textField.text ?? "" }
|
||||||
|
set { textField.text = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var onTextChange: ((String) -> Void)?
|
||||||
|
var onReturnKey: (() -> Void)?
|
||||||
|
|
||||||
|
init(placeholder: String = "请输入密码") {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI(placeholder: placeholder)
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
textField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI(placeholder: String) {
|
||||||
|
backgroundColor = AppColor.inputBackground
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
clipsToBounds = true
|
||||||
|
|
||||||
|
textField.font = .systemFont(ofSize: 14)
|
||||||
|
textField.textColor = AppColor.text333
|
||||||
|
textField.isSecureTextEntry = true
|
||||||
|
textField.autocapitalizationType = .none
|
||||||
|
textField.autocorrectionType = .no
|
||||||
|
textField.returnKeyType = .done
|
||||||
|
textField.delegate = self
|
||||||
|
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: AppColor.placeholder,
|
||||||
|
.font: UIFont.systemFont(ofSize: 14),
|
||||||
|
]
|
||||||
|
textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes)
|
||||||
|
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
|
||||||
|
|
||||||
|
toggleButton.setImage(UIImage(named: "icon_pwd_invisible"), for: .normal)
|
||||||
|
toggleButton.addTarget(self, action: #selector(togglePasswordVisibility), for: .touchUpInside)
|
||||||
|
|
||||||
|
addSubview(textField)
|
||||||
|
addSubview(toggleButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(46)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(8)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
make.trailing.equalTo(toggleButton.snp.leading).offset(-4)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textDidChange() {
|
||||||
|
onTextChange?(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func togglePasswordVisibility() {
|
||||||
|
isPasswordVisible.toggle()
|
||||||
|
textField.isSecureTextEntry = !isPasswordVisible
|
||||||
|
let imageName = isPasswordVisible ? "icon_pwd_visible" : "icon_pwd_invisible"
|
||||||
|
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PasswordInputField: UITextFieldDelegate {
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
onReturnKey?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
32
suixinkan/UI/Home/HomeViewController.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// HomeViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 首页 Tab 根页面。
|
||||||
|
final class HomeViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||||
|
titleLabel.text = "首页"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.textColor = AppColor.text333
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
suixinkan/UI/Login/LoginViewController.swift
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
//
|
||||||
|
// LoginViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 登录页,UI 严格对齐 Android `LoginScreen`。
|
||||||
|
final class LoginViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let viewModel = LoginViewModel()
|
||||||
|
|
||||||
|
private let backgroundImageView = UIImageView()
|
||||||
|
private let welcomeLabel = UILabel()
|
||||||
|
private let formCardView = UIView()
|
||||||
|
private let topFormStack = UIStackView()
|
||||||
|
private let accountField = LoginTextField(placeholder: "请输入账号")
|
||||||
|
private let passwordField = PasswordInputField()
|
||||||
|
private let agreementRow = AgreementRowView()
|
||||||
|
private let loginButton = AppButton(title: "登录", style: .primary)
|
||||||
|
private let loginByCodeButton = AppButton(title: "验证码登录", style: .secondary)
|
||||||
|
private let registerContainer = UIStackView()
|
||||||
|
private let registerPrefixLabel = UILabel()
|
||||||
|
private let registerButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private var hasPlayedEntranceAnimation = false
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
.lightContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
backgroundImageView.image = UIImage(named: "img_login_bg")
|
||||||
|
backgroundImageView.contentMode = .scaleAspectFill
|
||||||
|
backgroundImageView.clipsToBounds = true
|
||||||
|
|
||||||
|
welcomeLabel.text = "欢迎使用\n随心瞰商家版"
|
||||||
|
welcomeLabel.numberOfLines = 2
|
||||||
|
welcomeLabel.font = .systemFont(ofSize: 28, weight: .semibold)
|
||||||
|
welcomeLabel.textColor = .white
|
||||||
|
|
||||||
|
formCardView.backgroundColor = .white
|
||||||
|
formCardView.layer.cornerRadius = 16
|
||||||
|
formCardView.clipsToBounds = true
|
||||||
|
|
||||||
|
topFormStack.axis = .vertical
|
||||||
|
topFormStack.spacing = 0
|
||||||
|
topFormStack.alignment = .fill
|
||||||
|
|
||||||
|
loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside)
|
||||||
|
loginByCodeButton.addTarget(self, action: #selector(loginByCodeButtonTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
registerContainer.axis = .horizontal
|
||||||
|
registerContainer.alignment = .center
|
||||||
|
registerContainer.spacing = 0
|
||||||
|
|
||||||
|
registerPrefixLabel.text = "没有账号?"
|
||||||
|
registerPrefixLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
registerPrefixLabel.textColor = AppColor.text333
|
||||||
|
|
||||||
|
registerButton.setTitle("去注册", for: .normal)
|
||||||
|
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
registerButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
registerButton.addTarget(self, action: #selector(registerButtonTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
registerContainer.addArrangedSubview(registerPrefixLabel)
|
||||||
|
registerContainer.addArrangedSubview(registerButton)
|
||||||
|
|
||||||
|
view.addSubview(backgroundImageView)
|
||||||
|
view.addSubview(welcomeLabel)
|
||||||
|
view.addSubview(formCardView)
|
||||||
|
formCardView.addSubview(topFormStack)
|
||||||
|
formCardView.addSubview(registerContainer)
|
||||||
|
|
||||||
|
topFormStack.addArrangedSubview(accountField)
|
||||||
|
topFormStack.setCustomSpacing(8, after: accountField)
|
||||||
|
topFormStack.addArrangedSubview(passwordField)
|
||||||
|
topFormStack.setCustomSpacing(24, after: passwordField)
|
||||||
|
topFormStack.addArrangedSubview(agreementRow)
|
||||||
|
topFormStack.setCustomSpacing(16, after: agreementRow)
|
||||||
|
topFormStack.addArrangedSubview(loginButton)
|
||||||
|
topFormStack.setCustomSpacing(8, after: loginButton)
|
||||||
|
topFormStack.addArrangedSubview(loginByCodeButton)
|
||||||
|
|
||||||
|
bindViewModel()
|
||||||
|
updateUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
backgroundImageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
welcomeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(48)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
formCardView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24)
|
||||||
|
}
|
||||||
|
|
||||||
|
topFormStack.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerContainer.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
accountField.onTextChange = { [weak self] text in
|
||||||
|
self?.viewModel.updateAccount(text)
|
||||||
|
}
|
||||||
|
accountField.onReturnKey = { [weak self] in
|
||||||
|
self?.passwordField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordField.onTextChange = { [weak self] text in
|
||||||
|
self?.viewModel.updatePassword(text)
|
||||||
|
}
|
||||||
|
passwordField.onReturnKey = { [weak self] in
|
||||||
|
self?.view.endEditing(true)
|
||||||
|
self?.viewModel.login()
|
||||||
|
}
|
||||||
|
|
||||||
|
agreementRow.onCheckedChange = { [weak self] in
|
||||||
|
self?.viewModel.togglePrivacyChecked()
|
||||||
|
}
|
||||||
|
agreementRow.onUserAgreementTap = { [weak self] in
|
||||||
|
self?.viewModel.onUserAgreement?()
|
||||||
|
}
|
||||||
|
agreementRow.onPrivacyPolicyTap = { [weak self] in
|
||||||
|
self?.viewModel.onPrivacyPolicy?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
playEntranceAnimationIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindViewModel() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
self?.updateUI()
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
self?.showToast(message)
|
||||||
|
}
|
||||||
|
viewModel.onLogin = { [weak self] in
|
||||||
|
self?.showToast("登录功能待接入")
|
||||||
|
}
|
||||||
|
viewModel.onLoginByCode = { [weak self] in
|
||||||
|
self?.showToast("验证码登录待接入")
|
||||||
|
}
|
||||||
|
viewModel.onRegister = { [weak self] in
|
||||||
|
self?.showToast("注册待接入")
|
||||||
|
}
|
||||||
|
viewModel.onUserAgreement = { [weak self] in
|
||||||
|
self?.showToast("用户协议待接入")
|
||||||
|
}
|
||||||
|
viewModel.onPrivacyPolicy = { [weak self] in
|
||||||
|
self?.showToast("隐私政策待接入")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateUI() {
|
||||||
|
agreementRow.isChecked = viewModel.isPrivacyChecked
|
||||||
|
loginButton.isEnabled = viewModel.isLoginEnabled
|
||||||
|
|
||||||
|
let showRegisterSection = viewModel.enableRegister
|
||||||
|
loginByCodeButton.isHidden = !showRegisterSection
|
||||||
|
registerContainer.isHidden = !showRegisterSection
|
||||||
|
|
||||||
|
topFormStack.snp.remakeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
if showRegisterSection {
|
||||||
|
make.bottom.lessThanOrEqualTo(registerContainer.snp.top).offset(-16)
|
||||||
|
} else {
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playEntranceAnimationIfNeeded() {
|
||||||
|
guard !hasPlayedEntranceAnimation else { return }
|
||||||
|
hasPlayedEntranceAnimation = true
|
||||||
|
|
||||||
|
formCardView.transform = CGAffineTransform(translationX: 0, y: view.bounds.height)
|
||||||
|
welcomeLabel.alpha = 0
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) {
|
||||||
|
self.formCardView.transform = .identity
|
||||||
|
self.welcomeLabel.alpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func loginButtonTapped() {
|
||||||
|
view.endEditing(true)
|
||||||
|
viewModel.login()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func loginByCodeButtonTapped() {
|
||||||
|
view.endEditing(true)
|
||||||
|
viewModel.onLoginByCode?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func registerButtonTapped() {
|
||||||
|
view.endEditing(true)
|
||||||
|
viewModel.onRegister?()
|
||||||
|
}
|
||||||
|
}
|
||||||
61
suixinkan/UI/Login/LoginViewModel.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// LoginViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
|
||||||
|
final class LoginViewModel {
|
||||||
|
|
||||||
|
private(set) var account = ""
|
||||||
|
private(set) var password = ""
|
||||||
|
private(set) var isPrivacyChecked = false
|
||||||
|
private(set) var enableRegister = false
|
||||||
|
|
||||||
|
var isLoginEnabled: Bool {
|
||||||
|
!account.isEmpty && !password.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
func updateAccount(_ value: String) {
|
||||||
|
account = value
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePassword(_ value: String) {
|
||||||
|
password = value
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePrivacyChecked() {
|
||||||
|
isPrivacyChecked.toggle()
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEnableRegister(_ enabled: Bool) {
|
||||||
|
enableRegister = enabled
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func login() {
|
||||||
|
guard isPrivacyChecked else {
|
||||||
|
onShowMessage?("请先阅读并同意用户协议与隐私政策")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard isLoginEnabled else { return }
|
||||||
|
onLogin?()
|
||||||
|
}
|
||||||
|
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onLogin: (() -> Void)?
|
||||||
|
var onLoginByCode: (() -> Void)?
|
||||||
|
var onRegister: (() -> Void)?
|
||||||
|
var onUserAgreement: (() -> Void)?
|
||||||
|
var onPrivacyPolicy: (() -> Void)?
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
suixinkan/UI/MainTab/AppTab.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// AppTab.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 主 Tab 实体,描述底部导航中的一级入口。
|
||||||
|
enum AppTab: String, CaseIterable, Hashable {
|
||||||
|
case home
|
||||||
|
case orders
|
||||||
|
case statistics
|
||||||
|
case profile
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .home: "首页"
|
||||||
|
case .orders: "订单"
|
||||||
|
case .statistics: "数据"
|
||||||
|
case .profile: "我的"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedImageName: String {
|
||||||
|
switch self {
|
||||||
|
case .home: "tab_home_selected"
|
||||||
|
case .orders: "tab_order_selected"
|
||||||
|
case .statistics: "tab_data_selected"
|
||||||
|
case .profile: "tab_profile_selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unselectedImageName: String {
|
||||||
|
switch self {
|
||||||
|
case .home: "tab_home_unselected"
|
||||||
|
case .orders: "tab_order_unselected"
|
||||||
|
case .statistics: "tab_data_unselected"
|
||||||
|
case .profile: "tab_profile_unselected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建系统 TabBarItem。
|
||||||
|
func makeTabBarItem() -> UITabBarItem {
|
||||||
|
let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||||
|
let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||||
|
let item = UITabBarItem(title: title, image: unselectedImage, selectedImage: selectedImage)
|
||||||
|
item.accessibilityIdentifier = "main.tab.\(rawValue)"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 Tab 根页面。
|
||||||
|
func makeRootViewController() -> UIViewController {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return HomeViewController()
|
||||||
|
case .orders:
|
||||||
|
return OrdersViewController()
|
||||||
|
case .statistics:
|
||||||
|
return StatisticsViewController()
|
||||||
|
case .profile:
|
||||||
|
return ProfileViewController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
suixinkan/UI/MainTab/MainScanTabItem.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// MainScanTabItem.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 主 TabBar 中间扫码核销入口。
|
||||||
|
enum MainScanTabItem {
|
||||||
|
|
||||||
|
/// 构建扫码 TabBarItem,点击行为由 `MainTabBarController` 拦截。
|
||||||
|
static func makeTabBarItem() -> UITabBarItem {
|
||||||
|
let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal)
|
||||||
|
let item = UITabBarItem(title: nil, image: image, selectedImage: image)
|
||||||
|
item.accessibilityLabel = "扫码核销"
|
||||||
|
item.accessibilityIdentifier = "main.scan"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
21
suixinkan/UI/MainTab/MainTabBadgeViewModel.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// MainTabBadgeViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 主 Tab 角标 ViewModel,负责订单 Tab 待核销数量。
|
||||||
|
final class MainTabBadgeViewModel {
|
||||||
|
|
||||||
|
var onChange: (() -> Void)?
|
||||||
|
|
||||||
|
private(set) var pendingWriteOffCount: Int? {
|
||||||
|
didSet { onChange?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新待核销数量;接口未接入前保持为空。
|
||||||
|
func refreshPendingWriteOffCount() {
|
||||||
|
pendingWriteOffCount = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
154
suixinkan/UI/MainTab/MainTabBarController.swift
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
//
|
||||||
|
// MainTabBarController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 主 Tab 容器,展示四个一级入口及中间扫码按钮。
|
||||||
|
final class MainTabBarController: UITabBarController {
|
||||||
|
|
||||||
|
private let badgeViewModel = MainTabBadgeViewModel()
|
||||||
|
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||||
|
private var isSyncingTabSelection = false
|
||||||
|
|
||||||
|
/// 测试时可注入扫码点击处理。
|
||||||
|
var onScanTabSelected: (() -> Void)?
|
||||||
|
|
||||||
|
private enum TabSlot: CaseIterable {
|
||||||
|
case home
|
||||||
|
case orders
|
||||||
|
case scan
|
||||||
|
case statistics
|
||||||
|
case profile
|
||||||
|
|
||||||
|
var appTab: AppTab? {
|
||||||
|
switch self {
|
||||||
|
case .home: return .home
|
||||||
|
case .orders: return .orders
|
||||||
|
case .scan: return nil
|
||||||
|
case .statistics: return .statistics
|
||||||
|
case .profile: return .profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func index(for tab: AppTab) -> Int? {
|
||||||
|
allCases.firstIndex { $0.appTab == tab }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var scanIndex: Int? {
|
||||||
|
allCases.firstIndex(of: .scan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
delegate = self
|
||||||
|
configureTabBarAppearance()
|
||||||
|
configureTabs()
|
||||||
|
bindBadgeViewModel()
|
||||||
|
badgeViewModel.refreshPendingWriteOffCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中指定主 Tab。
|
||||||
|
func selectTab(_ tab: AppTab) {
|
||||||
|
guard let index = TabSlot.index(for: tab) else { return }
|
||||||
|
guard selectedIndex != index else { return }
|
||||||
|
isSyncingTabSelection = true
|
||||||
|
selectedIndex = index
|
||||||
|
isSyncingTabSelection = false
|
||||||
|
if tab == .orders {
|
||||||
|
badgeViewModel.refreshPendingWriteOffCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTabBarAppearance() {
|
||||||
|
tabBar.tintColor = AppColor.primary
|
||||||
|
tabBar.backgroundColor = .white
|
||||||
|
tabBar.shadowImage = UIImage()
|
||||||
|
|
||||||
|
let normalAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: UIColor(hex: 0x7D8DA3),
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .regular),
|
||||||
|
]
|
||||||
|
let selectedAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: AppColor.primary,
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .medium),
|
||||||
|
]
|
||||||
|
|
||||||
|
let appearance = UITabBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = .white
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
|
||||||
|
appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
|
||||||
|
tabBar.standardAppearance = appearance
|
||||||
|
tabBar.scrollEdgeAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTabs() {
|
||||||
|
tabNavigationControllers.removeAll()
|
||||||
|
viewControllers = TabSlot.allCases.map { slot in
|
||||||
|
if let tab = slot.appTab {
|
||||||
|
let navigationController = TabNavigationController(tab: tab)
|
||||||
|
navigationController.tabBarItem = tab.makeTabBarItem()
|
||||||
|
tabNavigationControllers[tab] = navigationController
|
||||||
|
return navigationController
|
||||||
|
}
|
||||||
|
|
||||||
|
let scannerPlaceholder = UIViewController()
|
||||||
|
scannerPlaceholder.tabBarItem = MainScanTabItem.makeTabBarItem()
|
||||||
|
return scannerPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindBadgeViewModel() {
|
||||||
|
badgeViewModel.onChange = { [weak self] in
|
||||||
|
self?.updateOrderBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateOrderBadge() {
|
||||||
|
let badgeValue: String?
|
||||||
|
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
|
||||||
|
badgeValue = "\(count)"
|
||||||
|
} else {
|
||||||
|
badgeValue = nil
|
||||||
|
}
|
||||||
|
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentGlobalScanner() {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
|
||||||
|
let scanner = ScanPlaceholderViewController()
|
||||||
|
let navigationController = UINavigationController(rootViewController: scanner)
|
||||||
|
navigationController.modalPresentationStyle = .fullScreen
|
||||||
|
present(navigationController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MainTabBarController: UITabBarControllerDelegate {
|
||||||
|
|
||||||
|
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
||||||
|
guard viewControllers?.firstIndex(of: viewController) == TabSlot.scanIndex else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onScanTabSelected {
|
||||||
|
onScanTabSelected()
|
||||||
|
} else {
|
||||||
|
presentGlobalScanner()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
||||||
|
guard !isSyncingTabSelection,
|
||||||
|
let navigationController = viewController as? TabNavigationController else { return }
|
||||||
|
|
||||||
|
if navigationController.appTab == .orders {
|
||||||
|
badgeViewModel.refreshPendingWriteOffCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
suixinkan/UI/MainTab/TabNavigationController.swift
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// TabNavigationController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 单个 Tab 的导航栈。
|
||||||
|
final class TabNavigationController: UINavigationController {
|
||||||
|
|
||||||
|
let appTab: AppTab
|
||||||
|
|
||||||
|
init(tab: AppTab) {
|
||||||
|
self.appTab = tab
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
delegate = self
|
||||||
|
navigationBar.prefersLargeTitles = false
|
||||||
|
setViewControllers([tab.makeRootViewController()], animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
|
||||||
|
viewController.hidesBottomBarWhenPushed = viewControllers.count > 0
|
||||||
|
updateNavigationBarVisibility(for: viewController, animated: animated)
|
||||||
|
super.pushViewController(viewController, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
|
||||||
|
if let topViewController = viewControllers.last {
|
||||||
|
updateNavigationBarVisibility(for: topViewController, animated: animated)
|
||||||
|
}
|
||||||
|
super.setViewControllers(viewControllers, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func popViewController(animated: Bool) -> UIViewController? {
|
||||||
|
let poppedViewController = super.popViewController(animated: animated)
|
||||||
|
if !animated, let topViewController {
|
||||||
|
updateNavigationBarVisibility(for: topViewController, animated: false)
|
||||||
|
}
|
||||||
|
return poppedViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||||
|
let poppedViewControllers = super.popToRootViewController(animated: animated)
|
||||||
|
if !animated, let topViewController {
|
||||||
|
updateNavigationBarVisibility(for: topViewController, animated: false)
|
||||||
|
}
|
||||||
|
return poppedViewControllers
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldHideNavigationBar(for viewController: UIViewController) -> Bool {
|
||||||
|
viewController is HomeViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNavigationBarVisibility(for viewController: UIViewController, animated: Bool) {
|
||||||
|
setNavigationBarHidden(shouldHideNavigationBar(for: viewController), animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TabNavigationController: UINavigationControllerDelegate {
|
||||||
|
|
||||||
|
func navigationController(
|
||||||
|
_ navigationController: UINavigationController,
|
||||||
|
willShow viewController: UIViewController,
|
||||||
|
animated: Bool
|
||||||
|
) {
|
||||||
|
updateNavigationBarVisibility(for: viewController, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
suixinkan/UI/Orders/OrdersViewController.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// OrdersViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 订单 Tab 根页面。
|
||||||
|
final class OrdersViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
title = "订单"
|
||||||
|
titleLabel.text = "订单"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.textColor = AppColor.text333
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
suixinkan/UI/Profile/ProfileViewController.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// ProfileViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 我的 Tab 根页面。
|
||||||
|
final class ProfileViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
title = "我的"
|
||||||
|
titleLabel.text = "我的"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.textColor = AppColor.text333
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
suixinkan/UI/Scan/ScanPlaceholderViewController.swift
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// ScanPlaceholderViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 扫码核销占位页,后续替换为真实扫码页。
|
||||||
|
final class ScanPlaceholderViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
titleLabel.text = "扫码核销"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .medium)
|
||||||
|
titleLabel.textColor = .white
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
closeButton.setTitle("关闭", for: .normal)
|
||||||
|
closeButton.setTitleColor(.white, for: .normal)
|
||||||
|
closeButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
view.addSubview(closeButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
suixinkan/UI/Statistics/StatisticsViewController.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// StatisticsViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 数据 Tab 根页面。
|
||||||
|
final class StatisticsViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
title = "数据"
|
||||||
|
titleLabel.text = "数据"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.textColor = AppColor.text333
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// ViewController.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
// Created by hanqiu on 2026/7/6.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class ViewController: BaseViewController {
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||