Add login flow, session routing, and MainTab shell aligned with reference UI.

Implement token-based root routing, Android-matched login screen, five-slot MainTabBar with scan action, and placeholder tab pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 15:12:34 +08:00
parent e290656322
commit b7d74905b7
70 changed files with 1588 additions and 48 deletions

View File

@ -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;

View 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()
})
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_cb_privacy_selected.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_cb_privacy_unselect.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_pwd_invisible.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_pwd_visible.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "img_login_bg.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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 = ""
}
}

View File

@ -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>

View File

@ -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.
} }
} }

View 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
}

View 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
)
}
}

View 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
}
}

View 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
}
}
}

View 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
}
}

View 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
}
}

View 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()
}
}
}

View 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?()
}
}

View File

@ -0,0 +1,61 @@
//
// LoginViewModel.swift
// suixinkan
//
import Foundation
/// ViewModelUI 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?()
}
}

View 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()
}
}
}

View 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
}
}

View 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
}
}

View 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()
}
}
}

View 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)
}
}

View 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()
}
}
}

View 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()
}
}
}

View 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)
}
}

View 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()
}
}
}

View File

@ -1,17 +0,0 @@
//
// ViewController.swift
// suixinkan
//
// Created by hanqiu on 2026/7/6.
//
import UIKit
class ViewController: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}