diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 6a8aa08..25d5189 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -163,7 +163,6 @@ INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16; @@ -200,7 +199,6 @@ INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16; diff --git a/suixinkan/App/AppRouter.swift b/suixinkan/App/AppRouter.swift new file mode 100644 index 0000000..c7adff6 --- /dev/null +++ b/suixinkan/App/AppRouter.swift @@ -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() + }) + } +} diff --git a/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/Contents.json new file mode 100644 index 0000000..7c9a98d --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_cb_privacy_selected.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/icon_cb_privacy_selected.png b/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/icon_cb_privacy_selected.png new file mode 100644 index 0000000..7dfce28 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_cb_privacy_selected.imageset/icon_cb_privacy_selected.png differ diff --git a/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/Contents.json new file mode 100644 index 0000000..c688206 --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_cb_privacy_unselect.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/icon_cb_privacy_unselect.png b/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..c53d8fd Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_cb_privacy_unselect.imageset/icon_cb_privacy_unselect.png differ diff --git a/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/Contents.json new file mode 100644 index 0000000..231bbbb --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_pwd_invisible.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/icon_pwd_invisible.png b/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/icon_pwd_invisible.png new file mode 100644 index 0000000..fd2ac79 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_pwd_invisible.imageset/icon_pwd_invisible.png differ diff --git a/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/Contents.json new file mode 100644 index 0000000..4d9eeae --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_pwd_visible.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/icon_pwd_visible.png b/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/icon_pwd_visible.png new file mode 100644 index 0000000..df11b0c Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_pwd_visible.imageset/icon_pwd_visible.png differ diff --git a/suixinkan/Assets.xcassets/icon_scan.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_scan.imageset/Contents.json new file mode 100644 index 0000000..c5d9acc --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_scan.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan.png b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan.png new file mode 100644 index 0000000..a03cfd1 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan.png differ diff --git a/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@2x.png b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@2x.png new file mode 100644 index 0000000..1f76573 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@3x.png b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@3x.png new file mode 100644 index 0000000..72e3340 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_scan.imageset/icon_scan@3x.png differ diff --git a/suixinkan/Assets.xcassets/img_login_bg.imageset/Contents.json b/suixinkan/Assets.xcassets/img_login_bg.imageset/Contents.json new file mode 100644 index 0000000..1a33e0a --- /dev/null +++ b/suixinkan/Assets.xcassets/img_login_bg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "img_login_bg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/img_login_bg.imageset/img_login_bg.png b/suixinkan/Assets.xcassets/img_login_bg.imageset/img_login_bg.png new file mode 100644 index 0000000..163b509 Binary files /dev/null and b/suixinkan/Assets.xcassets/img_login_bg.imageset/img_login_bg.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_selected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_data_selected.imageset/Contents.json new file mode 100644 index 0000000..90c8ea2 --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_data_selected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected.png b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected.png new file mode 100644 index 0000000..6a5fe1c Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@2x.png b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@2x.png new file mode 100644 index 0000000..84e2fb9 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@3x.png b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@3x.png new file mode 100644 index 0000000..50a0640 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_selected.imageset/tab_data_selected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_unselected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/Contents.json new file mode 100644 index 0000000..254b4fb --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected.png b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected.png new file mode 100644 index 0000000..cd2a559 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@2x.png b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@2x.png new file mode 100644 index 0000000..c1031e8 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@3x.png b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@3x.png new file mode 100644 index 0000000..4e875e1 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_data_unselected.imageset/tab_data_unselected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_selected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_home_selected.imageset/Contents.json new file mode 100644 index 0000000..566e1d5 --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_home_selected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected.png b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected.png new file mode 100644 index 0000000..f59bbb8 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@2x.png b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@2x.png new file mode 100644 index 0000000..50f856b Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@3x.png b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@3x.png new file mode 100644 index 0000000..f7c4f2c Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_selected.imageset/tab_home_selected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_unselected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/Contents.json new file mode 100644 index 0000000..e6194ed --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected.png b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected.png new file mode 100644 index 0000000..1cdaf5c Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@2x.png b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@2x.png new file mode 100644 index 0000000..54fb11a Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@3x.png b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@3x.png new file mode 100644 index 0000000..95bc97d Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_home_unselected.imageset/tab_home_unselected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_selected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_order_selected.imageset/Contents.json new file mode 100644 index 0000000..1e81253 --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_order_selected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected.png b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected.png new file mode 100644 index 0000000..3627ef9 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@2x.png b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@2x.png new file mode 100644 index 0000000..9a18927 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@3x.png b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@3x.png new file mode 100644 index 0000000..255ebef Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_selected.imageset/tab_order_selected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_unselected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/Contents.json new file mode 100644 index 0000000..89b0894 --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected.png b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected.png new file mode 100644 index 0000000..90a3d32 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@2x.png b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@2x.png new file mode 100644 index 0000000..44ceb95 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@3x.png b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@3x.png new file mode 100644 index 0000000..d49583c Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_order_unselected.imageset/tab_order_unselected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_selected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/Contents.json new file mode 100644 index 0000000..33d4fa6 --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected.png b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected.png new file mode 100644 index 0000000..c5f7477 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@2x.png b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@2x.png new file mode 100644 index 0000000..e9ef614 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@3x.png b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@3x.png new file mode 100644 index 0000000..b2996b3 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_selected.imageset/tab_profile_selected@3x.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/Contents.json b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/Contents.json new file mode 100644 index 0000000..961a1dd --- /dev/null +++ b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/Contents.json @@ -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 + } +} diff --git a/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected.png b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected.png new file mode 100644 index 0000000..73f36da Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@2x.png b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@2x.png new file mode 100644 index 0000000..18591e7 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@2x.png differ diff --git a/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@3x.png b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@3x.png new file mode 100644 index 0000000..d5abb60 Binary files /dev/null and b/suixinkan/Assets.xcassets/tab_profile_unselected.imageset/tab_profile_unselected@3x.png differ diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift new file mode 100644 index 0000000..8cb1b70 --- /dev/null +++ b/suixinkan/DataStore/AppStore.swift @@ -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 = "" + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index dd3c9af..0eb786d 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -15,8 +15,6 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index 2f9609f..8b0a23b 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -2,8 +2,6 @@ // SceneDelegate.swift // suixinkan // -// Created by hanqiu on 2026/7/6. -// import UIKit @@ -11,42 +9,57 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { 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) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + let window = UIWindow(windowScene: windowScene) + window.rootViewController = AppRouter.makeRootViewController() + window.makeKeyAndVisible() + self.window = window + + registerNotifications() } func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // 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). + NotificationCenter.default.removeObserver(self) } - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + private func registerNotifications() { + NotificationCenter.default.addObserver( + 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) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). + @objc private func handleSessionDidExpire() { + AppStore.shared.logout() + AppRouter.setRoot(.login, on: window) } - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. + @objc private func handleUserDidLogout() { + AppStore.shared.logout() + AppRouter.setRoot(.login, on: window) } - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // 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. + @objc private func handleUserDidLogin() { + AppRouter.setRoot(.mainTab, on: window) } - - } - diff --git a/suixinkan/Theme/AppColor.swift b/suixinkan/Theme/AppColor.swift new file mode 100644 index 0000000..9e7de15 --- /dev/null +++ b/suixinkan/Theme/AppColor.swift @@ -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 +} diff --git a/suixinkan/Theme/UIColor+Hex.swift b/suixinkan/Theme/UIColor+Hex.swift new file mode 100644 index 0000000..a0e26bd --- /dev/null +++ b/suixinkan/Theme/UIColor+Hex.swift @@ -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 + ) + } +} diff --git a/suixinkan/UI/Common/AgreementRowView.swift b/suixinkan/UI/Common/AgreementRowView.swift new file mode 100644 index 0000000..d8572d9 --- /dev/null +++ b/suixinkan/UI/Common/AgreementRowView.swift @@ -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 + } +} diff --git a/suixinkan/UI/Common/AppButton.swift b/suixinkan/UI/Common/AppButton.swift new file mode 100644 index 0000000..f70af10 --- /dev/null +++ b/suixinkan/UI/Common/AppButton.swift @@ -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 + } + } +} diff --git a/suixinkan/UI/Common/LoginTextField.swift b/suixinkan/UI/Common/LoginTextField.swift new file mode 100644 index 0000000..a291a22 --- /dev/null +++ b/suixinkan/UI/Common/LoginTextField.swift @@ -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 + } +} diff --git a/suixinkan/UI/Common/PasswordInputField.swift b/suixinkan/UI/Common/PasswordInputField.swift new file mode 100644 index 0000000..bd402d6 --- /dev/null +++ b/suixinkan/UI/Common/PasswordInputField.swift @@ -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 + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift new file mode 100644 index 0000000..5f3929f --- /dev/null +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -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() + } + } +} diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift new file mode 100644 index 0000000..0f33604 --- /dev/null +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -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?() + } +} diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift new file mode 100644 index 0000000..520cbed --- /dev/null +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -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?() + } +} diff --git a/suixinkan/UI/MainTab/AppTab.swift b/suixinkan/UI/MainTab/AppTab.swift new file mode 100644 index 0000000..e60c9e6 --- /dev/null +++ b/suixinkan/UI/MainTab/AppTab.swift @@ -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() + } + } +} diff --git a/suixinkan/UI/MainTab/MainScanTabItem.swift b/suixinkan/UI/MainTab/MainScanTabItem.swift new file mode 100644 index 0000000..ebf7792 --- /dev/null +++ b/suixinkan/UI/MainTab/MainScanTabItem.swift @@ -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 + } +} diff --git a/suixinkan/UI/MainTab/MainTabBadgeViewModel.swift b/suixinkan/UI/MainTab/MainTabBadgeViewModel.swift new file mode 100644 index 0000000..5597684 --- /dev/null +++ b/suixinkan/UI/MainTab/MainTabBadgeViewModel.swift @@ -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 + } +} diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift new file mode 100644 index 0000000..a2309f7 --- /dev/null +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -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() + } + } +} diff --git a/suixinkan/UI/MainTab/TabNavigationController.swift b/suixinkan/UI/MainTab/TabNavigationController.swift new file mode 100644 index 0000000..63e946a --- /dev/null +++ b/suixinkan/UI/MainTab/TabNavigationController.swift @@ -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) + } +} diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift new file mode 100644 index 0000000..84e48cc --- /dev/null +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -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() + } + } +} diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift new file mode 100644 index 0000000..dcead50 --- /dev/null +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -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() + } + } +} diff --git a/suixinkan/UI/Scan/ScanPlaceholderViewController.swift b/suixinkan/UI/Scan/ScanPlaceholderViewController.swift new file mode 100644 index 0000000..5f9fef2 --- /dev/null +++ b/suixinkan/UI/Scan/ScanPlaceholderViewController.swift @@ -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) + } +} diff --git a/suixinkan/UI/Statistics/StatisticsViewController.swift b/suixinkan/UI/Statistics/StatisticsViewController.swift new file mode 100644 index 0000000..fcf9d49 --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsViewController.swift @@ -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() + } + } +} diff --git a/suixinkan/ViewController.swift b/suixinkan/ViewController.swift deleted file mode 100644 index 7f6fc1e..0000000 --- a/suixinkan/ViewController.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ViewController.swift -// suixinkan -// -// Created by hanqiu on 2026/7/6. -// - -import UIKit - -class ViewController: BaseViewController { - - override func viewDidLoad() { - super.viewDidLoad() - } - -} -