diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b90258a..55338b2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.techpie" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2d632d9..368c33f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,17 +12,9 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5A0010032F1A000100000001 /* NativeGlassRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010022F1A000100000001 /* NativeGlassRegistry.swift */; }; 5A0010052F1A000100000001 /* NativeGlassTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010042F1A000100000001 /* NativeGlassTabBarView.swift */; }; - 5A0010072F1A000100000001 /* NativeGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010062F1A000100000001 /* NativeGlassButton.swift */; }; - 5A00100C2F1A000100000001 /* NativeGlassDropdownMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A00100B2F1A000100000001 /* NativeGlassDropdownMenu.swift */; }; - 5A00100E2F1A000100000001 /* NativeGlassSelect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A00100D2F1A000100000001 /* NativeGlassSelect.swift */; }; - 5A0010102F1A000100000001 /* NativeGlassSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A00100F2F1A000100000001 /* NativeGlassSwitch.swift */; }; - 5A0010122F1A000100000001 /* NativeGlassConfirmationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010112F1A000100000001 /* NativeGlassConfirmationButton.swift */; }; 5A0010142F1A000100000001 /* NativeGlassPresenterPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010132F1A000100000001 /* NativeGlassPresenterPlugin.swift */; }; 5A0010152F1A000100000001 /* IcsFilePresenterPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010162F1A000100000001 /* IcsFilePresenterPlugin.swift */; }; 5A0010182F1A000100000001 /* NativeNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010172F1A000100000001 /* NativeNavigationBar.swift */; }; - 5A00101A2F1A000100000001 /* NativeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0010192F1A000100000001 /* NativeTextView.swift */; }; - 5A00101C2F1A000100000001 /* NativeSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A00101B2F1A000100000001 /* NativeSegmentedControl.swift */; }; - 5A00101E2F1A000100000001 /* NativeTextFieldGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A00101D2F1A000100000001 /* NativeTextFieldGroup.swift */; }; 6BFBE954DB868241E9E9810B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 052EF181B45C1F8AB028A91F /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -65,17 +57,9 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 5A0010022F1A000100000001 /* NativeGlassRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassRegistry.swift; sourceTree = ""; }; 5A0010042F1A000100000001 /* NativeGlassTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassTabBarView.swift; sourceTree = ""; }; - 5A0010062F1A000100000001 /* NativeGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassButton.swift; sourceTree = ""; }; - 5A00100B2F1A000100000001 /* NativeGlassDropdownMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassDropdownMenu.swift; sourceTree = ""; }; - 5A00100D2F1A000100000001 /* NativeGlassSelect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassSelect.swift; sourceTree = ""; }; - 5A00100F2F1A000100000001 /* NativeGlassSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassSwitch.swift; sourceTree = ""; }; - 5A0010112F1A000100000001 /* NativeGlassConfirmationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassConfirmationButton.swift; sourceTree = ""; }; 5A0010132F1A000100000001 /* NativeGlassPresenterPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlassPresenterPlugin.swift; sourceTree = ""; }; 5A0010162F1A000100000001 /* IcsFilePresenterPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcsFilePresenterPlugin.swift; sourceTree = ""; }; 5A0010172F1A000100000001 /* NativeNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeNavigationBar.swift; sourceTree = ""; }; - 5A0010192F1A000100000001 /* NativeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextView.swift; sourceTree = ""; }; - 5A00101B2F1A000100000001 /* NativeSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSegmentedControl.swift; sourceTree = ""; }; - 5A00101D2F1A000100000001 /* NativeTextFieldGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextFieldGroup.swift; sourceTree = ""; }; 6B0AD00AB3D979A68E9ABE84 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -151,17 +135,9 @@ children = ( 5A0010022F1A000100000001 /* NativeGlassRegistry.swift */, 5A0010042F1A000100000001 /* NativeGlassTabBarView.swift */, - 5A0010062F1A000100000001 /* NativeGlassButton.swift */, - 5A00100B2F1A000100000001 /* NativeGlassDropdownMenu.swift */, - 5A00100D2F1A000100000001 /* NativeGlassSelect.swift */, - 5A00100F2F1A000100000001 /* NativeGlassSwitch.swift */, - 5A0010112F1A000100000001 /* NativeGlassConfirmationButton.swift */, 5A0010132F1A000100000001 /* NativeGlassPresenterPlugin.swift */, 5A0010162F1A000100000001 /* IcsFilePresenterPlugin.swift */, 5A0010172F1A000100000001 /* NativeNavigationBar.swift */, - 5A0010192F1A000100000001 /* NativeTextView.swift */, - 5A00101B2F1A000100000001 /* NativeSegmentedControl.swift */, - 5A00101D2F1A000100000001 /* NativeTextFieldGroup.swift */, ); path = NativeGlass; sourceTree = ""; @@ -433,17 +409,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5A0010102F1A000100000001 /* NativeGlassSwitch.swift in Sources */, 5A0010142F1A000100000001 /* NativeGlassPresenterPlugin.swift in Sources */, 5A0010152F1A000100000001 /* IcsFilePresenterPlugin.swift in Sources */, - 5A00100E2F1A000100000001 /* NativeGlassSelect.swift in Sources */, - 5A00100C2F1A000100000001 /* NativeGlassDropdownMenu.swift in Sources */, - 5A0010122F1A000100000001 /* NativeGlassConfirmationButton.swift in Sources */, 5A0010182F1A000100000001 /* NativeNavigationBar.swift in Sources */, - 5A00101A2F1A000100000001 /* NativeTextView.swift in Sources */, - 5A00101C2F1A000100000001 /* NativeSegmentedControl.swift in Sources */, - 5A00101E2F1A000100000001 /* NativeTextFieldGroup.swift in Sources */, - 5A0010072F1A000100000001 /* NativeGlassButton.swift in Sources */, 5A0010052F1A000100000001 /* NativeGlassTabBarView.swift in Sources */, 5A0010032F1A000100000001 /* NativeGlassRegistry.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 70cffea..073c89b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -22,16 +22,27 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + com.geekpie.techpie + CFBundleURLSchemes + + techpie + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS - NSCalendarsUsageDescription - TechPie 需要访问日历以将课表导入到你指定的系统日历中。 NSCalendarsFullAccessUsageDescription TechPie 需要完整日历权限,以查找或创建你指定名称的日历并导入课表。 + NSCalendarsUsageDescription + TechPie 需要访问日历以将课表导入到你指定的系统日历中。 UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName @@ -51,16 +62,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CFBundleURLTypes - - - CFBundleURLName - com.geekpie.techpie - CFBundleURLSchemes - - techpie - - - diff --git a/ios/Runner/NativeGlass/NativeGlassButton.swift b/ios/Runner/NativeGlass/NativeGlassButton.swift deleted file mode 100644 index ed9b9d2..0000000 --- a/ios/Runner/NativeGlass/NativeGlassButton.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Flutter -import UIKit - -final class NativeGlassButtonFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeGlassButtonPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeGlassButtonPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_glass_button" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let button = UIButton(type: .system) - - private var sfSymbol = "plus" - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - parseArguments(args) - buildViewHierarchy() - applyButtonAppearance() - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - func view() -> UIView { - rootView - } - - private func parseArguments(_ args: Any?) { - guard - let params = args as? [String: Any], - let rawSymbol = params["sfSymbol"] as? String, - !rawSymbol.isEmpty - else { - return - } - - sfSymbol = rawSymbol - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - rootView.clipsToBounds = false - - button.translatesAutoresizingMaskIntoConstraints = false - button.adjustsImageWhenHighlighted = true - button.tintAdjustmentMode = .normal - button.clipsToBounds = false - button.imageView?.contentMode = .center - - button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) - - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - } - - private func symbolImage() -> UIImage? { - return UIImage(systemName: sfSymbol)? - .withRenderingMode(.alwaysTemplate) - } - - private func applyButtonAppearance() { - let image = symbolImage() - - if #available(iOS 26.0, *) { - applyLiquidGlassAppearance(image: image) - } else if #available(iOS 15.0, *) { - applyModernFallbackAppearance(image: image) - } else { - applyLegacyFallbackAppearance(image: image) - } - } - - @available(iOS 26.0, *) - private func applyLiquidGlassAppearance(image: UIImage?) { - var configuration = UIButton.Configuration.prominentGlass() - configuration.image = image - - button.configuration = configuration - } - - @available(iOS 15.0, *) - private func applyModernFallbackAppearance(image: UIImage?) { - var configuration = UIButton.Configuration.plain() - configuration.image = image - - button.configuration = configuration - } - - private func applyLegacyFallbackAppearance(image: UIImage?) { - button.setImage(image, for: .normal) - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateSymbol": - guard - let arguments = call.arguments as? [String: Any], - let symbol = arguments["sfSymbol"] as? String, - !symbol.isEmpty - else { - result( - FlutterError( - code: "bad_args", - message: "Expected an sfSymbol string.", - details: nil - ) - ) - return - } - - sfSymbol = symbol - applyButtonAppearance() - result(nil) - - default: - result(FlutterMethodNotImplemented) - } - } - - @objc - private func handleTap() { - let feedback = UIImpactFeedbackGenerator(style: .light) - feedback.impactOccurred(intensity: 0.65) - - channel.invokeMethod("onTap", arguments: nil) - } - -} diff --git a/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift b/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift deleted file mode 100644 index 71a9ab6..0000000 --- a/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Flutter -import UIKit - -final class NativeGlassConfirmationButtonFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeGlassConfirmationButtonPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeGlassConfirmationButtonPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_glass_confirmation_button" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let button = UIButton(type: .system) - - private var label: String? - private var confirmTitle = "Are you sure?" - private var confirmLabel = "Confirm" - private var sfSymbol = "checkmark" - private var destructive = false - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - parseArguments(args) - buildViewHierarchy() - applyButtonAppearance() - button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func parseArguments(_ args: Any?) { - guard let params = args as? [String: Any] else { return } - - label = params["label"] as? String - if let rawTitle = params["confirmTitle"] as? String, !rawTitle.isEmpty { - confirmTitle = rawTitle - } - if let rawConfirmLabel = params["confirmLabel"] as? String, !rawConfirmLabel.isEmpty { - confirmLabel = rawConfirmLabel - } - if let rawSymbol = params["sfSymbol"] as? String, !rawSymbol.isEmpty { - sfSymbol = rawSymbol - } - destructive = params["destructive"] as? Bool ?? false - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - rootView.clipsToBounds = false - - button.translatesAutoresizingMaskIntoConstraints = false - button.accessibilityTraits.insert(.button) - button.clipsToBounds = false - if #available(iOS 26.0, *) { - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - return - } - - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - } - - private func applyButtonAppearance() { - if #available(iOS 26.0, *) { - applyLiquidGlassAppearance() - return - } - - let image = symbolImage(named: sfSymbol) - - if #available(iOS 15.0, *) { - var configuration = UIButton.Configuration.plain() - configuration.image = image - configuration.title = label - if destructive { - configuration.baseForegroundColor = .systemRed - } - button.configuration = configuration - } else { - button.setImage(image, for: .normal) - button.setTitle(label, for: .normal) - if destructive { - button.tintColor = .systemRed - button.setTitleColor(.systemRed, for: .normal) - } - } - } - - @available(iOS 26.0, *) - private func applyLiquidGlassAppearance() { - let image = symbolImage(named: sfSymbol) - - var configuration: UIButton.Configuration = - label == nil || label?.isEmpty == true - ? .clearGlass() - : .glass() - configuration.image = image - configuration.title = label - if destructive { - configuration.baseForegroundColor = .systemRed - } - - button.configuration = configuration - } - - private func symbolImage(named systemName: String) -> UIImage? { - return UIImage(systemName: systemName)? - .withRenderingMode(.alwaysTemplate) - } - - @objc - private func handleTap() { - guard let controller = nearestViewController() else { - return - } - - let actionSheet = UIAlertController( - title: confirmTitle, - message: nil, - preferredStyle: .actionSheet - ) - - let confirmStyle: UIAlertAction.Style = destructive ? .destructive : .default - actionSheet.addAction( - UIAlertAction(title: confirmLabel, style: confirmStyle) { [weak self] _ in - let feedback = UIImpactFeedbackGenerator(style: .light) - feedback.impactOccurred(intensity: 0.55) - self?.channel.invokeMethod("onConfirmed", arguments: nil) - } - ) - actionSheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - if let popover = actionSheet.popoverPresentationController { - if #available(iOS 26.0, *) { - popover.sourceItem = button - } else { - popover.sourceView = rootView - popover.sourceRect = rootView.bounds - } - } - - controller.present(actionSheet, animated: true) - } - - private func nearestViewController() -> UIViewController? { - if let nextResponder = sequence(first: rootView.next, next: { $0?.next }) - .first(where: { $0 is UIViewController }) as? UIViewController - { - return nextResponder - } - - let rootController = rootView.window?.rootViewController - var topController = rootController - - while let presented = topController?.presentedViewController { - topController = presented - } - - return topController - } -} diff --git a/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift b/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift deleted file mode 100644 index 9ff6838..0000000 --- a/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift +++ /dev/null @@ -1,290 +0,0 @@ -import Flutter -import UIKit - -final class NativeGlassDropdownMenuFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeGlassDropdownMenuPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeGlassDropdownMenuPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_glass_dropdown_menu" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let button = UIButton(type: .system) - - private var sfSymbol = "ellipsis" - private var label: String? - private var items: [NativeGlassDropdownMenuItem] = [] - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - parseArguments(args) - buildViewHierarchy() - applyButtonAppearance() - rebuildMenu() - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func parseArguments(_ args: Any?) { - guard let params = args as? [String: Any] else { - return - } - - if let rawSymbol = params["sfSymbol"] as? String, !rawSymbol.isEmpty { - sfSymbol = rawSymbol - } - - if let rawLabel = params["label"] as? String, !rawLabel.isEmpty { - label = rawLabel - } else { - label = nil - } - - if let rawItems = params["items"] as? [[String: Any]] { - items = rawItems.compactMap(NativeGlassDropdownMenuItem.init) - } - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - rootView.clipsToBounds = false - - button.translatesAutoresizingMaskIntoConstraints = false - button.accessibilityTraits.insert(.button) - button.clipsToBounds = false - - if #available(iOS 26.0, *) { - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - return - } - - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - } - - private func applyButtonAppearance() { - let image = symbolImage(named: sfSymbol) - - if #available(iOS 26.0, *) { - var configuration = UIButton.Configuration.glass() - configuration.image = image - configuration.title = label - button.configuration = configuration - return - } - - if #available(iOS 15.0, *) { - var configuration = UIButton.Configuration.plain() - configuration.image = image - configuration.title = label - button.configuration = configuration - } else { - button.setImage(image, for: .normal) - button.setTitle(label, for: .normal) - } - } - - private func rebuildMenu() { - button.removeTarget(self, action: #selector(handleLegacyTap), for: .touchUpInside) - - if #available(iOS 14.0, *) { - button.menu = UIMenu(children: items.compactMap(makeElement)) - button.showsMenuAsPrimaryAction = true - } else { - button.addTarget(self, action: #selector(handleLegacyTap), for: .touchUpInside) - } - } - - @available(iOS 14.0, *) - private func makeElement(for item: NativeGlassDropdownMenuItem) -> UIMenuElement { - if let children = item.children, !children.isEmpty { - return UIMenu( - title: item.title, - options: [], - children: children.compactMap(makeElement) - ) - } - - return makeAction(for: item) - } - - @available(iOS 14.0, *) - private func makeAction(for item: NativeGlassDropdownMenuItem) -> UIAction { - UIAction( - title: item.title, - attributes: item.destructive ? [.destructive] : [], - state: item.checked ? .on : .off - ) { [weak self] _ in - self?.notifySelection(value: item.value) - } - } - - private func symbolImage(named systemName: String) -> UIImage? { - guard systemName != "none" else { return nil } - - return UIImage(systemName: systemName)? - .withRenderingMode(.alwaysTemplate) - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateConfiguration": - parseArguments(call.arguments) - applyButtonAppearance() - rebuildMenu() - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - private func notifySelection(value: String) { - let feedback = UIImpactFeedbackGenerator(style: .light) - feedback.impactOccurred(intensity: 0.55) - channel.invokeMethod("onSelect", arguments: ["value": value]) - } - - @objc - private func handleLegacyTap() { - guard !items.isEmpty, let controller = nearestViewController() else { - return - } - - let sheet = UIAlertController(title: label, message: nil, preferredStyle: .actionSheet) - - for item in items { - let title = item.checked ? "✓ \(item.title)" : item.title - let style: UIAlertAction.Style = item.destructive ? .destructive : .default - sheet.addAction( - UIAlertAction(title: title, style: style) { [weak self] _ in - self?.notifySelection(value: item.value) - } - ) - } - - sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - if let popover = sheet.popoverPresentationController { - popover.sourceView = rootView - popover.sourceRect = rootView.bounds - } - - controller.present(sheet, animated: true) - } - - private func nearestViewController() -> UIViewController? { - if let nextResponder = sequence(first: rootView.next, next: { $0?.next }) - .first(where: { $0 is UIViewController }) as? UIViewController - { - return nextResponder - } - - let rootController = rootView.window?.rootViewController - var topController = rootController - - while let presented = topController?.presentedViewController { - topController = presented - } - - return topController - } -} - -private struct NativeGlassDropdownMenuItem { - let value: String - let title: String - let checked: Bool - let destructive: Bool - let children: [NativeGlassDropdownMenuItem]? - - init( - value: String, - title: String, - checked: Bool, - destructive: Bool, - children: [NativeGlassDropdownMenuItem]? - ) { - self.value = value - self.title = title - self.checked = checked - self.destructive = destructive - self.children = children - } - - init?(_ dictionary: [String: Any]) { - guard - let value = dictionary["value"] as? String, - let title = dictionary["title"] as? String, - !value.isEmpty, - !title.isEmpty - else { - return nil - } - - self.init( - value: value, - title: title, - checked: dictionary["checked"] as? Bool ?? false, - destructive: dictionary["destructive"] as? Bool ?? false, - children: (dictionary["children"] as? [[String: Any]])?.compactMap(Self.init) - ) - } -} diff --git a/ios/Runner/NativeGlass/NativeGlassPresenterPlugin.swift b/ios/Runner/NativeGlass/NativeGlassPresenterPlugin.swift index 674f5ad..98dd57d 100644 --- a/ios/Runner/NativeGlass/NativeGlassPresenterPlugin.swift +++ b/ios/Runner/NativeGlass/NativeGlassPresenterPlugin.swift @@ -1,48 +1,22 @@ import Flutter -import QuartzCore import SwiftUI import UIKit -final class NativeGlassPresenterPlugin: NSObject, FlutterPlugin, UIGestureRecognizerDelegate { +final class NativeGlassPresenterPlugin: NSObject, FlutterPlugin { private static let channelName = "techpie/native_glass_presenter" - private static let pageTransitionChannelName = "techpie/native_page_transition" private let channel: FlutterMethodChannel - private let pageTransitionChannel: FlutterMethodChannel - private var pageTransitionSource: UIImageView? - private weak var pageTransitionRootView: UIView? - private weak var pageTransitionWindow: UIWindow? - private var pageTransitionDirection = "push" - private var pageTransitionAnimating = false - private var pageBackSnapshots: [UIImage] = [] - private weak var interactivePopGestureWindow: UIWindow? - private var interactivePopGesture: UIPanGestureRecognizer? - private var interactivePopSource: UIImageView? - private var interactivePopDestination: UIImageView? - private var interactivePopFrame = CGRect.zero - private var interactivePopProgress: CGFloat = 0 static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: channelName, binaryMessenger: registrar.messenger() ) - let pageTransitionChannel = FlutterMethodChannel( - name: pageTransitionChannelName, - binaryMessenger: registrar.messenger() - ) - let instance = NativeGlassPresenterPlugin( - channel: channel, - pageTransitionChannel: pageTransitionChannel - ) + let instance = NativeGlassPresenterPlugin(channel: channel) registrar.addMethodCallDelegate(instance, channel: channel) } - init( - channel: FlutterMethodChannel, - pageTransitionChannel: FlutterMethodChannel - ) { + init(channel: FlutterMethodChannel) { self.channel = channel - self.pageTransitionChannel = pageTransitionChannel super.init() } @@ -64,318 +38,11 @@ final class NativeGlassPresenterPlugin: NSObject, FlutterPlugin, UIGestureRecogn case "presentLoginSheet": let arguments = call.arguments as? [String: Any] ?? [:] presentLoginSheet(arguments: arguments, result: result) - case "beginPageTransition": - let arguments = call.arguments as? [String: Any] ?? [:] - beginPageTransition(arguments: arguments, result: result) - case "finishPageTransition": - finishPageTransition(result: result) - case "cancelPageTransition": - cancelPageTransition() - result(nil) default: result(FlutterMethodNotImplemented) } } - private func beginPageTransition( - arguments: [String: Any], - result: FlutterResult - ) { - guard !pageTransitionAnimating, interactivePopSource == nil else { - result( - FlutterError( - code: "transition_in_progress", - message: "A page transition is already in progress.", - details: nil - ) - ) - return - } - - cancelPageTransition() - - guard - let window = keyWindow(), - let rootView = window.rootViewController?.view, - let direction = arguments["direction"] as? String, - let source = makePageSnapshot(of: rootView) - else { - result( - FlutterError( - code: "no_root_view", - message: "Unable to find the root view for the page transition.", - details: nil - ) - ) - return - } - - source.frame = rootView.convert(rootView.bounds, to: window) - source.clipsToBounds = true - source.isUserInteractionEnabled = true - window.addSubview(source) - - pageTransitionSource = source - pageTransitionRootView = rootView - pageTransitionWindow = window - pageTransitionDirection = direction - result(nil) - } - - private func finishPageTransition(result: FlutterResult) { - guard - let source = pageTransitionSource, - let rootView = pageTransitionRootView, - let window = pageTransitionWindow, - let destination = makePageSnapshot(of: rootView) - else { - cancelPageTransition() - result(nil) - return - } - - let frame = rootView.convert(rootView.bounds, to: window) - let width = frame.width - let isPop = pageTransitionDirection == "pop" - - destination.frame = frame - destination.clipsToBounds = true - destination.isUserInteractionEnabled = true - - if isPop { - destination.frame.origin.x = frame.minX - width / 3 - source.layer.shadowColor = UIColor.black.cgColor - source.layer.shadowOpacity = 0.2 - source.layer.shadowRadius = 12 - source.layer.shadowOffset = CGSize(width: -4, height: 0) - window.insertSubview(destination, belowSubview: source) - } else { - destination.frame.origin.x = frame.maxX - destination.layer.shadowColor = UIColor.black.cgColor - destination.layer.shadowOpacity = 0.2 - destination.layer.shadowRadius = 12 - destination.layer.shadowOffset = CGSize(width: -4, height: 0) - window.insertSubview(destination, aboveSubview: source) - } - - if isPop { - if !pageBackSnapshots.isEmpty { - pageBackSnapshots.removeLast() - } - } else if let sourceImage = source.image { - pageBackSnapshots.append(sourceImage) - installInteractivePopGesture(in: window) - } - - pageTransitionSource = nil - pageTransitionRootView = nil - pageTransitionWindow = nil - pageTransitionAnimating = true - - UIView.animate( - withDuration: 0.12, - delay: 0, - options: [.curveEaseInOut, .beginFromCurrentState] - ) { - source.frame.origin.x = isPop ? frame.maxX : frame.minX - width / 3 - destination.frame = frame - } completion: { [weak self] _ in - source.removeFromSuperview() - destination.removeFromSuperview() - self?.pageTransitionAnimating = false - } - - result(nil) - } - - private func cancelPageTransition() { - pageTransitionSource?.removeFromSuperview() - pageTransitionSource = nil - pageTransitionRootView = nil - pageTransitionWindow = nil - } - - private func makePageSnapshot(of view: UIView) -> UIImageView? { - guard !view.bounds.isEmpty else { return nil } - - let format = UIGraphicsImageRendererFormat.preferred() - format.opaque = false - let renderer = UIGraphicsImageRenderer(bounds: view.bounds, format: format) - let image = renderer.image { context in - if !view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) { - view.layer.render(in: context.cgContext) - } - } - - let imageView = UIImageView(image: image) - imageView.contentMode = .scaleToFill - return imageView - } - - private func installInteractivePopGesture(in window: UIWindow) { - if interactivePopGestureWindow === window, interactivePopGesture != nil { - return - } - - if let gesture = interactivePopGesture { - interactivePopGestureWindow?.removeGestureRecognizer(gesture) - } - - let gesture = UIPanGestureRecognizer( - target: self, - action: #selector(handleInteractivePopGesture(_:)) - ) - gesture.delegate = self - gesture.cancelsTouchesInView = true - window.addGestureRecognizer(gesture) - interactivePopGesture = gesture - interactivePopGestureWindow = window - } - - @objc private func handleInteractivePopGesture( - _ gesture: UIPanGestureRecognizer - ) { - switch gesture.state { - case .began: - beginInteractivePop(gesture) - case .changed: - guard interactivePopSource != nil else { return } - let width = max(interactivePopFrame.width, 1) - let progress = min(max(gesture.translation(in: gesture.view).x / width, 0), 1) - updateInteractivePop(progress: progress) - case .ended: - guard interactivePopSource != nil else { return } - let velocity = gesture.velocity(in: gesture.view).x - settleInteractivePop(completes: interactivePopProgress > 0.5 || velocity > 700) - case .cancelled, .failed: - guard interactivePopSource != nil else { return } - settleInteractivePop(completes: false) - default: - break - } - } - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard - let gesture = gestureRecognizer as? UIPanGestureRecognizer, - gesture === interactivePopGesture, - let view = gesture.view, - !pageTransitionAnimating, - pageTransitionSource == nil, - interactivePopSource == nil, - !pageBackSnapshots.isEmpty - else { return false } - - let startX = gesture.location(in: view).x - gesture.translation(in: view).x - let velocity = gesture.velocity(in: view) - return startX <= 24 && velocity.x > 0 && abs(velocity.x) > abs(velocity.y) - } - - private func beginInteractivePop(_ gesture: UIPanGestureRecognizer) { - guard - !pageTransitionAnimating, - pageTransitionSource == nil, - interactivePopSource == nil, - let previousImage = pageBackSnapshots.last, - let window = gesture.view as? UIWindow ?? keyWindow(), - let rootView = window.rootViewController?.view, - let source = makePageSnapshot(of: rootView) - else { - gesture.isEnabled = false - gesture.isEnabled = true - return - } - - let frame = rootView.convert(rootView.bounds, to: window) - let destination = UIImageView(image: previousImage) - destination.contentMode = .scaleToFill - destination.clipsToBounds = true - destination.isUserInteractionEnabled = true - source.clipsToBounds = true - source.isUserInteractionEnabled = true - - destination.frame = frame - source.frame = frame - source.layer.shadowColor = UIColor.black.cgColor - source.layer.shadowOpacity = 0.2 - source.layer.shadowRadius = 12 - source.layer.shadowOffset = CGSize(width: -4, height: 0) - - window.addSubview(destination) - window.addSubview(source) - - interactivePopSource = source - interactivePopDestination = destination - interactivePopFrame = frame - updateInteractivePop(progress: 0) - } - - private func updateInteractivePop(progress: CGFloat) { - guard - let source = interactivePopSource, - let destination = interactivePopDestination - else { return } - - let normalizedProgress = min(max(progress, 0), 1) - let width = interactivePopFrame.width - source.frame.origin.x = interactivePopFrame.minX + width * normalizedProgress - destination.frame.origin.x = interactivePopFrame.minX - width / 3 - + width / 3 * normalizedProgress - interactivePopProgress = normalizedProgress - } - - private func settleInteractivePop(completes: Bool) { - pageTransitionAnimating = true - let remainingProgress = completes - ? 1 - interactivePopProgress - : interactivePopProgress - let duration = max(0.12, 0.28 * Double(remainingProgress)) - - UIView.animate( - withDuration: duration, - delay: 0, - options: [.curveEaseOut, .beginFromCurrentState] - ) { [weak self] in - self?.updateInteractivePop(progress: completes ? 1 : 0) - } completion: { [weak self] _ in - guard let self else { return } - if completes { - self.requestFlutterInteractivePop() - } else { - self.cleanupInteractivePop() - } - } - } - - private func requestFlutterInteractivePop() { - pageTransitionChannel.invokeMethod( - "interactivePop", - arguments: nil - ) { [weak self] response in - guard let self else { return } - let didPop = (response as? NSNumber)?.boolValue ?? false - - if didPop { - if !self.pageBackSnapshots.isEmpty { - self.pageBackSnapshots.removeLast() - } - self.cleanupInteractivePop() - } else { - self.settleInteractivePop(completes: false) - } - } - } - - private func cleanupInteractivePop() { - interactivePopSource?.removeFromSuperview() - interactivePopDestination?.removeFromSuperview() - interactivePopSource = nil - interactivePopDestination = nil - interactivePopFrame = .zero - interactivePopProgress = 0 - pageTransitionAnimating = false - } - private func showAlert(arguments: [String: Any], result: @escaping FlutterResult) { guard let title = arguments["title"] as? String, diff --git a/ios/Runner/NativeGlass/NativeGlassRegistry.swift b/ios/Runner/NativeGlass/NativeGlassRegistry.swift index befcc66..38f4616 100644 --- a/ios/Runner/NativeGlass/NativeGlassRegistry.swift +++ b/ios/Runner/NativeGlass/NativeGlassRegistry.swift @@ -23,38 +23,6 @@ enum NativeGlassRegistry { NativeGlassTabBarFactory(messenger: messenger), withId: NativeGlassTabBarPlatformView.viewType ) - registrar.register( - NativeGlassButtonFactory(messenger: messenger), - withId: NativeGlassButtonPlatformView.viewType - ) - registrar.register( - NativeGlassDropdownMenuFactory(messenger: messenger), - withId: NativeGlassDropdownMenuPlatformView.viewType - ) - registrar.register( - NativeGlassSelectFactory(messenger: messenger), - withId: NativeGlassSelectPlatformView.viewType - ) - registrar.register( - NativeGlassSwitchFactory(messenger: messenger), - withId: NativeGlassSwitchPlatformView.viewType - ) - registrar.register( - NativeGlassConfirmationButtonFactory(messenger: messenger), - withId: NativeGlassConfirmationButtonPlatformView.viewType - ) - registrar.register( - NativeTextViewFactory(messenger: messenger), - withId: NativeTextViewPlatformView.viewType - ) - registrar.register( - NativeTextFieldGroupFactory(messenger: messenger), - withId: NativeTextFieldGroupPlatformView.viewType - ) - registrar.register( - NativeSegmentedControlFactory(messenger: messenger), - withId: NativeSegmentedControlPlatformView.viewType - ) registrar.register( NativeNavigationBarFactory(messenger: messenger), withId: NativeNavigationBarPlatformView.viewType diff --git a/ios/Runner/NativeGlass/NativeGlassSelect.swift b/ios/Runner/NativeGlass/NativeGlassSelect.swift deleted file mode 100644 index b3ac46b..0000000 --- a/ios/Runner/NativeGlass/NativeGlassSelect.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Flutter -import UIKit - -final class NativeGlassSelectFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeGlassSelectPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeGlassSelectPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_glass_select" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let button = UIButton(type: .system) - - private var placeholder = "Select" - private var sfSymbol = "chevron.up.chevron.down" - private var selectedValue: String? - private var options: [NativeGlassSelectOption] = [] - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - parseArguments(args) - buildViewHierarchy() - applyButtonAppearance() - rebuildMenu() - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func parseArguments(_ args: Any?) { - guard let params = args as? [String: Any] else { - return - } - - if let rawPlaceholder = params["placeholder"] as? String, !rawPlaceholder.isEmpty { - placeholder = rawPlaceholder - } - - if let rawSymbol = params["sfSymbol"] as? String, !rawSymbol.isEmpty { - sfSymbol = rawSymbol - } - - if let rawSelectedValue = params["value"] as? String, !rawSelectedValue.isEmpty { - selectedValue = rawSelectedValue - } else { - selectedValue = nil - } - - if let rawOptions = params["options"] as? [[String: Any]] { - options = rawOptions.compactMap(NativeGlassSelectOption.init) - } - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - - button.translatesAutoresizingMaskIntoConstraints = false - button.semanticContentAttribute = .forceRightToLeft - button.contentHorizontalAlignment = .fill - button.accessibilityTraits.insert(.button) - - if #available(iOS 26.0, *) { - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - return - } - - rootView.addSubview(button) - - NSLayoutConstraint.activate([ - button.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - button.topAnchor.constraint(equalTo: rootView.topAnchor), - button.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - } - - private var selectedLabel: String { - if let selectedValue, let option = options.first(where: { $0.value == selectedValue }) { - return option.label - } - - return placeholder - } - - private func applyButtonAppearance() { - let image = symbolImage(named: sfSymbol) - - if #available(iOS 26.0, *) { - var configuration = UIButton.Configuration.glass() - configuration.image = image - configuration.title = selectedLabel - button.configuration = configuration - return - } - - if #available(iOS 15.0, *) { - var configuration = UIButton.Configuration.plain() - configuration.image = image - configuration.title = selectedLabel - button.configuration = configuration - } else { - button.setImage(image, for: .normal) - button.setTitle(selectedLabel, for: .normal) - } - } - - private func rebuildMenu() { - button.removeTarget(self, action: #selector(handleLegacyTap), for: .touchUpInside) - - if #available(iOS 14.0, *) { - button.menu = UIMenu(children: options.map(makeAction)) - button.showsMenuAsPrimaryAction = true - } else { - button.addTarget(self, action: #selector(handleLegacyTap), for: .touchUpInside) - } - } - - @available(iOS 14.0, *) - private func makeAction(for option: NativeGlassSelectOption) -> UIAction { - UIAction( - title: option.label, - state: option.value == selectedValue ? .on : .off - ) { [weak self] _ in - self?.setSelectedValue(option.value, notifyFlutter: true) - } - } - - private func symbolImage(named systemName: String) -> UIImage? { - guard systemName != "none" else { return nil } - - return UIImage(systemName: systemName)? - .withRenderingMode(.alwaysTemplate) - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateSelection": - guard - let arguments = call.arguments as? [String: Any], - let value = arguments["value"] as? String, - !value.isEmpty - else { - result( - FlutterError( - code: "bad_args", - message: "Expected a non-empty value string.", - details: nil - ) - ) - return - } - - setSelectedValue(value, notifyFlutter: false) - result(nil) - - case "updateConfiguration": - parseArguments(call.arguments) - applyButtonAppearance() - rebuildMenu() - result(nil) - - default: - result(FlutterMethodNotImplemented) - } - } - - private func setSelectedValue(_ value: String, notifyFlutter: Bool) { - selectedValue = value - applyButtonAppearance() - rebuildMenu() - - if notifyFlutter { - let feedback = UIImpactFeedbackGenerator(style: .light) - feedback.impactOccurred(intensity: 0.55) - channel.invokeMethod("onChanged", arguments: ["value": value]) - } - } - - @objc - private func handleLegacyTap() { - guard !options.isEmpty, let controller = nearestViewController() else { - return - } - - let sheet = UIAlertController(title: placeholder, message: nil, preferredStyle: .actionSheet) - - for option in options { - let title = option.value == selectedValue ? "✓ \(option.label)" : option.label - sheet.addAction( - UIAlertAction(title: title, style: .default) { [weak self] _ in - self?.setSelectedValue(option.value, notifyFlutter: true) - } - ) - } - - sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - if let popover = sheet.popoverPresentationController { - if #available(iOS 26.0, *) { - popover.sourceItem = button - } else { - popover.sourceView = rootView - popover.sourceRect = rootView.bounds - } - } - - controller.present(sheet, animated: true) - } - - private func nearestViewController() -> UIViewController? { - if let nextResponder = sequence(first: rootView.next, next: { $0?.next }) - .first(where: { $0 is UIViewController }) as? UIViewController - { - return nextResponder - } - - let rootController = rootView.window?.rootViewController - var topController = rootController - - while let presented = topController?.presentedViewController { - topController = presented - } - - return topController - } -} - -private struct NativeGlassSelectOption { - let value: String - let label: String - - init(value: String, label: String) { - self.value = value - self.label = label - } - - init?(_ dictionary: [String: Any]) { - guard - let value = dictionary["value"] as? String, - let label = dictionary["label"] as? String, - !value.isEmpty, - !label.isEmpty - else { - return nil - } - - self.init(value: value, label: label) - } -} diff --git a/ios/Runner/NativeGlass/NativeGlassSwitch.swift b/ios/Runner/NativeGlass/NativeGlassSwitch.swift deleted file mode 100644 index fd58ec4..0000000 --- a/ios/Runner/NativeGlass/NativeGlassSwitch.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Flutter -import UIKit - -final class NativeGlassSwitchFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeGlassSwitchPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeGlassSwitchPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_glass_switch" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let toggle = UISwitch(frame: .zero) - - private var isOn = false - private var isEnabled = true - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - parseArguments(args) - buildViewHierarchy() - applyState(animated: false) - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func parseArguments(_ args: Any?) { - guard let params = args as? [String: Any] else { - return - } - - isOn = params["value"] as? Bool ?? isOn - isEnabled = params["enabled"] as? Bool ?? isEnabled - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - - toggle.translatesAutoresizingMaskIntoConstraints = false - toggle.addTarget(self, action: #selector(handleValueChanged), for: .valueChanged) - - rootView.addSubview(toggle) - - NSLayoutConstraint.activate([ - toggle.centerXAnchor.constraint(equalTo: rootView.centerXAnchor), - toggle.centerYAnchor.constraint(equalTo: rootView.centerYAnchor), - ]) - } - - private func applyState(animated: Bool) { - toggle.setOn(isOn, animated: animated) - toggle.isEnabled = isEnabled - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateValue": - guard - let arguments = call.arguments as? [String: Any], - let value = arguments["value"] as? Bool - else { - result( - FlutterError( - code: "bad_args", - message: "Expected a boolean value.", - details: nil - ) - ) - return - } - - isOn = value - applyState(animated: true) - result(nil) - - case "updateEnabled": - guard - let arguments = call.arguments as? [String: Any], - let enabled = arguments["enabled"] as? Bool - else { - result( - FlutterError( - code: "bad_args", - message: "Expected an enabled boolean.", - details: nil - ) - ) - return - } - - isEnabled = enabled - applyState(animated: false) - result(nil) - - default: - result(FlutterMethodNotImplemented) - } - } - - @objc - private func handleValueChanged() { - isOn = toggle.isOn - - let feedback = UIImpactFeedbackGenerator(style: .light) - feedback.impactOccurred(intensity: 0.5) - - channel.invokeMethod("onChanged", arguments: ["value": isOn]) - } -} diff --git a/ios/Runner/NativeGlass/NativeSegmentedControl.swift b/ios/Runner/NativeGlass/NativeSegmentedControl.swift deleted file mode 100644 index 6f3bb9f..0000000 --- a/ios/Runner/NativeGlass/NativeSegmentedControl.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Flutter -import UIKit - -final class NativeSegmentedControlFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeSegmentedControlPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeSegmentedControlPlatformView: NSObject, FlutterPlatformView { - static let viewType = "techpie/native_segmented_control" - - private let rootView: UIView - private let channel: FlutterMethodChannel - private let control = UISegmentedControl() - - private var segments: [String] = [] - private var selectedIndex = 0 - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - parseConfiguration(args as? [String: Any] ?? [:]) - buildViewHierarchy() - applyConfiguration() - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - - control.translatesAutoresizingMaskIntoConstraints = false - control.addTarget(self, action: #selector(handleValueChanged), for: .valueChanged) - - rootView.addSubview(control) - - NSLayoutConstraint.activate([ - control.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - control.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - control.centerYAnchor.constraint(equalTo: rootView.centerYAnchor), - ]) - } - - private func parseConfiguration(_ params: [String: Any]) { - segments = params["segments"] as? [String] ?? segments - selectedIndex = params["value"] as? Int ?? selectedIndex - } - - private func applyConfiguration() { - control.removeAllSegments() - - for (index, title) in segments.enumerated() { - control.insertSegment(withTitle: title, at: index, animated: false) - } - - if segments.indices.contains(selectedIndex) { - control.selectedSegmentIndex = selectedIndex - } else if !segments.isEmpty { - selectedIndex = 0 - control.selectedSegmentIndex = 0 - } else { - selectedIndex = UISegmentedControl.noSegment - control.selectedSegmentIndex = UISegmentedControl.noSegment - } - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateConfiguration": - parseConfiguration(call.arguments as? [String: Any] ?? [:]) - applyConfiguration() - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - @objc - private func handleValueChanged() { - selectedIndex = control.selectedSegmentIndex - channel.invokeMethod("onChanged", arguments: ["value": selectedIndex]) - } -} diff --git a/ios/Runner/NativeGlass/NativeTextFieldGroup.swift b/ios/Runner/NativeGlass/NativeTextFieldGroup.swift deleted file mode 100644 index 9b4ed7d..0000000 --- a/ios/Runner/NativeGlass/NativeTextFieldGroup.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Flutter -import UIKit - -final class NativeTextFieldGroupFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeTextFieldGroupPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeTextFieldGroupPlatformView: NSObject, FlutterPlatformView, UITextFieldDelegate { - static let viewType = "techpie/native_text_field_group" - - private let rootView: UIView - private let containerView = UIView() - private let stackView = UIStackView() - private let channel: FlutterMethodChannel - - private var fields: [UITextField] = [] - private var separators: [UIView] = [] - private var itemConfigurations: [[String: Any]] = [] - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - buildViewHierarchy() - applyConfiguration(args as? [String: Any] ?? [:]) - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - - containerView.translatesAutoresizingMaskIntoConstraints = false - containerView.backgroundColor = .secondarySystemBackground - containerView.layer.cornerRadius = 16 - containerView.layer.cornerCurve = .continuous - containerView.clipsToBounds = true - - stackView.axis = .vertical - stackView.alignment = .fill - stackView.distribution = .fill - stackView.translatesAutoresizingMaskIntoConstraints = false - - rootView.addSubview(containerView) - containerView.addSubview(stackView) - - NSLayoutConstraint.activate([ - containerView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - containerView.topAnchor.constraint(equalTo: rootView.topAnchor), - containerView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - stackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - stackView.topAnchor.constraint(equalTo: containerView.topAnchor), - stackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - ]) - } - - private func applyConfiguration(_ params: [String: Any]) { - itemConfigurations = params["items"] as? [[String: Any]] ?? [] - rebuildFieldsIfNeeded() - - for (index, configuration) in itemConfigurations.enumerated() where index < fields.count { - apply(configuration: configuration, to: fields[index]) - } - } - - private func rebuildFieldsIfNeeded() { - guard fields.count != itemConfigurations.count else { - return - } - - for view in stackView.arrangedSubviews { - stackView.removeArrangedSubview(view) - view.removeFromSuperview() - } - fields.removeAll() - separators.removeAll() - - for index in itemConfigurations.indices { - if index > 0 { - let separatorRow = UIView() - let separator = UIView() - separator.backgroundColor = .separator - separator.translatesAutoresizingMaskIntoConstraints = false - separatorRow.addSubview(separator) - separatorRow.translatesAutoresizingMaskIntoConstraints = false - separatorRow.heightAnchor.constraint(equalToConstant: 0.5).isActive = true - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: separatorRow.leadingAnchor, constant: 16), - separator.trailingAnchor.constraint(equalTo: separatorRow.trailingAnchor), - separator.topAnchor.constraint(equalTo: separatorRow.topAnchor), - separator.bottomAnchor.constraint(equalTo: separatorRow.bottomAnchor), - ]) - separators.append(separator) - stackView.addArrangedSubview(separatorRow) - } - - let field = UITextField() - field.delegate = self - field.borderStyle = .none - field.clearButtonMode = .whileEditing - field.autocapitalizationType = .none - field.autocorrectionType = .no - field.adjustsFontForContentSizeCategory = true - field.font = .preferredFont(forTextStyle: .body) - field.tag = index - - let row = UIView() - row.translatesAutoresizingMaskIntoConstraints = false - field.translatesAutoresizingMaskIntoConstraints = false - row.addSubview(field) - - NSLayoutConstraint.activate([ - field.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), - field.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), - field.topAnchor.constraint(equalTo: row.topAnchor), - field.bottomAnchor.constraint(equalTo: row.bottomAnchor), - row.heightAnchor.constraint(equalToConstant: 56), - ]) - - fields.append(field) - stackView.addArrangedSubview(row) - } - } - - private func apply(configuration: [String: Any], to field: UITextField) { - field.text = configuration["text"] as? String ?? "" - field.placeholder = configuration["placeholder"] as? String - field.keyboardType = uiKeyboardType(configuration["keyboardType"] as? String ?? "text") - field.returnKeyType = uiReturnKeyType(configuration["textInputAction"] as? String) - field.isSecureTextEntry = configuration["obscureText"] as? Bool ?? false - field.isEnabled = configuration["enabled"] as? Bool ?? true - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateTexts": - guard let params = call.arguments as? [String: Any] else { - result(nil) - return - } - - let texts = params["texts"] as? [String] ?? [] - for (index, text) in texts.enumerated() where index < fields.count { - if fields[index].text != text { - fields[index].text = text - } - } - result(nil) - case "updateConfiguration": - applyConfiguration(call.arguments as? [String: Any] ?? [:]) - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - private func emitChanged(field: UITextField) { - channel.invokeMethod( - "onChanged", - arguments: ["index": field.tag, "text": field.text ?? ""] - ) - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - emitChanged(field: textField) - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - channel.invokeMethod( - "onSubmitted", - arguments: ["index": textField.tag, "text": textField.text ?? ""] - ) - return true - } - - private func uiKeyboardType(_ value: String) -> UIKeyboardType { - switch value { - case "emailAddress": - return .emailAddress - case "phone": - return .phonePad - case "url": - return .URL - case "number": - return .numberPad - default: - return .default - } - } - - private func uiReturnKeyType(_ value: String?) -> UIReturnKeyType { - switch value { - case "done": - return .done - case "go": - return .go - case "search": - return .search - case "send": - return .send - default: - return .next - } - } -} diff --git a/ios/Runner/NativeGlass/NativeTextView.swift b/ios/Runner/NativeGlass/NativeTextView.swift deleted file mode 100644 index 412ce98..0000000 --- a/ios/Runner/NativeGlass/NativeTextView.swift +++ /dev/null @@ -1,195 +0,0 @@ -import Flutter -import UIKit - -final class NativeTextViewFactory: NSObject, FlutterPlatformViewFactory { - private let messenger: FlutterBinaryMessenger - - init(messenger: FlutterBinaryMessenger) { - self.messenger = messenger - super.init() - } - - func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { - FlutterStandardMessageCodec.sharedInstance() - } - - func create( - withFrame frame: CGRect, - viewIdentifier viewId: Int64, - arguments args: Any? - ) -> FlutterPlatformView { - NativeTextViewPlatformView( - frame: frame, - viewId: viewId, - arguments: args, - messenger: messenger - ) - } -} - -final class NativeTextViewPlatformView: NSObject, FlutterPlatformView, UITextViewDelegate { - static let viewType = "techpie/native_text_view" - - private let rootView: UIView - private let textView = UITextView() - private let textViewPlaceholderLabel = UILabel() - private let channel: FlutterMethodChannel - - private var text = "" - - init( - frame: CGRect, - viewId: Int64, - arguments args: Any?, - messenger: FlutterBinaryMessenger - ) { - rootView = UIView(frame: frame) - channel = FlutterMethodChannel( - name: "\(Self.viewType)/\(viewId)", - binaryMessenger: messenger - ) - - super.init() - - buildViewHierarchy() - applyConfiguration(args as? [String: Any] ?? [:]) - - channel.setMethodCallHandler { [weak self] call, result in - self?.handle(call: call, result: result) - } - } - - deinit { - channel.setMethodCallHandler(nil) - } - - func view() -> UIView { - rootView - } - - private func buildViewHierarchy() { - rootView.backgroundColor = .clear - - textView.delegate = self - textView.font = .preferredFont(forTextStyle: .body) - textView.adjustsFontForContentSizeCategory = true - textView.autocapitalizationType = .none - textView.autocorrectionType = .no - textView.isScrollEnabled = true - textView.textContainerInset = UIEdgeInsets(top: 8, left: 6, bottom: 8, right: 6) - applyTextViewBorderStyle() - - textViewPlaceholderLabel.font = .preferredFont(forTextStyle: .body) - textViewPlaceholderLabel.adjustsFontForContentSizeCategory = true - textViewPlaceholderLabel.textColor = .placeholderText - textViewPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false - textView.addSubview(textViewPlaceholderLabel) - - NSLayoutConstraint.activate([ - textViewPlaceholderLabel.leadingAnchor.constraint( - equalTo: textView.leadingAnchor, constant: 11), - textViewPlaceholderLabel.trailingAnchor.constraint( - lessThanOrEqualTo: textView.trailingAnchor, constant: -11), - textViewPlaceholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: 8), - ]) - - textView.translatesAutoresizingMaskIntoConstraints = false - rootView.addSubview(textView) - - NSLayoutConstraint.activate([ - textView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - textView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - textView.topAnchor.constraint(equalTo: rootView.topAnchor), - textView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - ]) - } - - private func applyConfiguration(_ params: [String: Any]) { - text = params["text"] as? String ?? text - let placeholderText = params["placeholder"] as? String ?? "" - textViewPlaceholderLabel.text = placeholderText - - textView.text = text - updateTextViewPlaceholderVisibility() - textView.isEditable = params["enabled"] as? Bool ?? true - - let keyboardType = params["keyboardType"] as? String ?? "text" - textView.keyboardType = uiKeyboardType(keyboardType) - - let returnKeyType = uiReturnKeyType(params["textInputAction"] as? String) - textView.returnKeyType = returnKeyType - } - - private func applyTextViewBorderStyle() { - textView.backgroundColor = .secondarySystemBackground - textView.layer.borderWidth = 1 - textView.layer.cornerRadius = 10 - textView.layer.borderColor = UIColor.separator.cgColor - } - - private func handle(call: FlutterMethodCall, result: FlutterResult) { - switch call.method { - case "updateText": - guard let params = call.arguments as? [String: Any] else { - result(nil) - return - } - let nextText = params["text"] as? String ?? "" - if text != nextText { - text = nextText - textView.text = nextText - updateTextViewPlaceholderVisibility() - } - result(nil) - case "updateConfiguration": - applyConfiguration(call.arguments as? [String: Any] ?? [:]) - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - private func emitChanged(_ nextText: String) { - text = nextText - channel.invokeMethod("onChanged", arguments: ["text": nextText]) - } - - func textViewDidChange(_ textView: UITextView) { - updateTextViewPlaceholderVisibility() - emitChanged(textView.text ?? "") - } - - private func updateTextViewPlaceholderVisibility() { - textViewPlaceholderLabel.isHidden = !(textView.text ?? "").isEmpty - } - - private func uiKeyboardType(_ value: String) -> UIKeyboardType { - switch value { - case "emailAddress": - return .emailAddress - case "phone": - return .phonePad - case "url": - return .URL - case "number": - return .numberPad - default: - return .default - } - } - - private func uiReturnKeyType(_ value: String?) -> UIReturnKeyType { - switch value { - case "done": - return .done - case "go": - return .go - case "search": - return .search - case "send": - return .send - default: - return .next - } - } -} diff --git a/lib/models/feature.dart b/lib/models/feature.dart index 4df06a5..ec90d0b 100644 --- a/lib/models/feature.dart +++ b/lib/models/feature.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../pages/oa_gym_page.dart'; +import '../widgets/adaptive_page_navigation.dart'; enum FeatureMode { native, @@ -54,8 +55,9 @@ final featureEntries = [ id: 'oa_gym', description: '场馆预约', mode: FeatureMode.native, - nativeEntry: (context) => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const OaGymPage()), + nativeEntry: (context) => pushAdaptivePage( + context, + builder: (_) => const OaGymPage(), ), icon: const Icon(Icons.sports_tennis), ), diff --git a/lib/pages/assignments_page.dart b/lib/pages/assignments_page.dart index 3d263da..6e4cbd2 100644 --- a/lib/pages/assignments_page.dart +++ b/lib/pages/assignments_page.dart @@ -8,11 +8,14 @@ import '../models/assignment.dart'; import '../models/assignment_overrides.dart'; import '../services/assignment_service.dart'; import '../services/service_provider.dart'; +import '../utils/adaptive_motion.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; import '../widgets/swipeable_card.dart'; import 'hidden_assignments_page.dart'; @@ -183,11 +186,9 @@ class _AssignmentsPageState extends State { _toggleExamsVisibility(); case 'hidden': unawaited( - Navigator.push( + pushAdaptivePage( context, - MaterialPageRoute( - builder: (_) => const HiddenAssignmentsPage(), - ), + builder: (_) => const HiddenAssignmentsPage(), ), ); } @@ -208,11 +209,9 @@ class _AssignmentsPageState extends State { _toggleExamsVisibility(); case 'hidden': unawaited( - Navigator.push( + pushAdaptivePage( context, - MaterialPageRoute( - builder: (_) => const HiddenAssignmentsPage(), - ), + builder: (_) => const HiddenAssignmentsPage(), ), ); } @@ -283,6 +282,67 @@ class _AssignmentsPageState extends State { ) { final allSelected = visible.isNotEmpty && _selected.length == visible.length; + if (isIos()) { + return IosNativeNavigationBar( + title: '已选择 ${_selected.length} 个', + selectionMode: true, + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'cancelSelection', + title: 'Cancel', + accessibilityLabel: '退出多选', + placementGroup: 'leading-main', + ), + ], + trailingItems: [ + IosNativeNavigationBarItem( + id: 'toggleSelectAll', + title: allSelected ? 'Deselect All' : 'Select All', + enabled: visible.isNotEmpty, + accessibilityLabel: allSelected ? '全不选' : '全选', + placementGroup: 'selection-actions', + ), + IosNativeNavigationBarItem( + id: 'selectionMore', + sfSymbol: 'ellipsis.circle', + accessibilityLabel: '更多选择操作', + placementGroup: 'selection-actions', + menuItems: [ + const IosNativeNavigationBarMenuItem( + value: 'invertSelection', + title: '反选', + sfSymbol: 'arrow.triangle.2.circlepath', + ), + IosNativeNavigationBarMenuItem( + value: 'resetSelected', + title: '重置状态', + sfSymbol: 'arrow.counterclockwise', + destructive: _selected.isNotEmpty, + ), + ], + ), + ], + onItemPressed: (id) { + switch (id) { + case 'cancelSelection': + _exitSelection(); + case 'toggleSelectAll': + if (visible.isNotEmpty) _selectAll(visible); + } + }, + onMenuSelected: (_, value) { + switch (value) { + case 'invertSelection': + _invertSelection(visible); + case 'resetSelected': + if (_selected.isNotEmpty) { + unawaited(_resetSelected(service)); + } + } + }, + ); + } + return BlurredAppBar( leading: IconButton( icon: const Icon(Icons.close), @@ -399,7 +459,12 @@ class _AssignmentsPageState extends State { return RefreshIndicator( onRefresh: () async => _refresh(service), child: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ InkWell( onTap: () => setState(() => _pastCollapsed = !_pastCollapsed), @@ -754,7 +819,7 @@ class _AssignmentCard extends StatelessWidget { clipBehavior: Clip.antiAlias, color: selected ? theme.colorScheme.secondaryContainer : null, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), side: selected ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none, @@ -813,9 +878,16 @@ class _AssignmentCard extends StatelessWidget { ], const Spacer(), AnimatedSwitcher( - duration: const Duration(milliseconds: 280), - switchInCurve: Curves.easeInOutCubicEmphasized, - switchOutCurve: Curves.easeInOutCubicEmphasized, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 280), + ), + switchInCurve: isIos() + ? Curves.easeInOut + : Curves.easeInOutCubicEmphasized, + switchOutCurve: isIos() + ? Curves.easeInOut + : Curves.easeInOutCubicEmphasized, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: ScaleTransition(scale: animation, child: child), @@ -871,9 +943,14 @@ class _AssignmentCard extends StatelessWidget { ), const Spacer(), AnimatedSwitcher( - duration: const Duration(milliseconds: 320), - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeInCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 320), + ), + switchInCurve: + isIos() ? Curves.easeOut : Curves.easeOutBack, + switchOutCurve: + isIos() ? Curves.easeIn : Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: ScaleTransition(scale: animation, child: child), diff --git a/lib/pages/debug_log_page.dart b/lib/pages/debug_log_page.dart index c86656b..71f8ff4 100644 --- a/lib/pages/debug_log_page.dart +++ b/lib/pages/debug_log_page.dart @@ -1,10 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../services/debug_logger.dart'; import '../services/service_provider.dart'; import '../utils/platform.dart'; +import '../widgets/adaptive_alert_dialog.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_glass_confirmation_button.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; class DebugLogPage extends StatelessWidget { const DebugLogPage({super.key}); @@ -12,38 +15,41 @@ class DebugLogPage extends StatelessWidget { @override Widget build(BuildContext context) { final logger = ServiceProvider.of(context).debugLogger; + final useIosChrome = isIos(); final useLegacyIosChrome = usesLegacyIosChrome(); - final topPad = useLegacyIosChrome + final topPad = useIosChrome || useLegacyIosChrome ? 0.0 : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top; return Scaffold( - extendBodyBehindAppBar: !useLegacyIosChrome, - appBar: BlurredAppBar( - title: const Text('Debug Logs'), - actions: [ - if (isIos()) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: Center( - child: IosGlassConfirmationButton( - confirmTitle: '清空所有日志?', - confirmLabel: '清空', - icon: Icons.delete_outline, + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: 'Debug Logs', + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'clear', sfSymbol: 'trash', - destructive: true, - onConfirmed: logger.clear, + role: IosNativeNavigationBarItemRole.destructive, + accessibilityLabel: 'Clear logs', ), - ), + ], + onItemPressed: (id) { + if (id == 'clear') { + unawaited(_confirmClearLogs(context, logger)); + } + }, ) - else - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: logger.clear, - tooltip: 'Clear logs', + : BlurredAppBar( + title: const Text('Debug Logs'), + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: logger.clear, + tooltip: 'Clear logs', + ), + ], ), - ], - ), body: ListenableBuilder( listenable: logger, builder: (context, _) { @@ -63,6 +69,26 @@ class DebugLogPage extends StatelessWidget { ), ); } + + Future _confirmClearLogs( + BuildContext context, + DebugLogger logger, + ) async { + final confirmed = await showAdaptiveAlertDialog( + context: context, + title: '清空所有日志?', + message: '此操作无法撤销。', + actions: const [ + AdaptiveAlertAction(label: '取消', value: false), + AdaptiveAlertAction( + label: '清空', + value: true, + isDestructive: true, + ), + ], + ); + if (confirmed == true) logger.clear(); + } } class _LogTile extends StatelessWidget { diff --git a/lib/pages/hidden_assignments_page.dart b/lib/pages/hidden_assignments_page.dart index d1bd50e..996dce7 100644 --- a/lib/pages/hidden_assignments_page.dart +++ b/lib/pages/hidden_assignments_page.dart @@ -7,8 +7,11 @@ import '../models/assignment_overrides.dart'; import '../services/assignment_service.dart'; import '../services/service_provider.dart'; import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; +import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; class HiddenAssignmentsPage extends StatefulWidget { const HiddenAssignmentsPage({super.key}); @@ -125,7 +128,7 @@ class _HiddenAssignmentsPageState extends State { onItemPressed: (id) { switch (id) { case 'back': - unawaited(Navigator.maybePop(context)); + unawaited(maybePopAdaptivePage(context)); case 'toggleSelectAll': if (hiddenKeys.isNotEmpty) { _toggleSelectAll(hiddenKeys); @@ -196,6 +199,17 @@ class _HiddenAssignmentsPageState extends State { ); } + Future _restoreOne(String key) async { + final service = ServiceProvider.of(context).assignmentService; + await service.unhide(key); + if (!mounted) return; + showAdaptiveFeedback( + context: context, + message: '已恢复事项', + style: AdaptiveFeedbackStyle.success, + ); + } + Widget _buildList( BuildContext context, AssignmentService service, @@ -245,6 +259,16 @@ class _HiddenAssignmentsPageState extends State { _selected.add(key); }); }, + trailing: _selectionMode + ? null + : AdaptiveButton( + icon: Icons.restore, + sfSymbol: 'arrow.uturn.backward', + width: 44, + height: 44, + accessibilityLabel: '恢复 ${a?.title ?? key}', + onPressed: () => unawaited(_restoreOne(key)), + ), ); }, ); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9d2408d..62c585c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -12,12 +12,16 @@ import '../models/feature.dart'; import '../services/assignment_service.dart'; import '../services/schedule_service.dart'; import '../services/service_provider.dart'; +import '../utils/adaptive_motion.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; +import '../widgets/adaptive_button.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; import '../widgets/app_card.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; import 'generic_webview_page.dart'; import 'login_page.dart'; @@ -34,6 +38,7 @@ class _HomePageState extends State with TickerProviderStateMixin { List _todayCourses = []; List _periods = defaultPeriods.toList(); bool _initialized = false; + bool _animationsEnabled = true; // Refresh every minute to update course now/past state and day changes Timer? _refreshTimer; @@ -54,6 +59,16 @@ class _HomePageState extends State with TickerProviderStateMixin { @override void didChangeDependencies() { super.didChangeDependencies(); + final animationsEnabled = appAnimationsEnabled(context); + if (_animationsEnabled != animationsEnabled) { + _animationsEnabled = animationsEnabled; + if (!animationsEnabled) { + _staggerController?.dispose(); + _staggerController = null; + _itemSlides = []; + _itemFades = []; + } + } if (!_initialized) { _initialized = true; _schedule = ServiceProvider.of(context).scheduleService; @@ -131,6 +146,12 @@ class _HomePageState extends State with TickerProviderStateMixin { void _runStaggerAnimation(int count) { _staggerController?.dispose(); + if (!_animationsEnabled) { + _staggerController = null; + _itemSlides = []; + _itemFades = []; + return; + } _staggerController = AnimationController( vsync: this, duration: Duration(milliseconds: 300 + count * 60), @@ -141,7 +162,11 @@ class _HomePageState extends State with TickerProviderStateMixin { for (int i = 0; i < count; i++) { final start = (i * 0.12).clamp(0.0, 0.6); final end = (start + 0.5).clamp(start + 0.1, 1.0); - final interval = Interval(start, end, curve: Curves.easeOutCubic); + final interval = Interval( + start, + end, + curve: appAnimationCurve(Curves.easeOutCubic), + ); _itemSlides.add( Tween(begin: 24.0, end: 0.0).animate( CurvedAnimation(parent: _staggerController!, curve: interval), @@ -278,7 +303,7 @@ class _HomePageState extends State with TickerProviderStateMixin { 16, topInset, 16, - 120, + AppShellMetrics.bottomContentPaddingOf(context), ), children: [ Card.filled( @@ -329,8 +354,11 @@ class _HomePageState extends State with TickerProviderStateMixin { return Card.outlined( clipBehavior: Clip.antiAlias, child: AnimatedSize( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 350), + ), + curve: appAnimationCurve(Curves.easeOutCubic), alignment: Alignment.topCenter, child: Column( key: ValueKey('apps-${features.length}-$rows'), @@ -374,13 +402,12 @@ class _HomePageState extends State with TickerProviderStateMixin { final cookies = _buildCookiesForFeature(feature.cookieType); if (feature.url != null) { unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => GenericWebViewPage( - title: feature.description, - url: feature.url!, - cookies: cookies, - ), + pushAdaptivePage( + context, + builder: (_) => GenericWebViewPage( + title: feature.description, + url: feature.url!, + cookies: cookies, ), ), ); @@ -435,6 +462,8 @@ class _HomePageState extends State with TickerProviderStateMixin { icon: Icons.login_rounded, title: '登录以查看今日课程', subtitle: '连接你的教务系统账号', + actionLabel: '登录', + actionSfSymbol: 'person.crop.circle.badge.checkmark', onTap: () async { await presentLoginPage(context); if (!mounted) return; @@ -459,13 +488,22 @@ class _HomePageState extends State with TickerProviderStateMixin { return Card.outlined( clipBehavior: Clip.antiAlias, child: AnimatedSize( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 350), + ), + curve: appAnimationCurve(Curves.easeOutCubic), alignment: Alignment.topCenter, child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 300), + ), + switchInCurve: appAnimationCurve(Curves.easeOutCubic), + switchOutCurve: appAnimationCurve( + Curves.easeInCubic, + iosCurve: Curves.easeInOut, + ), transitionBuilder: (child, animation) { return FadeTransition(opacity: animation, child: child); }, @@ -551,13 +589,22 @@ class _HomePageState extends State with TickerProviderStateMixin { return Card.outlined( clipBehavior: Clip.antiAlias, child: AnimatedSize( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 350), + ), + curve: appAnimationCurve(Curves.easeOutCubic), alignment: Alignment.topCenter, child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 300), + ), + switchInCurve: appAnimationCurve(Curves.easeOutCubic), + switchOutCurve: appAnimationCurve( + Curves.easeInCubic, + iosCurve: Curves.easeInOut, + ), transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), layoutBuilder: (currentChild, previousChildren) { @@ -683,6 +730,8 @@ class _HomePageState extends State with TickerProviderStateMixin { required IconData icon, required String title, required String subtitle, + String? actionLabel, + String? actionSfSymbol, VoidCallback? onTap, }) { final inner = SizedBox( @@ -713,19 +762,23 @@ class _HomePageState extends State with TickerProviderStateMixin { color: theme.colorScheme.onSurfaceVariant, ), ), + if (onTap != null && actionLabel != null) ...[ + const SizedBox(height: 16), + AdaptiveButton( + label: actionLabel, + icon: icon, + sfSymbol: actionSfSymbol ?? 'arrow.right', + role: AdaptiveButtonRole.prominent, + width: 180, + onPressed: onTap, + accessibilityLabel: actionLabel, + ), + ], ], ), ), ); - if (onTap != null) { - return InkWell( - key: key, - onTap: onTap, - child: inner, - ); - } - return Container(key: key, child: inner); } @@ -792,13 +845,19 @@ class _HomePageState extends State with TickerProviderStateMixin { ); return AnimatedOpacity( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 300), + ), + curve: appAnimationCurve(Curves.easeOutCubic), opacity: isPast ? 0.5 : 1.0, child: ListTile( title: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 300), + ), + curve: appAnimationCurve(Curves.easeOutCubic), style: titleStyle, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -874,7 +933,10 @@ class _HomePageState extends State with TickerProviderStateMixin { }), ), AnimatedCrossFade( - duration: const Duration(milliseconds: 250), + duration: appAnimationDuration( + context, + const Duration(milliseconds: 250), + ), crossFadeState: _debugPanelExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, @@ -982,9 +1044,15 @@ class _CourseBadge extends StatelessWidget { status == _CourseStatus.soon || status == _CourseStatus.ongoing; return AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 350), + ), + switchInCurve: appAnimationCurve(Curves.easeOutCubic), + switchOutCurve: appAnimationCurve( + Curves.easeInCubic, + iosCurve: Curves.easeInOut, + ), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index ff2a25a..f2d824c 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -8,8 +8,10 @@ import '../services/schedule_service.dart'; import '../services/service_provider.dart'; import '../services/uni_auth_service.dart'; import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; import '../widgets/adaptive_feedback.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -52,8 +54,9 @@ Future presentLoginPage(BuildContext context) async { } if (context.mounted) { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LoginPage()), + await pushAdaptivePage( + context, + builder: (_) => const LoginPage(), ); } } @@ -254,7 +257,7 @@ class _LoginPageState extends State { ], onItemPressed: (id) { if (id == 'back') { - unawaited(Navigator.maybePop(context)); + unawaited(maybePopAdaptivePage(context)); } }, ), @@ -270,26 +273,15 @@ class _LoginPageState extends State { _IosInlineFeedback(message: _inlineMessage!), const SizedBox(height: 16), ], - FilledButton( + AdaptiveButton( onPressed: _loading ? null : _geekpieLogin, - child: SizedBox( - height: liquidGlass ? 56 : 52, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (_loading) ...[ - const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - ], - const Text('通过 GeekPie Uni-Auth 登录'), - ], - ), - ), + icon: Icons.login, + sfSymbol: 'person.crop.circle.badge.plus', + label: '通过 GeekPie Uni-Auth 登录', + role: AdaptiveButtonRole.prominent, + loading: _loading, + height: liquidGlass ? 56 : 52, + accessibilityLabel: '通过 GeekPie Uni-Auth 登录', ), ], ), diff --git a/lib/pages/oa_gym_page.dart b/lib/pages/oa_gym_page.dart index da30ff7..dafed22 100644 --- a/lib/pages/oa_gym_page.dart +++ b/lib/pages/oa_gym_page.dart @@ -1,13 +1,18 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import '../models/oa_gym.dart'; import '../services/service_provider.dart'; import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; +import '../widgets/adaptive_date_picker.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; import 'login_page.dart'; import 'third_party_accounts_page.dart'; @@ -21,38 +26,42 @@ class OaGymPage extends StatefulWidget { class _OaGymPageState extends State with SingleTickerProviderStateMixin { late final TabController _tabController; + int _selectedTab = 0; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(_onTabChanged); + } + + void _onTabChanged() { + if (_selectedTab == _tabController.index || !mounted) return; + setState(() => _selectedTab = _tabController.index); } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } - Future _handleLogin() async { + Future _handlePrerequisite() async { final sp = ServiceProvider.of(context); - final alreadyLoggedIn = sp.authService.isLoggedIn; + if (sp.authService.isLoggedIn) { + await _openThirdPartyAccounts(); + return; + } await presentLoginPage(context); if (!mounted) return; - // Already logged into the primary SSO account but missing the eGate - // binding — route the user to the third-party accounts page instead of - // re-showing the login sheet. - if (alreadyLoggedIn && sp.authService.isLoggedIn) { - unawaited(_openThirdPartyAccounts()); - } setState(() {}); } Future _openThirdPartyAccounts() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ThirdPartyAccountsPage(), - ), + await pushAdaptivePage( + context, + builder: (_) => const ThirdPartyAccountsPage(), ); } @@ -84,7 +93,7 @@ class _OaGymPageState extends State onItemPressed: (id) { switch (id) { case 'back': - unawaited(Navigator.maybePop(context)); + unawaited(maybePopAdaptivePage(context)); } }, ) @@ -99,20 +108,37 @@ class _OaGymPageState extends State SizedBox(height: topInset), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Card.outlined( - clipBehavior: Clip.antiAlias, - child: TabBar( - controller: _tabController, - tabs: const [ - Tab( - text: '预约', - icon: Icon(Icons.event_available), + child: isIos() + ? CupertinoSlidingSegmentedControl( + groupValue: _selectedTab, + children: const { + 0: Text('预约'), + 1: Text('查询'), + 2: Text('个人'), + }, + onValueChanged: (value) { + if (value != null) { + _tabController.animateTo(value); + } + }, + ) + : Card.outlined( + clipBehavior: Clip.antiAlias, + child: TabBar( + controller: _tabController, + tabs: const [ + Tab( + text: '预约', + icon: Icon(Icons.event_available), + ), + Tab(text: '查询', icon: Icon(Icons.search)), + Tab( + text: '个人', + icon: Icon(Icons.person_outline), + ), + ], + ), ), - Tab(text: '查询', icon: Icon(Icons.search)), - Tab(text: '个人', icon: Icon(Icons.person_outline)), - ], - ), - ), ), Expanded( child: TabBarView( @@ -127,7 +153,12 @@ class _OaGymPageState extends State ], ) : ListView( - padding: EdgeInsets.fromLTRB(16, topInset + 16, 16, 120), + padding: EdgeInsets.fromLTRB( + 16, + topInset + 16, + 16, + AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ Card.filled( child: Padding( @@ -155,12 +186,18 @@ class _OaGymPageState extends State style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: _handleLogin, - icon: const Icon(Icons.login), - label: Text( - auth.isLoggedIn ? '去绑定 eGate' : '去登录', - ), + AdaptiveButton( + onPressed: _handlePrerequisite, + icon: auth.isLoggedIn + ? Icons.vpn_key_outlined + : Icons.login, + sfSymbol: auth.isLoggedIn + ? 'key.horizontal' + : 'person.crop.circle.badge.plus', + label: auth.isLoggedIn ? '去绑定 eGate' : '去登录', + role: AdaptiveButtonRole.prominent, + accessibilityLabel: + auth.isLoggedIn ? '前往绑定 eGate' : '登录 TechPie', ), ], ), @@ -211,7 +248,7 @@ class _BookingTabState extends State<_BookingTab> { final maxDate = now.hour >= 12 ? DateTime(now.year, now.month, now.day + 2) : DateTime(now.year, now.month, now.day + 1); - final picked = await showDatePicker( + final picked = await showAdaptiveDatePicker( context: context, initialDate: _date, firstDate: DateTime(now.year, now.month, now.day), @@ -346,7 +383,12 @@ class _BookingTabState extends State<_BookingTab> { }; return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ Card.outlined( child: Padding( @@ -411,15 +453,16 @@ class _BookingTabState extends State<_BookingTab> { ), Align( alignment: Alignment.centerRight, - child: TextButton.icon( + child: AdaptiveButton( onPressed: _checking ? null : _refreshAvailability, - icon: _checking - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - label: const Text('刷新可用场地'), + icon: Icons.refresh, + sfSymbol: 'arrow.clockwise', + label: '刷新可用场地', + role: AdaptiveButtonRole.plain, + loading: _checking, + width: 180, + height: 44, + accessibilityLabel: '刷新可用场地', ), ), ], @@ -467,15 +510,14 @@ class _BookingTabState extends State<_BookingTab> { ), const SizedBox(height: 12), ], - FilledButton.icon( + AdaptiveButton( onPressed: _submitting || _selectedCount == 0 ? null : _submit, - icon: _submitting - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send), - label: Text(_selectedCount == 0 ? '提交预约' : '提交预约 ($_selectedCount)'), + icon: Icons.send, + sfSymbol: 'paperplane.fill', + label: _selectedCount == 0 ? '提交预约' : '提交预约 ($_selectedCount)', + role: AdaptiveButtonRole.prominent, + loading: _submitting, + accessibilityLabel: '提交场馆预约', ), ], ); @@ -586,7 +628,7 @@ class _SearchTabState extends State<_SearchTab> { } Future _pickStartDate() async { - final picked = await showDatePicker( + final picked = await showAdaptiveDatePicker( context: context, initialDate: _startDate, firstDate: DateTime.now().subtract(const Duration(days: 30)), @@ -596,7 +638,7 @@ class _SearchTabState extends State<_SearchTab> { } Future _pickEndDate() async { - final picked = await showDatePicker( + final picked = await showAdaptiveDatePicker( context: context, initialDate: _endDate, firstDate: _startDate, @@ -654,7 +696,12 @@ class _SearchTabState extends State<_SearchTab> { ); return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ Card.outlined( child: Padding( @@ -801,15 +848,14 @@ class _SearchTabState extends State<_SearchTab> { ), ], const SizedBox(height: 16), - FilledButton.icon( + AdaptiveButton( onPressed: _loading ? null : _search, - icon: _loading - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.search), - label: Text(_loading ? '查询中...' : '查询预约记录'), + icon: Icons.search, + sfSymbol: 'magnifyingglass', + label: _loading ? '查询中...' : '查询预约记录', + role: AdaptiveButtonRole.prominent, + loading: _loading, + accessibilityLabel: '查询预约记录', ), ], ), @@ -985,7 +1031,12 @@ class _ProfileTabState extends State<_ProfileTab> { final avatarText = displayName.characters.firstOrNull ?? 'U'; return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ Card.filled( child: Padding( @@ -1050,10 +1101,13 @@ class _ProfileTabState extends State<_ProfileTab> { keyboardType: TextInputType.emailAddress, ), const SizedBox(height: 16), - FilledButton.icon( + AdaptiveButton( onPressed: _save, - icon: const Icon(Icons.save_outlined), - label: const Text('保存'), + icon: Icons.save_outlined, + sfSymbol: 'square.and.arrow.down', + label: '保存', + role: AdaptiveButtonRole.prominent, + accessibilityLabel: '保存预约信息', ), ], ), diff --git a/lib/pages/schedule_page.dart b/lib/pages/schedule_page.dart index ea90f5b..6406d9e 100644 --- a/lib/pages/schedule_page.dart +++ b/lib/pages/schedule_page.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:open_filex/open_filex.dart'; -import 'package:reel_text/reel_text.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/course.dart'; @@ -14,13 +12,21 @@ import '../services/ics/ics_export_service.dart'; import '../services/ics/ics_file_saver.dart'; import '../services/schedule_service.dart'; import '../services/service_provider.dart'; +import '../utils/adaptive_layout.dart'; +import '../utils/adaptive_motion.dart'; import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/adaptive_text_input_dialog.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; import '../widgets/course_detail_panel.dart'; import '../widgets/desktop_popup.dart'; import '../widgets/desktop_select_popover.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; +import 'login_page.dart'; +import 'third_party_accounts_page.dart'; class SchedulePage extends StatefulWidget { const SchedulePage({super.key}); @@ -31,7 +37,6 @@ class SchedulePage extends StatefulWidget { class _SchedulePageState extends State { final IcsExportService _icsExport = IcsExportService(); - final TextEditingController _calendarNameController = TextEditingController(); late ScheduleService _schedule; List _courses = []; List _periods = defaultPeriods.toList(); @@ -64,7 +69,6 @@ class _SchedulePageState extends State { @override void dispose() { - _calendarNameController.dispose(); _weekPageController.dispose(); _schedule.removeListener(_onScheduleChanged); super.dispose(); @@ -94,17 +98,11 @@ class _SchedulePageState extends State { ); } - Future _dismissKeyboard() async { - FocusManager.instance.primaryFocus?.unfocus(); - if (isIos()) { - await SystemChannels.textInput.invokeMethod('TextInput.hide'); - } - } - void _rebuildCourses() { if (!mounted) return; setState(() { - _currentWeek = _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); + _currentWeek = + _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); final table = _schedule.courseTable; if (table != null) { if (table.periods.isNotEmpty) { @@ -146,8 +144,11 @@ class _SchedulePageState extends State { unawaited( _weekPageController.animateToPage( _currentWeek - 1, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, + duration: appAnimationDuration( + context, + const Duration(milliseconds: 280), + ), + curve: appAnimationCurve(Curves.easeOutCubic), ), ); } @@ -259,7 +260,7 @@ class _SchedulePageState extends State { void _showWeekPicker() { // Desktop is handled directly by _DesktopWeekTitleMenu. - if (isDesktopLayout(context)) return; + if (usesSidebarLayout(context)) return; final computedWeek = _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); @@ -523,48 +524,14 @@ class _SchedulePageState extends State { } Future _promptCalendarName({required String initialValue}) async { - _calendarNameController - ..text = initialValue - ..selection = TextSelection( - baseOffset: 0, - extentOffset: initialValue.length, - ); - - return showDialog( + return showAdaptiveTextInputDialog( context: context, - builder: (dialogContext) { - Future closeDialog([String? value]) async { - await _dismissKeyboard(); - if (!dialogContext.mounted) return; - Navigator.of(dialogContext).pop(value); - } - - return AlertDialog( - title: const Text('请输入日历名称'), - content: TextField( - controller: _calendarNameController, - autofocus: true, - decoration: const InputDecoration( - labelText: '日历名', - hintText: '例如:2025-2026 春季学期', - ), - textInputAction: TextInputAction.done, - onSubmitted: (value) { - closeDialog(value.trim()); - }, - ), - actions: [ - TextButton( - onPressed: () => closeDialog(), - child: const Text('取消'), - ), - FilledButton( - onPressed: () => closeDialog(_calendarNameController.text.trim()), - child: const Text('导入'), - ), - ], - ); - }, + title: '请输入日历名称', + fieldLabel: '日历名', + hintText: '例如:2025-2026 春季学期', + initialValue: initialValue, + confirmLabel: '导入', + trimResult: true, ); } @@ -651,7 +618,7 @@ class _SchedulePageState extends State { title: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (isDesktopLayout(context)) + if (usesSidebarLayout(context)) _DesktopWeekTitleMenu( currentWeek: _currentWeek, semesterLabel: _semesterLabel, @@ -670,7 +637,7 @@ class _SchedulePageState extends State { trailingIcon: Icons.unfold_more, ), ), - if (isDesktopLayout(context) && + if (usesSidebarLayout(context) && isInTerm && !isViewingCurrentWeek) ...[ const SizedBox(width: 12), @@ -705,7 +672,7 @@ class _SchedulePageState extends State { ) : const Icon(Icons.ios_share_rounded), ), - if (isDesktopLayout(context)) + if (usesSidebarLayout(context)) IconButton( key: _viewSettingsAnchorKey, icon: const Icon(Icons.more_vert), @@ -764,6 +731,32 @@ class _SchedulePageState extends State { color: theme.colorScheme.onSurfaceVariant, ), ), + const SizedBox(height: 20), + AdaptiveButton( + label: auth.isLoggedIn ? '去绑定 eGate' : '登录', + icon: auth.isLoggedIn + ? Icons.account_tree_outlined + : Icons.login, + sfSymbol: auth.isLoggedIn + ? 'person.crop.circle.badge.plus' + : 'person.crop.circle.badge.checkmark', + role: AdaptiveButtonRole.prominent, + width: 220, + onPressed: () { + if (auth.isLoggedIn) { + unawaited( + pushAdaptivePage( + context, + builder: (_) => const ThirdPartyAccountsPage(), + ), + ); + } else { + unawaited(presentLoginPage(context)); + } + }, + accessibilityLabel: + auth.isLoggedIn ? '绑定 eGate 账号' : '登录 TechPie', + ), ], ), ) @@ -850,7 +843,10 @@ class _SchedulePageState extends State { Expanded( child: animated ? AnimatedSwitcher( - duration: const Duration(milliseconds: 300), + duration: appAnimationDuration( + context, + const Duration(milliseconds: 300), + ), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: content, @@ -1117,8 +1113,7 @@ class _SemesterWheelPickerState extends State<_SemesterWheelPicker> { final terms = widget.info.semesters[year] ?? const {}; final entries = terms.entries.toList() ..sort( - (a, b) => - semesterTermRank(a.key).compareTo(semesterTermRank(b.key)), + (a, b) => semesterTermRank(a.key).compareTo(semesterTermRank(b.key)), ); return entries; } @@ -1139,9 +1134,8 @@ class _SemesterWheelPickerState extends State<_SemesterWheelPicker> { final labelIndex = previousLabel == null ? -1 : _termsForYear.indexWhere((entry) => entry.key == previousLabel); - _termIndex = labelIndex < 0 - ? 0 - : labelIndex.clamp(0, _termsForYear.length - 1); + _termIndex = + labelIndex < 0 ? 0 : labelIndex.clamp(0, _termsForYear.length - 1); }); _termController.jumpToItem(_termIndex); _reportSelection(); @@ -1284,13 +1278,27 @@ class _WeekTitleContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ReelText( - '第 $currentWeek 周', - style: theme.textTheme.titleMedium, - options: ReelTextOptions( - direction: slideDirection < 0 - ? ReelTextDirection.down - : ReelTextDirection.up, + AnimatedSwitcher( + duration: appAnimationDuration( + context, + const Duration(milliseconds: 200), + ), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, slideDirection >= 0 ? 0.3 : -0.3), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Text( + '第 $currentWeek 周', + key: ValueKey(currentWeek), + style: theme.textTheme.titleMedium, ), ), if (semesterLabel.isNotEmpty) @@ -1334,7 +1342,10 @@ class _DayHeader extends StatelessWidget { width: 48, child: Center( child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), + duration: appAnimationDuration( + context, + const Duration(milliseconds: 250), + ), child: Text( '${weekStart.month}\n月', key: ValueKey('${weekStart.year}-${weekStart.month}'), @@ -1396,7 +1407,10 @@ class _DayHeaderCell extends StatelessWidget { ), const SizedBox(height: 2), AnimatedSwitcher( - duration: const Duration(milliseconds: 250), + duration: appAnimationDuration( + context, + const Duration(milliseconds: 250), + ), child: Container( key: ValueKey('${date.year}-${date.month}-${date.day}'), width: 28, @@ -1444,7 +1458,9 @@ class _TimetableGrid extends StatelessWidget { final theme = Theme.of(context); return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 120), + padding: EdgeInsets.only( + bottom: AppShellMetrics.bottomContentPaddingOf(context), + ), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1679,7 +1695,7 @@ class _CourseBlock extends StatelessWidget { } void _showCourseDetail(BuildContext context) { - if (isDesktopLayout(context)) { + if (usesSidebarLayout(context)) { showDesktopPopover( anchorContext: context, width: 360, diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 95ce26e..1b4788a 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -9,14 +9,18 @@ import '../services/service_provider.dart'; import '../services/sync_service.dart'; import '../services/theme_service.dart'; import '../services/third_party_auth_service.dart'; +import '../utils/adaptive_layout.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; +import '../widgets/adaptive_button.dart'; +import '../widgets/adaptive_confirmation_button.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/adaptive_select.dart'; +import '../widgets/adaptive_switch.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; import '../widgets/desktop_popup.dart'; -import '../widgets/ios_liquid/ios_glass_select.dart'; -import '../widgets/ios_liquid/ios_glass_switch.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; -import '../widgets/ios_liquid/ios_platform_view_page_transitions.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; import 'debug_log_page.dart'; import 'login_page.dart'; import 'sync_settings_page.dart'; @@ -50,7 +54,7 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { - if (isDesktopLayout(context)) { + if (usesSidebarLayout(context)) { return Navigator( onGenerateRoute: (settings) => MaterialPageRoute( settings: settings, @@ -87,7 +91,10 @@ class _SettingsPageState extends State { body: ListenableBuilder( listenable: Listenable.merge([auth, logger, themeService, tpAuth]), builder: (context, _) => ListView( - padding: EdgeInsets.only(top: topInset, bottom: 120), + padding: EdgeInsets.only( + top: topInset, + bottom: AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ // Account section _sectionHeader(theme, 'Account'), @@ -115,7 +122,7 @@ class _SettingsPageState extends State { ), trailing: const Icon(Icons.chevron_right), onTap: () => unawaited( - pushPlatformViewPage( + pushAdaptivePage( context, builder: (_) => const ThirdPartyAccountsPage(), ), @@ -127,20 +134,46 @@ class _SettingsPageState extends State { subtitle: Text(_cloudSyncSubtitle(sp.syncService)), trailing: const Icon(Icons.chevron_right), onTap: () => unawaited( - Navigator.push( + pushAdaptivePage( context, - MaterialPageRoute( - builder: (_) => const SyncSettingsPage(), - ), + builder: (_) => const SyncSettingsPage(), ), ), ), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Logout'), - onTap: () => unawaited(_confirmLogout(auth)), - ), - ] else + if (useIosChrome) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: AdaptiveConfirmationButton( + label: 'Logout', + icon: Icons.logout, + sfSymbol: 'rectangle.portrait.and.arrow.right', + confirmTitle: '退出登录?', + confirmLabel: '退出登录', + destructive: true, + width: double.infinity, + height: 44, + onConfirmed: () => unawaited(auth.logout()), + ), + ) + else + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Logout'), + onTap: () => unawaited(_confirmLogout(auth)), + ), + ] else if (useIosChrome) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: AdaptiveButton( + icon: Icons.login, + sfSymbol: 'person.crop.circle.badge.plus', + label: '通过 GeekPie Uni-Auth 登录', + role: AdaptiveButtonRole.prominent, + accessibilityLabel: '登录 TechPie', + onPressed: () => unawaited(presentLoginPage(context)), + ), + ) + else ListTile( leading: const Icon(Icons.login), title: const Text('Login'), @@ -156,13 +189,13 @@ class _SettingsPageState extends State { leading: Icon(themeService.mode.icon), title: const Text('Theme'), subtitle: Text(themeService.mode.label), - trailing: IosGlassSelect( + trailing: AdaptiveSelect( value: themeService.mode.name, placeholder: 'Choose theme', width: 156, options: [ for (final mode in AppThemeMode.values) - IosGlassSelectOption(value: mode.name, label: mode.label), + AdaptiveSelectOption(value: mode.name, label: mode.label), ], onChanged: (value) { final mode = AppThemeMode.values.firstWhere( @@ -182,32 +215,7 @@ class _SettingsPageState extends State { onTap: () => _showThemePicker(tileContext, themeService), ), ), - if (useIosChrome) - ListTile( - leading: Icon(themeService.colorScheme.icon), - title: const Text('Color'), - subtitle: Text(_colorSubtitle(themeService)), - trailing: IosGlassSelect( - value: themeService.colorScheme.name, - placeholder: 'Choose color', - width: 156, - options: [ - for (final scheme in AppColorScheme.values) - IosGlassSelectOption( - value: scheme.name, - label: scheme.label, - ), - ], - onChanged: (value) { - final scheme = AppColorScheme.values.firstWhere( - (item) => item.name == value, - orElse: () => AppColorScheme.system, - ); - unawaited(themeService.setColorScheme(scheme)); - }, - ), - ) - else + if (themeService.supportsColorSchemeSelection) Builder( builder: (tileContext) => ListTile( leading: Icon(themeService.colorScheme.icon), @@ -220,12 +228,6 @@ class _SettingsPageState extends State { // General section _sectionHeader(theme, 'General'), - ListTile( - leading: const Icon(Icons.notifications_outlined), - title: const Text('Notifications'), - subtitle: const Text('Manage notification preferences'), - onTap: () {}, - ), ListTile( leading: const Icon(Icons.info_outline), title: const Text('About'), @@ -234,7 +236,6 @@ class _SettingsPageState extends State { ? 'Version Unknown' : 'Version $_appVersion', ), - onTap: () {}, ), const Divider(), @@ -270,11 +271,9 @@ class _SettingsPageState extends State { title: const Text('View Logs'), subtitle: Text('${logger.entries.length} entries'), onTap: () => unawaited( - Navigator.push( + pushAdaptivePage( context, - MaterialPageRoute( - builder: (_) => const DebugLogPage(), - ), + builder: (_) => const DebugLogPage(), ), ), ), @@ -305,7 +304,7 @@ class _SettingsPageState extends State { } void _showThemePicker(BuildContext context, ThemeService themeService) { - if (isDesktopLayout(context)) { + if (usesSidebarLayout(context)) { showDesktopPopover( anchorContext: context, width: 260, @@ -404,7 +403,7 @@ class _SettingsPageState extends State { } void _showColorPicker(BuildContext context, ThemeService themeService) { - if (isDesktopLayout(context)) { + if (usesSidebarLayout(context)) { showDesktopPopover( anchorContext: context, width: 260, @@ -552,7 +551,7 @@ class _AdaptiveSwitchTile extends StatelessWidget { leading: secondary, title: Text(title), subtitle: Text(subtitle), - trailing: IosGlassSwitch(value: value, onChanged: onChanged), + trailing: AdaptiveSwitch(value: value, onChanged: onChanged), onTap: () => onChanged(!value), ); } @@ -586,7 +585,7 @@ class _CpdailyBindingTile extends StatelessWidget { onTap: bound ? null : () => unawaited( - pushPlatformViewPage( + pushAdaptivePage( context, builder: (_) => const ThirdPartyAccountsPage(), ), diff --git a/lib/pages/sync_settings_page.dart b/lib/pages/sync_settings_page.dart index 3078851..ec7dedc 100644 --- a/lib/pages/sync_settings_page.dart +++ b/lib/pages/sync_settings_page.dart @@ -6,9 +6,13 @@ import '../services/service_provider.dart'; import '../services/sync_service.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; +import '../widgets/adaptive_button.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/adaptive_text_input_dialog.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; /// Cloud-sync settings: turn sync on/off, set / restore / change the master /// password, and trigger a manual pull. All cryptography + Casdoor I/O lives @@ -21,7 +25,9 @@ class SyncSettingsPage extends StatefulWidget { } class _SyncSettingsPageState extends State { - bool _busy = false; + String? _busyAction; + + bool get _busy => _busyAction != null; @override Widget build(BuildContext context) { @@ -36,60 +42,98 @@ class _SyncSettingsPageState extends State { return Scaffold( extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, appBar: useIosChrome - ? const IosNativeNavigationBar(title: 'Cloud sync') + ? IosNativeNavigationBar( + title: 'Cloud sync', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Settings', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Settings', + placementGroup: 'leading-main', + ), + ], + onItemPressed: (id) { + if (id == 'back') { + unawaited(maybePopAdaptivePage(context)); + } + }, + ) : const BlurredAppBar(title: Text('Cloud sync')), body: ListenableBuilder( listenable: sync, builder: (context, _) { return ListView( - padding: EdgeInsets.only(top: topInset, bottom: 120), + padding: EdgeInsets.only( + top: topInset, + bottom: AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ _ExplanationCard(enabled: sync.enabled), const SizedBox(height: 8), if (sync.enabled) ...[ _statusTile(sync), - ListTile( - leading: const Icon(Icons.download_for_offline_outlined), - title: const Text('立即从云端恢复'), - subtitle: const Text('用云端备份覆盖本设备绑定'), - onTap: _busy ? null : () => unawaited(_pull(sync)), - ), - ListTile( - leading: const Icon(Icons.upload_outlined), - title: const Text('立即备份到云端'), - subtitle: const Text('用本设备绑定覆盖云端备份'), - onTap: _busy ? null : () => unawaited(_push(sync)), - ), - ListTile( - leading: const Icon(Icons.lock_outline), - title: const Text('修改主密码'), - onTap: _busy ? null : () => unawaited(_changePassword(sync)), - ), - ListTile( - leading: Icon( - Icons.cloud_off_outlined, - color: Theme.of(context).colorScheme.error, + _actionPanel([ + _actionButton( + id: 'pull', + label: '立即从云端恢复', + subtitle: '用云端备份覆盖本设备绑定', + icon: Icons.download_for_offline_outlined, + sfSymbol: 'arrow.down.circle', + onPressed: () => unawaited(_pull(sync)), ), - title: const Text('关闭云同步'), - subtitle: const Text('清除云端备份与本设备主密码'), - onTap: _busy ? null : () => unawaited(_disable(sync)), - ), + _actionButton( + id: 'push', + label: '立即备份到云端', + subtitle: '用本设备绑定覆盖云端备份', + icon: Icons.upload_outlined, + sfSymbol: 'arrow.up.circle', + role: AdaptiveButtonRole.prominent, + onPressed: () => unawaited(_push(sync)), + ), + _actionButton( + id: 'password', + label: '修改主密码', + icon: Icons.lock_outline, + sfSymbol: 'key', + role: AdaptiveButtonRole.plain, + onPressed: () => unawaited(_changePassword(sync)), + ), + _actionButton( + id: 'disable', + label: '关闭云同步', + subtitle: '清除云端备份与本设备主密码', + icon: Icons.cloud_off_outlined, + sfSymbol: 'icloud.slash', + role: AdaptiveButtonRole.destructive, + onPressed: () => unawaited(_disable(sync)), + ), + ]), ] else ...[ - ListTile( - leading: const Icon(Icons.lock_reset_outlined), - title: const Text('从云端恢复(已有备份)'), - subtitle: const Text('在其它设备设置过云同步时使用'), - onTap: _busy ? null : () => unawaited(_restore(sync)), - ), - ListTile( - leading: const Icon(Icons.cloud_upload_outlined), - title: const Text('开启并备份(首次设置)'), - subtitle: const Text('设置主密码,把当前绑定加密上传'), - onTap: _busy ? null : () => unawaited(_setup(sync)), - ), + _actionPanel([ + _actionButton( + id: 'restore', + label: '从云端恢复', + subtitle: '已有备份时输入主密码恢复', + icon: Icons.lock_reset_outlined, + sfSymbol: 'icloud.and.arrow.down', + onPressed: () => unawaited(_restore(sync)), + ), + _actionButton( + id: 'setup', + label: '开启并备份', + subtitle: '首次设置主密码并加密上传', + icon: Icons.cloud_upload_outlined, + sfSymbol: 'icloud.and.arrow.up', + role: AdaptiveButtonRole.prominent, + onPressed: () => unawaited(_setup(sync)), + ), + ]), ], if (sync.needsRestore) - _RestoreBanner(onTap: _busy ? null : () => unawaited(_restore(sync))), + _RestoreBanner( + onTap: _busy ? null : () => unawaited(_restore(sync)), + ), ], ); }, @@ -114,14 +158,72 @@ class _SyncSettingsPageState extends State { ); } + Widget _actionPanel(List actions) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0) const SizedBox(height: 10), + actions[index], + ], + ], + ), + ); + } + + Widget _actionButton({ + required String id, + required String label, + required IconData icon, + required String sfSymbol, + required VoidCallback onPressed, + String? subtitle, + AdaptiveButtonRole role = AdaptiveButtonRole.standard, + }) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AdaptiveButton( + label: label, + icon: icon, + sfSymbol: sfSymbol, + role: role, + height: 44, + loading: _busyAction == id, + onPressed: _busy ? null : onPressed, + accessibilityLabel: label, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ); + } + // -- Actions ----------------------------------------------------------------- - Future _guard(Future Function() task) async { - setState(() => _busy = true); + Future _guard( + String action, + Future Function() task, + ) async { + setState(() => _busyAction = action); try { await task(); } finally { - if (mounted) setState(() => _busy = false); + if (mounted) setState(() => _busyAction = null); } } @@ -132,7 +234,7 @@ class _SyncSettingsPageState extends State { warning: _e2eExplanation, ); if (pwd == null) return; - await _guard(() async { + await _guard('setup', () async { final outcome = await sync.setupWithMasterPassword(pwd); _feedback(outcome); }); @@ -145,7 +247,7 @@ class _SyncSettingsPageState extends State { warning: _e2eExplanation, ); if (pwd == null) return; - await _guard(() async { + await _guard('restore', () async { final outcome = await sync.restoreWithMasterPassword(pwd); _feedback(outcome); }); @@ -164,7 +266,7 @@ class _SyncSettingsPageState extends State { warning: _e2eExplanation, ); if (newPwd == null) return; - await _guard(() async { + await _guard('password', () async { final outcome = await sync.changeMasterPassword(oldPwd, newPwd); _feedback(outcome); }); @@ -185,14 +287,14 @@ class _SyncSettingsPageState extends State { ], ); if (ok != true) return; - await _guard(() async { + await _guard('disable', () async { final outcome = await sync.disable(); _feedback(outcome); }); } Future _pull(SyncService sync) async { - await _guard(() async { + await _guard('pull', () async { try { await sync.pull(); _toast('已从云端恢复绑定'); @@ -205,7 +307,7 @@ class _SyncSettingsPageState extends State { } Future _push(SyncService sync) async { - await _guard(() async { + await _guard('push', () async { final res = await sync.push(); if (res.ok) { _toast('已备份到云端'); @@ -232,7 +334,8 @@ class _SyncSettingsPageState extends State { showAdaptiveFeedback( context: context, message: message, - style: isError ? AdaptiveFeedbackStyle.error : AdaptiveFeedbackStyle.success, + style: + isError ? AdaptiveFeedbackStyle.error : AdaptiveFeedbackStyle.success, ); } @@ -241,83 +344,15 @@ class _SyncSettingsPageState extends State { required bool confirm, required String? warning, }) async { - final controller = TextEditingController(); - final confirmController = TextEditingController(); - return showDialog( + return showAdaptiveTextInputDialog( context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - final obscure = true; - final canSubmit = controller.text.isNotEmpty && - (!confirm || controller.text == confirmController.text); - return AlertDialog( - title: Text(title), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (warning != null) ...[ - Text( - warning, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - ], - TextField( - controller: controller, - obscureText: obscure, - autofocus: true, - decoration: const InputDecoration( - labelText: '主密码', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - if (confirm) ...[ - const SizedBox(height: 12), - TextField( - controller: confirmController, - obscureText: obscure, - decoration: const InputDecoration( - labelText: '再次输入', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - if (confirmController.text.isNotEmpty && - controller.text != confirmController.text) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '两次输入不一致', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, - ), - ), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - FilledButton( - onPressed: canSubmit - ? () => Navigator.pop(context, controller.text) - : null, - child: const Text('确定'), - ), - ], - ); - }, - ); - }, + title: title, + message: warning, + fieldLabel: '主密码', + confirmationFieldLabel: confirm ? '再次输入' : null, + mismatchMessage: '两次输入不一致', + confirmLabel: '确定', + obscureText: true, ); } } @@ -376,23 +411,50 @@ class _RestoreBanner extends StatelessWidget { return Card.filled( margin: const EdgeInsets.all(16), color: theme.colorScheme.secondaryContainer, - child: ListTile( - leading: Icon( - Icons.cloud_download_outlined, - color: theme.colorScheme.onSecondaryContainer, - ), - title: Text( - '云端有备份,点此恢复', - style: TextStyle(color: theme.colorScheme.onSecondaryContainer), - ), - subtitle: Text( - '检测到其它设备设置了云同步,输入主密码即可恢复绑定', - style: TextStyle( - color: theme.colorScheme.onSecondaryContainer.withValues(alpha: 0.8), - fontSize: 12, - ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.cloud_download_outlined, + color: theme.colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '云端有可恢复的备份', + style: TextStyle( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '检测到其它设备设置了云同步,输入主密码即可恢复绑定。', + style: TextStyle( + color: theme.colorScheme.onSecondaryContainer.withValues( + alpha: 0.8, + ), + fontSize: 12, + ), + ), + const SizedBox(height: 12), + AdaptiveButton( + label: '恢复备份', + icon: Icons.cloud_download_outlined, + sfSymbol: 'icloud.and.arrow.down', + role: AdaptiveButtonRole.prominent, + onPressed: onTap, + accessibilityLabel: '恢复云端备份', + ), + ], ), - onTap: onTap, ), ); } diff --git a/lib/pages/third_party_accounts_page.dart b/lib/pages/third_party_accounts_page.dart index ee83e4f..3609744 100644 --- a/lib/pages/third_party_accounts_page.dart +++ b/lib/pages/third_party_accounts_page.dart @@ -7,10 +7,11 @@ import '../models/third_party_account.dart'; import '../services/service_provider.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; +import '../widgets/adaptive_confirmation_button.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_glass_confirmation_button.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; -import '../widgets/ios_liquid/ios_platform_view_page_transitions.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; import 'login_page.dart'; import 'third_party_bind_page.dart'; @@ -38,14 +39,14 @@ class ThirdPartyAccountsPage extends StatelessWidget { if (Navigator.canPop(context)) const IosNativeNavigationBarItem( id: 'back', - title: '返回', + title: 'Settings', sfSymbol: 'chevron.left', - accessibilityLabel: '返回', + accessibilityLabel: '返回 Settings', ), ], onItemPressed: (id) { if (id == 'back') { - unawaited(maybePopPlatformViewPage(context)); + unawaited(maybePopAdaptivePage(context)); } }, ) @@ -54,7 +55,10 @@ class ThirdPartyAccountsPage extends StatelessWidget { listenable: Listenable.merge([tpAuth, auth]), builder: (context, _) { return ListView( - padding: EdgeInsets.only(top: topInset, bottom: 120), + padding: EdgeInsets.only( + top: topInset, + bottom: AppShellMetrics.bottomContentPaddingOf(context), + ), children: [ // Blackboard read-only entry _BlackboardTile(), @@ -135,7 +139,7 @@ class _ThirdPartyTile extends StatelessWidget { subtitle: const Text('未绑定'), trailing: const Icon(Icons.chevron_right), onTap: () => unawaited( - pushPlatformViewPage( + pushAdaptivePage( context, builder: (_) => ThirdPartyBindPage(platform: platform), ), @@ -160,7 +164,7 @@ class _ThirdPartyTile extends StatelessWidget { subtitle: Text(subtitleParts.join('\n')), isThreeLine: subtitleParts.length > 1, trailing: isIos() - ? IosGlassConfirmationButton( + ? AdaptiveConfirmationButton( label: 'Unbind', confirmTitle: '解绑 ${platform.label}?', confirmLabel: '解绑', diff --git a/lib/pages/third_party_bind_page.dart b/lib/pages/third_party_bind_page.dart index c409ce2..f720e68 100644 --- a/lib/pages/third_party_bind_page.dart +++ b/lib/pages/third_party_bind_page.dart @@ -9,13 +9,13 @@ import '../services/third_party_auth_service.dart'; import '../utils/platform.dart'; import '../widgets/adaptive_alert_dialog.dart'; import '../widgets/adaptive_feedback.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/adaptive_segmented_control.dart'; +import '../widgets/adaptive_switch.dart'; +import '../widgets/adaptive_text_area.dart'; +import '../widgets/adaptive_text_field_group.dart'; import '../widgets/blurred_app_bar.dart'; -import '../widgets/ios_liquid/ios_glass_switch.dart'; -import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; -import '../widgets/ios_liquid/ios_native_segmented_control.dart'; -import '../widgets/ios_liquid/ios_native_text_field_group.dart'; -import '../widgets/ios_liquid/ios_native_text_view.dart'; -import '../widgets/ios_liquid/ios_platform_view_page_transitions.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; class ThirdPartyBindPage extends StatefulWidget { final ThirdPartyPlatform platform; @@ -186,7 +186,7 @@ class _ThirdPartyBindPageState extends State { style: AdaptiveFeedbackStyle.success, ); } - await maybePopPlatformViewPage(context); + await maybePopAdaptivePage(context); } on ThirdPartyBindException catch (e) { if (!mounted) return; if (isIos()) { @@ -432,7 +432,7 @@ class _ThirdPartyBindPageState extends State { contentPadding: EdgeInsets.zero, title: const Text('自动更新 Token'), subtitle: const Text('过期前 48 小时内自动重登,免去手动重绑'), - trailing: IosGlassSwitch( + trailing: AdaptiveSwitch( value: _autoRenew, onChanged: (v) => unawaited(_onAutoRenewChanged(v)), ), @@ -490,7 +490,7 @@ class _ThirdPartyBindPageState extends State { ], onItemPressed: (id) { if (id == 'back') { - unawaited(maybePopPlatformViewPage(context)); + unawaited(maybePopAdaptivePage(context)); } }, ), @@ -505,7 +505,7 @@ class _ThirdPartyBindPageState extends State { const SizedBox(height: 16), ], if (_isCpdaily) ...[ - IosNativeSegmentedControl( + AdaptiveSegmentedControl( value: _cpdailyLoginMethod, segments: const ['密码登录', '短信登录'], onChanged: (value) { @@ -517,14 +517,14 @@ class _ThirdPartyBindPageState extends State { ), const SizedBox(height: 18), if (_cpdailyLoginMethod == 0) - IosNativeTextFieldGroup( + AdaptiveTextFieldGroup( items: [ - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: '学号', controller: _accountCtrl, textInputAction: TextInputAction.next, ), - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: '密码', controller: _passwordCtrl, obscureText: _obscure, @@ -534,9 +534,9 @@ class _ThirdPartyBindPageState extends State { ], ) else ...[ - IosNativeTextFieldGroup( + AdaptiveTextFieldGroup( items: [ - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: '手机号码', controller: _cpdailyPhoneCtrl, keyboardType: TextInputType.phone, @@ -548,9 +548,9 @@ class _ThirdPartyBindPageState extends State { Row( children: [ Expanded( - child: IosNativeTextFieldGroup( + child: AdaptiveTextFieldGroup( items: [ - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: '验证码', controller: _cpdailyCodeCtrl, keyboardType: TextInputType.number, @@ -582,9 +582,9 @@ class _ThirdPartyBindPageState extends State { ), ], ] else - IosNativeTextFieldGroup( + AdaptiveTextFieldGroup( items: [ - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: _isGradescope ? '邮箱' : '用户名', controller: _accountCtrl, keyboardType: _isGradescope @@ -592,7 +592,7 @@ class _ThirdPartyBindPageState extends State { : TextInputType.text, textInputAction: TextInputAction.next, ), - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: '密码', controller: _passwordCtrl, obscureText: _obscure, @@ -606,9 +606,9 @@ class _ThirdPartyBindPageState extends State { ), if (_isHydro) ...[ const SizedBox(height: 16), - IosNativeTextFieldGroup( + AdaptiveTextFieldGroup( items: [ - IosNativeTextFieldGroupItem( + AdaptiveTextFieldGroupItem( placeholder: 'Origin', controller: _hydroOriginCtrl, keyboardType: TextInputType.url, @@ -624,7 +624,7 @@ class _ThirdPartyBindPageState extends State { ), ), const SizedBox(height: 10), - IosNativeTextView( + AdaptiveTextArea( placeholder: 'Domain', controller: _hydroDomainsCtrl, minLines: 2, @@ -649,7 +649,7 @@ class _ThirdPartyBindPageState extends State { '过期前 48 小时内自动重登,免去手动重绑', style: TextStyle(color: secondaryLabel), ), - trailing: IosGlassSwitch( + trailing: AdaptiveSwitch( value: _autoRenew, onChanged: (value) => unawaited(_onAutoRenewChanged(value)), ), diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index d6c655c..838a1f8 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -104,6 +104,8 @@ class ThemeService extends ChangeNotifier { bool get _usesIosTheme => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + bool get supportsColorSchemeSelection => !_usesIosTheme; + ColorScheme _resolveScheme(Brightness brightness) { if (_colorScheme == AppColorScheme.system) { final system = diff --git a/lib/utils/adaptive_layout.dart b/lib/utils/adaptive_layout.dart new file mode 100644 index 0000000..e63d72a --- /dev/null +++ b/lib/utils/adaptive_layout.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +enum AppWindowSizeClass { compact, medium, expanded } + +const double mediumWindowBreakpoint = 600; +const double expandedWindowBreakpoint = 960; + +AppWindowSizeClass appWindowSizeClassFor(double width) { + if (width >= expandedWindowBreakpoint) { + return AppWindowSizeClass.expanded; + } + if (width >= mediumWindowBreakpoint) { + return AppWindowSizeClass.medium; + } + return AppWindowSizeClass.compact; +} + +AppWindowSizeClass appWindowSizeClassOf(BuildContext context) { + return appWindowSizeClassFor(MediaQuery.sizeOf(context).width); +} + +bool usesSidebarLayout(BuildContext context) { + return appWindowSizeClassOf(context) != AppWindowSizeClass.compact; +} diff --git a/lib/utils/adaptive_motion.dart b/lib/utils/adaptive_motion.dart new file mode 100644 index 0000000..5cf0c20 --- /dev/null +++ b/lib/utils/adaptive_motion.dart @@ -0,0 +1,18 @@ +import 'package:flutter/widgets.dart'; + +import 'platform.dart'; + +bool appAnimationsEnabled(BuildContext context) => + !MediaQuery.disableAnimationsOf(context); + +Duration appAnimationDuration( + BuildContext context, + Duration duration, +) => + appAnimationsEnabled(context) ? duration : Duration.zero; + +Curve appAnimationCurve( + Curve materialCurve, { + Curve iosCurve = Curves.easeInOut, +}) => + isIos() ? iosCurve : materialCurve; diff --git a/lib/utils/platform.dart b/lib/utils/platform.dart index a1fed3b..6545848 100644 --- a/lib/utils/platform.dart +++ b/lib/utils/platform.dart @@ -4,6 +4,8 @@ import 'package:flutter/services.dart'; const MethodChannel _platformChannel = MethodChannel('techpie/platform'); +const double iosMinimumInteractiveDimension = 44; + bool _supportsIosLiquidGlass = false; /// Load native platform capabilities that are needed before the first frame. diff --git a/lib/widgets/adaptive_alert_dialog.dart b/lib/widgets/adaptive_alert_dialog.dart index e7e31bc..d61b90d 100644 --- a/lib/widgets/adaptive_alert_dialog.dart +++ b/lib/widgets/adaptive_alert_dialog.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -88,7 +89,7 @@ Future _showNativeIosAlert({ } on PlatformException { if (!fallbackContext.mounted) return null; - return _showFlutterAlertDialog( + return _showCupertinoAlertDialog( context: fallbackContext, title: title, message: message, @@ -97,7 +98,7 @@ Future _showNativeIosAlert({ } on MissingPluginException { if (!fallbackContext.mounted) return null; - return _showFlutterAlertDialog( + return _showCupertinoAlertDialog( context: fallbackContext, title: title, message: message, @@ -106,6 +107,30 @@ Future _showNativeIosAlert({ } } +Future _showCupertinoAlertDialog({ + required BuildContext context, + required String title, + required String message, + required List> actions, +}) { + return showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text(title), + content: Text(message), + actions: [ + for (final action in actions) + CupertinoDialogAction( + isDefaultAction: action.isDefault, + isDestructiveAction: action.isDestructive, + onPressed: () => Navigator.pop(dialogContext, action.value), + child: Text(action.label), + ), + ], + ), + ); +} + Future _showFlutterAlertDialog({ required BuildContext context, required String title, diff --git a/lib/widgets/adaptive_button.dart b/lib/widgets/adaptive_button.dart new file mode 100644 index 0000000..92a5e54 --- /dev/null +++ b/lib/widgets/adaptive_button.dart @@ -0,0 +1,200 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:techpie/utils/platform.dart'; + +import 'cupertino_symbol_icons.dart'; + +enum AdaptiveButtonRole { prominent, standard, plain, destructive } + +class AdaptiveButton extends StatelessWidget { + const AdaptiveButton({ + super.key, + required this.onPressed, + required this.icon, + required this.sfSymbol, + this.label, + this.subtitle, + this.role = AdaptiveButtonRole.standard, + this.loading = false, + this.width, + this.height, + this.accessibilityLabel, + this.showIosIcon = false, + }); + + final VoidCallback? onPressed; + final IconData icon; + final String sfSymbol; + final String? label; + final String? subtitle; + final AdaptiveButtonRole role; + final bool loading; + final double? width; + final double? height; + final String? accessibilityLabel; + final bool showIosIcon; + + @override + Widget build(BuildContext context) { + if (!isIos()) return _buildMaterialButton(context); + + final hasLabel = label != null && label!.isNotEmpty; + final buttonHeight = (height ?? iosMinimumInteractiveDimension).clamp( + iosMinimumInteractiveDimension, + double.infinity, + ); + final buttonWidth = width ?? (hasLabel ? double.infinity : buttonHeight); + final primaryColor = CupertinoTheme.of(context).primaryColor; + final foreground = switch (role) { + AdaptiveButtonRole.prominent => CupertinoColors.white, + AdaptiveButtonRole.destructive => CupertinoColors.systemRed, + _ => primaryColor, + }; + final background = switch (role) { + AdaptiveButtonRole.prominent => primaryColor, + AdaptiveButtonRole.standard => CupertinoDynamicColor.resolve( + CupertinoColors.secondarySystemFill, + context, + ), + AdaptiveButtonRole.plain || AdaptiveButtonRole.destructive => null, + }; + final enabled = onPressed != null && !loading; + + return Semantics( + button: true, + label: accessibilityLabel ?? label, + enabled: enabled, + child: SizedBox( + width: buttonWidth, + height: buttonHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(10), + ), + child: CupertinoButton( + minSize: iosMinimumInteractiveDimension, + padding: EdgeInsets.symmetric(horizontal: hasLabel ? 16 : 0), + borderRadius: BorderRadius.circular(10), + onPressed: enabled ? onPressed : null, + child: _buildIosContent(context, foreground, hasLabel), + ), + ), + ), + ); + } + + Widget _buildIosContent( + BuildContext context, + Color foreground, + bool hasLabel, + ) { + if (loading) { + return CupertinoActivityIndicator(color: foreground); + } + + final symbol = Icon( + cupertinoIconForSfSymbol(sfSymbol, fallback: icon), + size: 20, + color: foreground, + ); + if (!hasLabel) return symbol; + + final text = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CupertinoTheme.of(context).textTheme.actionTextStyle.copyWith( + color: foreground, + fontWeight: role == AdaptiveButtonRole.prominent + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + if (subtitle case final subtitle? when subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CupertinoTheme.of(context).textTheme.textStyle.copyWith( + color: foreground.withValues(alpha: 0.72), + fontSize: 12, + ), + ), + ], + ); + + if (!showIosIcon) return text; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [symbol, const SizedBox(width: 8), Flexible(child: text)], + ); + } + + Widget _buildMaterialButton(BuildContext context) { + final buttonLabel = label; + if (buttonLabel == null || buttonLabel.isEmpty) { + return IconButton.filled( + onPressed: loading ? null : onPressed, + icon: loading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(icon), + ); + } + + final foreground = role == AdaptiveButtonRole.destructive + ? Theme.of(context).colorScheme.error + : null; + final child = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (loading) + const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon(icon), + const SizedBox(width: 8), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(buttonLabel), + if (subtitle != null) + Text(subtitle!, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ), + ], + ); + final style = ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size.fromHeight(height ?? 56)), + foregroundColor: + foreground == null ? null : WidgetStatePropertyAll(foreground), + ); + final callback = loading ? null : onPressed; + + return switch (role) { + AdaptiveButtonRole.prominent => + FilledButton(onPressed: callback, style: style, child: child), + AdaptiveButtonRole.standard => FilledButton.tonal( + onPressed: callback, + style: style, + child: child, + ), + AdaptiveButtonRole.plain || + AdaptiveButtonRole.destructive => + TextButton(onPressed: callback, style: style, child: child), + }; + } +} diff --git a/lib/widgets/adaptive_confirmation_button.dart b/lib/widgets/adaptive_confirmation_button.dart new file mode 100644 index 0000000..0839879 --- /dev/null +++ b/lib/widgets/adaptive_confirmation_button.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:techpie/utils/platform.dart'; + +import 'adaptive_alert_dialog.dart'; +import 'cupertino_symbol_icons.dart'; + +class AdaptiveConfirmationButton extends StatelessWidget { + const AdaptiveConfirmationButton({ + super.key, + this.label, + required this.confirmTitle, + required this.confirmLabel, + required this.onConfirmed, + this.icon = Icons.link_off, + this.sfSymbol = 'link.badge.minus', + this.destructive = false, + this.width, + this.height = iosMinimumInteractiveDimension, + }); + + final String? label; + final String confirmTitle; + final String confirmLabel; + final VoidCallback onConfirmed; + final IconData icon; + final String sfSymbol; + final bool destructive; + final double? width; + final double height; + + @override + Widget build(BuildContext context) { + final hasLabel = label != null && label!.isNotEmpty; + final buttonWidth = + width ?? (hasLabel ? 92 : iosMinimumInteractiveDimension); + final constrainedWidth = buttonWidth < iosMinimumInteractiveDimension + ? iosMinimumInteractiveDimension + : buttonWidth; + final constrainedHeight = height < iosMinimumInteractiveDimension + ? iosMinimumInteractiveDimension + : height; + void callback() => unawaited(_confirm(context)); + + if (!isIos()) { + return SizedBox( + width: width, + height: constrainedHeight, + child: hasLabel + ? TextButton.icon( + onPressed: callback, + icon: Icon(icon, size: 18), + label: Text(label!), + ) + : IconButton( + onPressed: callback, + icon: Icon(icon, size: 18), + tooltip: confirmTitle, + ), + ); + } + + final color = destructive + ? CupertinoColors.systemRed + : CupertinoTheme.of(context).primaryColor; + return SizedBox( + width: constrainedWidth, + height: constrainedHeight, + child: CupertinoButton( + minSize: iosMinimumInteractiveDimension, + padding: const EdgeInsets.symmetric(horizontal: 8), + onPressed: callback, + child: hasLabel + ? Text( + label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 17), + ) + : Icon( + cupertinoIconForSfSymbol(sfSymbol, fallback: icon), + color: color, + size: 20, + ), + ), + ); + } + + Future _confirm(BuildContext context) async { + final confirmed = await showAdaptiveAlertDialog( + context: context, + title: confirmTitle, + message: '', + actions: [ + const AdaptiveAlertAction(label: '取消', value: false), + AdaptiveAlertAction( + label: confirmLabel, + value: true, + isDestructive: destructive, + isDefault: !destructive, + ), + ], + ); + if (confirmed == true) onConfirmed(); + } +} diff --git a/lib/widgets/adaptive_date_picker.dart b/lib/widgets/adaptive_date_picker.dart new file mode 100644 index 0000000..d7f2109 --- /dev/null +++ b/lib/widgets/adaptive_date_picker.dart @@ -0,0 +1,69 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +Future showAdaptiveDatePicker({ + required BuildContext context, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, +}) { + if (!isIos()) { + return showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ); + } + + var selected = initialDate.isBefore(firstDate) + ? firstDate + : initialDate.isAfter(lastDate) + ? lastDate + : initialDate; + return showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoPopupSurface( + child: SafeArea( + top: false, + child: ColoredBox( + color: CupertinoColors.systemBackground.resolveFrom(context), + child: SizedBox( + height: 320, + child: Column( + children: [ + SizedBox( + height: 52, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + CupertinoButton( + onPressed: () => Navigator.pop(context, selected), + child: const Text('Done'), + ), + ], + ), + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: selected, + minimumDate: firstDate, + maximumDate: lastDate, + onDateTimeChanged: (value) => selected = value, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/adaptive_page_navigation.dart b/lib/widgets/adaptive_page_navigation.dart new file mode 100644 index 0000000..1e5b94d --- /dev/null +++ b/lib/widgets/adaptive_page_navigation.dart @@ -0,0 +1,29 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +/// Pushes a page using the platform's standard navigation transition. +/// +/// iOS intentionally uses Flutter's [CupertinoPageRoute] for standard +/// push/pop geometry, edge-swipe interaction, and reduced-motion behavior. +Future pushAdaptivePage( + BuildContext context, { + required WidgetBuilder builder, + RouteSettings? settings, +}) { + final navigator = Navigator.of(context); + final route = isIos() + ? CupertinoPageRoute(settings: settings, builder: builder) + : MaterialPageRoute(settings: settings, builder: builder); + + return navigator.push(route); +} + +/// Pops the current page through the active platform route. +Future maybePopAdaptivePage( + BuildContext context, [ + T? result, +]) { + return Navigator.of(context).maybePop(result); +} diff --git a/lib/widgets/adaptive_segmented_control.dart b/lib/widgets/adaptive_segmented_control.dart new file mode 100644 index 0000000..c03ce8f --- /dev/null +++ b/lib/widgets/adaptive_segmented_control.dart @@ -0,0 +1,60 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +class AdaptiveSegmentedControl extends StatelessWidget { + const AdaptiveSegmentedControl({ + super.key, + required this.value, + required this.segments, + required this.onChanged, + }); + + final int value; + final List segments; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (!isIos()) { + return SegmentedButton( + segments: [ + for (var index = 0; index < segments.length; index++) + ButtonSegment(value: index, label: Text(segments[index])), + ], + selected: {value}, + onSelectionChanged: (selection) { + if (selection.isNotEmpty) onChanged(selection.first); + }, + ); + } + + return SizedBox( + height: iosMinimumInteractiveDimension, + width: double.infinity, + child: Center( + child: SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: value, + children: { + for (var index = 0; index < segments.length; index++) + index: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + segments[index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + }, + onValueChanged: (selection) { + if (selection != null) onChanged(selection); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/adaptive_select.dart b/lib/widgets/adaptive_select.dart new file mode 100644 index 0000000..e3d84ea --- /dev/null +++ b/lib/widgets/adaptive_select.dart @@ -0,0 +1,147 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:techpie/utils/platform.dart'; + +class AdaptiveSelectOption { + const AdaptiveSelectOption({required this.value, required this.label}); + + final String value; + final String label; +} + +class AdaptiveSelect extends StatelessWidget { + const AdaptiveSelect({ + super.key, + required this.options, + required this.onChanged, + this.value, + this.placeholder = 'Select', + this.width = 150, + this.height = iosMinimumInteractiveDimension, + }); + + final List options; + final String? value; + final ValueChanged onChanged; + final String placeholder; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + if (!isIos()) return _buildMaterialSelect(context); + + final effectiveWidth = width < iosMinimumInteractiveDimension + ? iosMinimumInteractiveDimension + : width; + final effectiveHeight = height < iosMinimumInteractiveDimension + ? iosMinimumInteractiveDimension + : height; + final selected = + options.where((option) => option.value == value).firstOrNull; + final foreground = CupertinoColors.label.resolveFrom(context); + final secondary = CupertinoColors.secondaryLabel.resolveFrom(context); + + return SizedBox( + width: effectiveWidth, + height: effectiveHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: CupertinoColors.secondarySystemFill.resolveFrom(context), + borderRadius: BorderRadius.circular(10), + ), + child: CupertinoButton( + minSize: iosMinimumInteractiveDimension, + padding: const EdgeInsetsDirectional.fromSTEB(12, 0, 10, 0), + borderRadius: BorderRadius.circular(10), + onPressed: options.isEmpty ? null : () async => _showOptions(context), + child: Row( + children: [ + Expanded( + child: Text( + selected?.label ?? placeholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected == null ? secondary : foreground, + fontSize: 15, + ), + ), + ), + const SizedBox(width: 6), + Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 14, + color: secondary, + ), + ], + ), + ), + ), + ); + } + + Widget _buildMaterialSelect(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: + options.any((option) => option.value == value) ? value : null, + hint: Text(placeholder), + items: [ + for (final option in options) + DropdownMenuItem( + value: option.value, + child: Text(option.label, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (selection) { + if (selection != null) onChanged(selection); + }, + ), + ), + ), + ); + } + + Future _showOptions(BuildContext context) { + return showCupertinoModalPopup( + context: context, + builder: (sheetContext) => CupertinoActionSheet( + actions: [ + for (final option in options) + CupertinoActionSheetAction( + isDefaultAction: option.value == value, + onPressed: () { + Navigator.of(sheetContext).pop(); + onChanged(option.value); + }, + child: Row( + children: [ + SizedBox( + width: 24, + child: option.value == value + ? const Icon(CupertinoIcons.check_mark, size: 18) + : null, + ), + const SizedBox(width: 8), + Expanded(child: Text(option.label)), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('取消'), + ), + ), + ); + } +} diff --git a/lib/widgets/adaptive_switch.dart b/lib/widgets/adaptive_switch.dart new file mode 100644 index 0000000..efd63c1 --- /dev/null +++ b/lib/widgets/adaptive_switch.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +class AdaptiveSwitch extends StatelessWidget { + const AdaptiveSwitch({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + final bool value; + final ValueChanged onChanged; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (!isIos()) { + return Switch( + value: value, + onChanged: enabled ? onChanged : null, + ); + } + + return SizedBox( + width: 52, + height: iosMinimumInteractiveDimension, + child: Center( + child: CupertinoSwitch( + value: value, + onChanged: enabled ? onChanged : null, + ), + ), + ); + } +} diff --git a/lib/widgets/adaptive_text_area.dart b/lib/widgets/adaptive_text_area.dart new file mode 100644 index 0000000..ce6cef8 --- /dev/null +++ b/lib/widgets/adaptive_text_area.dart @@ -0,0 +1,62 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +class AdaptiveTextArea extends StatelessWidget { + const AdaptiveTextArea({ + super.key, + required this.controller, + required this.placeholder, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.next, + this.minLines = 1, + this.maxLines = 1, + this.enabled = true, + }); + + final TextEditingController controller; + final String placeholder; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final int minLines; + final int maxLines; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (!isIos()) { + return TextField( + controller: controller, + decoration: InputDecoration(hintText: placeholder), + keyboardType: keyboardType, + textInputAction: textInputAction, + minLines: minLines, + maxLines: maxLines, + enabled: enabled, + ); + } + + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 108), + child: CupertinoTextField( + controller: controller, + placeholder: placeholder, + keyboardType: keyboardType, + textInputAction: textInputAction, + minLines: minLines, + maxLines: maxLines, + enabled: enabled, + padding: const EdgeInsets.all(14), + textAlignVertical: TextAlignVertical.top, + clearButtonMode: OverlayVisibilityMode.editing, + decoration: BoxDecoration( + color: CupertinoColors.secondarySystemGroupedBackground.resolveFrom( + context, + ), + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } +} diff --git a/lib/widgets/adaptive_text_field_group.dart b/lib/widgets/adaptive_text_field_group.dart new file mode 100644 index 0000000..b477f5b --- /dev/null +++ b/lib/widgets/adaptive_text_field_group.dart @@ -0,0 +1,103 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../utils/platform.dart'; + +class AdaptiveTextFieldGroupItem { + const AdaptiveTextFieldGroupItem({ + required this.controller, + required this.placeholder, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.next, + this.obscureText = false, + this.enabled = true, + this.onSubmitted, + }); + + final TextEditingController controller; + final String placeholder; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final bool obscureText; + final bool enabled; + final ValueChanged? onSubmitted; +} + +class AdaptiveTextFieldGroup extends StatelessWidget { + const AdaptiveTextFieldGroup({super.key, required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + if (!isIos()) { + return Column( + children: [for (final item in items) _buildMaterialField(item)], + ); + } + + final background = + CupertinoColors.secondarySystemGroupedBackground.resolveFrom(context); + final separator = CupertinoColors.separator.resolveFrom(context); + + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: DecoratedBox( + decoration: BoxDecoration(color: background), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + _buildCupertinoField(context, items[index]), + if (index < items.length - 1) + Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: Container(height: 0.5, color: separator), + ), + ], + ], + ), + ), + ); + } + + Widget _buildCupertinoField( + BuildContext context, + AdaptiveTextFieldGroupItem item, + ) { + return SizedBox( + height: 56, + child: CupertinoTextField( + controller: item.controller, + placeholder: item.placeholder, + keyboardType: item.keyboardType, + textInputAction: item.textInputAction, + obscureText: item.obscureText, + enabled: item.enabled, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration(), + clearButtonMode: item.obscureText + ? OverlayVisibilityMode.never + : OverlayVisibilityMode.editing, + onSubmitted: (value) { + if (item.textInputAction == TextInputAction.next) { + FocusScope.of(context).nextFocus(); + } + item.onSubmitted?.call(value); + }, + ), + ); + } + + Widget _buildMaterialField(AdaptiveTextFieldGroupItem item) { + return TextField( + controller: item.controller, + decoration: InputDecoration(hintText: item.placeholder), + keyboardType: item.keyboardType, + textInputAction: item.textInputAction, + obscureText: item.obscureText, + enabled: item.enabled, + onSubmitted: item.onSubmitted, + ); + } +} diff --git a/lib/widgets/adaptive_text_input_dialog.dart b/lib/widgets/adaptive_text_input_dialog.dart new file mode 100644 index 0000000..1efca41 --- /dev/null +++ b/lib/widgets/adaptive_text_input_dialog.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../utils/platform.dart'; + +Future showAdaptiveTextInputDialog({ + required BuildContext context, + required String title, + required String fieldLabel, + required String confirmLabel, + String? message, + String? hintText, + String initialValue = '', + bool obscureText = false, + String? confirmationFieldLabel, + String mismatchMessage = 'Values do not match', + bool trimResult = false, +}) { + if (isIos()) { + return showCupertinoDialog( + context: context, + builder: (dialogContext) => _AdaptiveTextInputDialog( + title: title, + message: message, + fieldLabel: fieldLabel, + hintText: hintText, + confirmLabel: confirmLabel, + obscureText: obscureText, + confirmationFieldLabel: confirmationFieldLabel, + mismatchMessage: mismatchMessage, + trimResult: trimResult, + initialValue: initialValue, + usesCupertinoStyle: true, + ), + ); + } + + return showDialog( + context: context, + builder: (dialogContext) => _AdaptiveTextInputDialog( + title: title, + message: message, + fieldLabel: fieldLabel, + hintText: hintText, + confirmLabel: confirmLabel, + obscureText: obscureText, + confirmationFieldLabel: confirmationFieldLabel, + mismatchMessage: mismatchMessage, + trimResult: trimResult, + initialValue: initialValue, + usesCupertinoStyle: false, + ), + ); +} + +class _AdaptiveTextInputDialog extends StatefulWidget { + const _AdaptiveTextInputDialog({ + required this.title, + required this.message, + required this.fieldLabel, + required this.hintText, + required this.confirmLabel, + required this.obscureText, + required this.confirmationFieldLabel, + required this.mismatchMessage, + required this.trimResult, + required this.initialValue, + required this.usesCupertinoStyle, + }); + + final String title; + final String? message; + final String fieldLabel; + final String? hintText; + final String confirmLabel; + final bool obscureText; + final String? confirmationFieldLabel; + final String mismatchMessage; + final bool trimResult; + final String initialValue; + final bool usesCupertinoStyle; + + @override + State<_AdaptiveTextInputDialog> createState() => + _AdaptiveTextInputDialogState(); +} + +class _AdaptiveTextInputDialogState extends State<_AdaptiveTextInputDialog> { + late final TextEditingController _controller; + late final TextEditingController _confirmationController; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _confirmationController = TextEditingController(); + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.initialValue.length, + ); + } + + @override + void dispose() { + _controller.dispose(); + _confirmationController.dispose(); + super.dispose(); + } + + bool get _needsConfirmation => widget.confirmationFieldLabel != null; + + bool get _valuesMatch => + !_needsConfirmation || _controller.text == _confirmationController.text; + + bool get _canSubmit => _controller.text.isNotEmpty && _valuesMatch; + + String get _result => + widget.trimResult ? _controller.text.trim() : _controller.text; + + void _close([String? result]) { + FocusManager.instance.primaryFocus?.unfocus(); + if (isIos()) { + unawaited(_hideIosKeyboard()); + } + if (!mounted) return; + Navigator.of(context).pop(result); + } + + Future _hideIosKeyboard() async { + try { + await SystemChannels.textInput.invokeMethod('TextInput.hide'); + } on MissingPluginException { + // Widget tests and non-engine hosts may not expose the text input channel. + } on PlatformException { + // Dismissing the dialog must not depend on keyboard channel availability. + } + } + + void _onChanged(String value) => setState(() {}); + + void _submit() { + if (_canSubmit) _close(_result); + } + + Widget _buildFields() { + final mismatch = _confirmationController.text.isNotEmpty && !_valuesMatch; + final fields = [ + if (widget.message != null) ...[ + Text(widget.message!), + const SizedBox(height: 12), + ], + _buildTextField( + controller: _controller, + label: widget.fieldLabel, + hint: widget.hintText, + autofocus: true, + onSubmitted: _needsConfirmation ? null : (_) => _submit(), + ), + if (_needsConfirmation) ...[ + const SizedBox(height: 12), + _buildTextField( + controller: _confirmationController, + label: widget.confirmationFieldLabel!, + onSubmitted: (_) => _submit(), + ), + ], + if (mismatch) ...[ + const SizedBox(height: 8), + Text( + widget.mismatchMessage, + style: TextStyle( + color: widget.usesCupertinoStyle + ? CupertinoColors.systemRed.resolveFrom(context) + : Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ], + ]; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: fields, + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + String? hint, + bool autofocus = false, + ValueChanged? onSubmitted, + }) { + if (widget.usesCupertinoStyle) { + return CupertinoTextField( + controller: controller, + obscureText: widget.obscureText, + autofocus: autofocus, + placeholder: hint == null ? label : '$label - $hint', + textInputAction: + onSubmitted == null ? TextInputAction.next : TextInputAction.done, + onChanged: _onChanged, + onSubmitted: onSubmitted, + ); + } + + return TextField( + controller: controller, + obscureText: widget.obscureText, + autofocus: autofocus, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ), + textInputAction: + onSubmitted == null ? TextInputAction.next : TextInputAction.done, + onChanged: _onChanged, + onSubmitted: onSubmitted, + ); + } + + @override + Widget build(BuildContext context) { + if (widget.usesCupertinoStyle) { + return CupertinoAlertDialog( + title: Text(widget.title), + content: Padding( + padding: const EdgeInsets.only(top: 12), + child: _buildFields(), + ), + actions: [ + CupertinoDialogAction( + onPressed: _close, + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: _canSubmit ? _submit : null, + child: Text(widget.confirmLabel), + ), + ], + ); + } + + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView(child: _buildFields()), + actions: [ + TextButton(onPressed: _close, child: const Text('取消')), + FilledButton( + onPressed: _canSubmit ? _submit : null, + child: Text(widget.confirmLabel), + ), + ], + ); + } +} diff --git a/lib/widgets/app_shell/app_destination.dart b/lib/widgets/app_shell/app_destination.dart index 45891be..7b5c076 100644 --- a/lib/widgets/app_shell/app_destination.dart +++ b/lib/widgets/app_shell/app_destination.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../ios_liquid/ios_glass_tab_bar.dart'; +import '../ios/ios_native_tab_bar.dart'; class AppDestination { final String label; @@ -19,8 +19,8 @@ class AppDestination { required this.page, }); - IosGlassTabBarItem toIosGlassTabBarItem() { - return IosGlassTabBarItem( + IosNativeTabBarItem toIosNativeTabBarItem() { + return IosNativeTabBarItem( label: label, icon: icon, selectedIcon: selectedIcon, diff --git a/lib/widgets/app_shell/app_shell.dart b/lib/widgets/app_shell/app_shell.dart index 5a6eec3..9f7fa49 100644 --- a/lib/widgets/app_shell/app_shell.dart +++ b/lib/widgets/app_shell/app_shell.dart @@ -5,7 +5,8 @@ import '../../pages/assignments_page.dart'; import '../../pages/home_page.dart'; import '../../pages/schedule_page.dart'; import '../../pages/settings_page.dart'; -import '../../utils/motion.dart'; +import '../../utils/adaptive_layout.dart'; +import '../../utils/platform.dart'; import 'app_destination.dart'; import 'desktop/desktop_shell.dart'; import 'mobile_shell.dart'; @@ -18,8 +19,6 @@ class AppShell extends StatefulWidget { } class _AppShellState extends State { - static const int _assignmentsIndex = 2; - int _selectedIndex = 0; int _previousSelectedIndex = 0; bool _sidebarCollapsed = false; @@ -68,50 +67,13 @@ class _AppShellState extends State { setState(() => _sidebarCollapsed = !_sidebarCollapsed); } - Widget _buildPageView({ - Duration duration = const Duration(milliseconds: 300), - Curve curve = Curves.easeInOutCubicEmphasized, - }) { - return PageTransitionSwitcher( - duration: duration, - layoutBuilder: (entries) => Stack( - fit: StackFit.expand, - children: entries, - ), - transitionBuilder: (child, animation, secondaryAnimation) { - // Pure horizontal slide — no fade, no scale, no color fill. - // Controllers always run forward: the new entry's primary (0→1) - // drives its incoming slide; the old entry's secondary (0→1) - // drives its outgoing slide. Direction is encoded only in the - // tween sign, so backward navigation mirrors forward correctly - // and the easing curve stays consistent in both directions. - final direction = _selectedIndex >= _previousSelectedIndex ? 1.0 : -1.0; - final incoming = Tween( - begin: Offset(direction, 0), - end: Offset.zero, - ); - final outgoing = Tween( - begin: Offset.zero, - end: Offset(-direction, 0), - ); - return AnimatedBuilder( - animation: Listenable.merge([animation, secondaryAnimation]), - builder: (context, built) { - final incomingOffset = incoming.transform( - curve.transform(animation.value), - ); - final outgoingOffset = outgoing.transform( - curve.transform(secondaryAnimation.value), - ); - return FractionalTranslation( - translation: incomingOffset + outgoingOffset, - child: built, - ); - }, - child: child, - ); - }, - child: _destinations[_selectedIndex].page, + Widget _buildPageView(BuildContext context) { + return AppDestinationSwitcher( + selectedIndex: _selectedIndex, + previousSelectedIndex: _previousSelectedIndex, + preserveVisitedPages: isIos(), + animationsEnabled: !MediaQuery.disableAnimationsOf(context), + pages: _destinations.map((destination) => destination.page).toList(), ); } @@ -127,10 +89,10 @@ class _AppShellState extends State { @override Widget build(BuildContext context) { - final width = MediaQuery.sizeOf(context).width; + final windowSizeClass = appWindowSizeClassOf(context); + final pageView = _buildPageView(context); - if (width >= 960) { - final pageView = _buildPageView(); + if (windowSizeClass == AppWindowSizeClass.expanded) { return DesktopShell( destinations: _destinations, selectedIndex: _selectedIndex, @@ -141,7 +103,7 @@ class _AppShellState extends State { ); } - if (width >= 600) { + if (windowSizeClass == AppWindowSizeClass.medium) { return DesktopShell( destinations: _destinations, selectedIndex: _selectedIndex, @@ -149,22 +111,119 @@ class _AppShellState extends State { showToggleButton: false, onDestinationSelected: _onDestinationSelected, onToggleSidebarCollapsed: () {}, - child: _buildDesktopContentNavigator(_buildPageView()), + child: _buildDesktopContentNavigator(pageView), ); } - // Mobile: page slide uses Telegram's main-tabs pager settle (320ms - // EASE_OUT_QUINT) — the same length as the tab selector pop, so the - // bottom bar and the page move on one shared timeline. return MobileShell( destinations: _destinations, selectedIndex: _selectedIndex, - assignmentsIndex: _assignmentsIndex, onDestinationSelected: _onDestinationSelected, - child: _buildPageView( - duration: TgMotion.tabsPageSwitch, - curve: TgMotion.easeOutQuint, + child: pageView, + ); + } +} + +class AppDestinationSwitcher extends StatefulWidget { + const AppDestinationSwitcher({ + super.key, + required this.pages, + required this.selectedIndex, + required this.previousSelectedIndex, + required this.preserveVisitedPages, + required this.animationsEnabled, + }); + + final List pages; + final int selectedIndex; + final int previousSelectedIndex; + final bool preserveVisitedPages; + final bool animationsEnabled; + + @override + State createState() => _AppDestinationSwitcherState(); +} + +class _AppDestinationSwitcherState extends State { + late final Set _visitedIndexes = {widget.selectedIndex}; + + @override + void didUpdateWidget(covariant AppDestinationSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + _visitedIndexes.add(widget.selectedIndex); + _visitedIndexes.removeWhere((index) => index >= widget.pages.length); + } + + @override + Widget build(BuildContext context) { + assert(widget.pages.isNotEmpty); + assert( + widget.selectedIndex >= 0 && widget.selectedIndex < widget.pages.length, + ); + + if (widget.preserveVisitedPages) { + return Stack( + fit: StackFit.expand, + children: [ + for (var index = 0; index < widget.pages.length; index++) + if (_visitedIndexes.contains(index)) + Offstage( + offstage: index != widget.selectedIndex, + child: TickerMode( + enabled: index == widget.selectedIndex, + child: widget.pages[index], + ), + ), + ], + ); + } + + if (!widget.animationsEnabled) { + return widget.pages[widget.selectedIndex]; + } + + return PageTransitionSwitcher( + duration: const Duration(milliseconds: 300), + layoutBuilder: (entries) => Stack( + fit: StackFit.expand, + children: entries, ), + transitionBuilder: (child, animation, secondaryAnimation) { + // Pure horizontal slide — no fade, no scale, no color fill. + // Controllers always run forward: the new entry's primary (0→1) + // drives its incoming slide; the old entry's secondary (0→1) + // drives its outgoing slide. Direction is encoded only in the + // tween sign, so backward navigation mirrors forward correctly + // and the easing curve stays consistent in both directions. + final direction = + widget.selectedIndex >= widget.previousSelectedIndex ? 1.0 : -1.0; + final incoming = Tween( + begin: Offset(direction, 0), + end: Offset.zero, + ); + final outgoing = Tween( + begin: Offset.zero, + end: Offset(-direction, 0), + ); + return AnimatedBuilder( + animation: Listenable.merge([animation, secondaryAnimation]), + builder: (context, built) { + final incomingOffset = incoming.transform( + Curves.easeInOutCubicEmphasized.transform(animation.value), + ); + final outgoingOffset = outgoing.transform( + Curves.easeInOutCubicEmphasized + .transform(secondaryAnimation.value), + ); + return FractionalTranslation( + translation: incomingOffset + outgoingOffset, + child: built, + ); + }, + child: child, + ); + }, + child: widget.pages[widget.selectedIndex], ); } } diff --git a/lib/widgets/app_shell/app_shell_metrics.dart b/lib/widgets/app_shell/app_shell_metrics.dart new file mode 100644 index 0000000..1a8c52a --- /dev/null +++ b/lib/widgets/app_shell/app_shell_metrics.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; + +class AppShellMetrics extends InheritedWidget { + const AppShellMetrics({ + super.key, + required this.bottomObstruction, + required super.child, + }); + + final double bottomObstruction; + + static double bottomContentPaddingOf( + BuildContext context, { + double margin = 16, + }) { + final metrics = + context.dependOnInheritedWidgetOfExactType(); + final obstruction = + metrics?.bottomObstruction ?? MediaQuery.viewPaddingOf(context).bottom; + return obstruction + margin; + } + + @override + bool updateShouldNotify(AppShellMetrics oldWidget) { + return bottomObstruction != oldWidget.bottomObstruction; + } +} diff --git a/lib/widgets/app_shell/mobile_shell.dart b/lib/widgets/app_shell/mobile_shell.dart index a490c16..e66ddc6 100644 --- a/lib/widgets/app_shell/mobile_shell.dart +++ b/lib/widgets/app_shell/mobile_shell.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import '../../utils/platform.dart'; -import '../ios_liquid/ios_glass_button.dart'; -import '../ios_liquid/ios_glass_tab_bar.dart'; +import '../ios/ios_native_tab_bar.dart'; import 'app_destination.dart'; +import 'app_shell_metrics.dart'; import 'tg_bottom_nav_bar.dart'; class MobileShell extends StatelessWidget { final List destinations; final int selectedIndex; - final int assignmentsIndex; final ValueChanged onDestinationSelected; final Widget child; @@ -17,7 +16,6 @@ class MobileShell extends StatelessWidget { super.key, required this.destinations, required this.selectedIndex, - required this.assignmentsIndex, required this.onDestinationSelected, required this.child, }); @@ -25,36 +23,31 @@ class MobileShell extends StatelessWidget { @override Widget build(BuildContext context) { final usesIosChrome = isIos(); + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final bottomObstruction = (usesIosChrome + ? 49.0 + : TgBottomNavBar.barHeight + TgBottomNavBar.margin * 2) + + bottomInset; - return Scaffold( - extendBody: true, - body: child, - floatingActionButton: selectedIndex == assignmentsIndex - ? usesIosChrome - ? IosGlassButton( - onPressed: () {}, - icon: Icons.add, - sfSymbol: 'plus', - ) - : FloatingActionButton( - onPressed: () {}, - child: const Icon(Icons.add), - ) - : null, - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - bottomNavigationBar: usesIosChrome - ? IosGlassTabBar( - selectedIndex: selectedIndex, - items: destinations - .map((item) => item.toIosGlassTabBarItem()) - .toList(), - onSelected: onDestinationSelected, - ) - : TgBottomNavBar( - destinations: destinations, - selectedIndex: selectedIndex, - onDestinationSelected: onDestinationSelected, - ), + return AppShellMetrics( + bottomObstruction: bottomObstruction, + child: Scaffold( + extendBody: true, + body: child, + bottomNavigationBar: usesIosChrome + ? IosNativeTabBar( + selectedIndex: selectedIndex, + items: destinations + .map((item) => item.toIosNativeTabBarItem()) + .toList(), + onSelected: onDestinationSelected, + ) + : TgBottomNavBar( + destinations: destinations, + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + ), + ), ); } } diff --git a/lib/widgets/cupertino_symbol_icons.dart b/lib/widgets/cupertino_symbol_icons.dart new file mode 100644 index 0000000..671ff7f --- /dev/null +++ b/lib/widgets/cupertino_symbol_icons.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart'; + +IconData cupertinoIconForSfSymbol( + String? symbol, { + required IconData fallback, +}) { + return switch (symbol) { + 'arrow.clockwise' || + 'arrow.counterclockwise' || + 'arrow.triangle.2.circlepath' => + CupertinoIcons.refresh, + 'arrow.down.circle' || + 'icloud.and.arrow.down' => + CupertinoIcons.cloud_download, + 'arrow.right' => CupertinoIcons.chevron_forward, + 'arrow.up.circle' || 'icloud.and.arrow.up' => CupertinoIcons.cloud_upload, + 'arrow.uturn.backward' => CupertinoIcons.arrow_uturn_left, + 'calendar.badge.clock' => CupertinoIcons.calendar, + 'checklist' => CupertinoIcons.check_mark_circled, + 'chevron.left' => CupertinoIcons.back, + 'ellipsis' => CupertinoIcons.ellipsis, + 'ellipsis.circle' => CupertinoIcons.ellipsis_circle, + 'gearshape' => CupertinoIcons.gear, + 'gearshape.fill' => CupertinoIcons.gear_solid, + 'house' || 'house.fill' => CupertinoIcons.home, + 'icloud.slash' => CupertinoIcons.cloud, + 'key' => CupertinoIcons.lock, + 'link' || 'link.badge.minus' => CupertinoIcons.link, + 'magnifyingglass' => CupertinoIcons.search, + 'message.badge' => CupertinoIcons.chat_bubble, + 'paperplane.fill' => CupertinoIcons.paperplane_fill, + 'person.crop.circle.badge.plus' => CupertinoIcons.person_add, + 'rectangle.portrait.and.arrow.right' => CupertinoIcons.square_arrow_right, + 'square.and.arrow.down' => CupertinoIcons.square_arrow_down, + 'square.and.arrow.up' => CupertinoIcons.share, + 'trash' => CupertinoIcons.trash, + 'calendar' => CupertinoIcons.calendar, + 'calendar.circle.fill' => CupertinoIcons.calendar_circle_fill, + 'checkmark.circle' => CupertinoIcons.check_mark_circled, + 'checkmark.circle.fill' => CupertinoIcons.check_mark_circled_solid, + _ => fallback, + }; +} diff --git a/lib/widgets/desktop_popup.dart b/lib/widgets/desktop_popup.dart index 6bcf694..bd0c5e7 100644 --- a/lib/widgets/desktop_popup.dart +++ b/lib/widgets/desktop_popup.dart @@ -9,10 +9,6 @@ typedef DesktopPopoverBuilder = Widget Function( VoidCallback close, ); -bool isDesktopLayout(BuildContext context) { - return MediaQuery.sizeOf(context).width >= 600; -} - void showDesktopPopover({ required BuildContext anchorContext, required DesktopPopoverBuilder builder, diff --git a/lib/widgets/ios_liquid/ios_native_navigation_bar.dart b/lib/widgets/ios/ios_native_navigation_bar.dart similarity index 92% rename from lib/widgets/ios_liquid/ios_native_navigation_bar.dart rename to lib/widgets/ios/ios_native_navigation_bar.dart index 28d125c..a42cdbe 100644 --- a/lib/widgets/ios_liquid/ios_native_navigation_bar.dart +++ b/lib/widgets/ios/ios_native_navigation_bar.dart @@ -135,9 +135,7 @@ class _IosNativeNavigationBarState extends State { @override Widget build(BuildContext context) { - if (!isIos()) { - return AppBar(title: Text(widget.title)); - } + if (!isIos()) return AppBar(title: Text(widget.title)); return SafeArea( bottom: false, @@ -170,17 +168,16 @@ class _IosNativeNavigationBarState extends State { }); } - Map get _configuration { - return { - 'title': widget.title, - 'subtitle': widget.subtitle, - 'leadingItems': widget.leadingItems.map((item) => item.toMap()).toList(), - 'trailingItems': - widget.trailingItems.map((item) => item.toMap()).toList(), - 'selectionMode': widget.selectionMode, - 'largeTitleMode': widget.largeTitleMode, - }; - } + Map get _configuration => { + 'title': widget.title, + 'subtitle': widget.subtitle, + 'leadingItems': + widget.leadingItems.map((item) => item.toMap()).toList(), + 'trailingItems': + widget.trailingItems.map((item) => item.toMap()).toList(), + 'selectionMode': widget.selectionMode, + 'largeTitleMode': widget.largeTitleMode, + }; Future _sendConfigurationUpdate(MethodChannel channel) async { try { diff --git a/lib/widgets/ios_liquid/ios_glass_tab_bar.dart b/lib/widgets/ios/ios_native_tab_bar.dart similarity index 88% rename from lib/widgets/ios_liquid/ios_glass_tab_bar.dart rename to lib/widgets/ios/ios_native_tab_bar.dart index ee99347..ab33a39 100644 --- a/lib/widgets/ios_liquid/ios_glass_tab_bar.dart +++ b/lib/widgets/ios/ios_native_tab_bar.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class IosGlassTabBarItem { - const IosGlassTabBarItem({ +class IosNativeTabBarItem { + const IosNativeTabBarItem({ required this.label, required this.icon, required this.selectedIcon, @@ -19,8 +19,8 @@ class IosGlassTabBarItem { final String selectedSfSymbol; } -class IosGlassTabBar extends StatefulWidget { - const IosGlassTabBar({ +class IosNativeTabBar extends StatefulWidget { + const IosNativeTabBar({ super.key, required this.selectedIndex, required this.onSelected, @@ -29,13 +29,13 @@ class IosGlassTabBar extends StatefulWidget { final int selectedIndex; final ValueChanged onSelected; - final List items; + final List items; @override - State createState() => _IosGlassTabBarState(); + State createState() => _IosNativeTabBarState(); } -class _IosGlassTabBarState extends State { +class _IosNativeTabBarState extends State { MethodChannel? _channel; int get _safeSelectedIndex { @@ -44,9 +44,8 @@ class _IosGlassTabBarState extends State { } @override - void didUpdateWidget(covariant IosGlassTabBar oldWidget) { + void didUpdateWidget(covariant IosNativeTabBar oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.selectedIndex == widget.selectedIndex) return; unawaited(_sendSelectionUpdate(_safeSelectedIndex)); } @@ -61,7 +60,8 @@ class _IosGlassTabBarState extends State { @override Widget build(BuildContext context) { final bottomInset = MediaQuery.paddingOf(context).bottom; - final tabBarHeight = 52.0 + bottomInset; + const standardTabBarHeight = 49.0; + final tabBarHeight = standardTabBarHeight + bottomInset; return LayoutBuilder( builder: (context, constraints) { @@ -115,14 +115,11 @@ class _IosGlassTabBarState extends State { channel.setMethodCallHandler((call) async { if (call.method != 'onSelect') return null; - final arguments = call.arguments; final index = arguments is Map ? arguments['index'] : null; - if (index is int && index >= 0 && index < widget.items.length) { widget.onSelected(index); } - return null; }); } diff --git a/lib/widgets/ios_liquid/ios_glass_button.dart b/lib/widgets/ios_liquid/ios_glass_button.dart deleted file mode 100644 index d86a63c..0000000 --- a/lib/widgets/ios_liquid/ios_glass_button.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:techpie/utils/platform.dart'; - -class IosGlassButton extends StatefulWidget { - const IosGlassButton({ - super.key, - required this.onPressed, - required this.icon, - required this.sfSymbol, - }); - - final VoidCallback onPressed; - final IconData icon; - final String sfSymbol; - - @override - State createState() => _IosGlassButtonState(); -} - -class _IosGlassButtonState extends State { - MethodChannel? _channel; - - @override - void didUpdateWidget(covariant IosGlassButton oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.sfSymbol == widget.sfSymbol) return; - unawaited(_sendSymbolUpdate(widget.sfSymbol)); - } - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!isIos()) { - return IconButton.filled( - onPressed: widget.onPressed, - icon: Icon(widget.icon), - ); - } - - return SizedBox.square( - dimension: 64, - child: UiKitView( - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: {'sfSymbol': widget.sfSymbol}, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onTap') return null; - - widget.onPressed(); - return null; - }); - } - - Future _sendSymbolUpdate(String sfSymbol) async { - final channel = _channel; - if (channel == null) return; - - try { - await channel.invokeMethod('updateSymbol', { - 'sfSymbol': sfSymbol, - }); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } -} - -const _viewType = 'techpie/native_glass_button'; -const _channelPrefix = 'techpie/native_glass_button'; diff --git a/lib/widgets/ios_liquid/ios_glass_confirmation_button.dart b/lib/widgets/ios_liquid/ios_glass_confirmation_button.dart deleted file mode 100644 index 7900d45..0000000 --- a/lib/widgets/ios_liquid/ios_glass_confirmation_button.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:techpie/utils/platform.dart'; - -class IosGlassConfirmationButton extends StatefulWidget { - const IosGlassConfirmationButton({ - super.key, - this.label, - required this.confirmTitle, - required this.confirmLabel, - required this.onConfirmed, - this.icon = Icons.link_off, - this.sfSymbol = 'link.badge.minus', - this.destructive = false, - this.width, - this.height = 36, - }); - - final String? label; - final String confirmTitle; - final String confirmLabel; - final VoidCallback onConfirmed; - final IconData icon; - final String sfSymbol; - final bool destructive; - final double? width; - final double height; - - @override - State createState() => - _IosGlassConfirmationButtonState(); -} - -class _IosGlassConfirmationButtonState - extends State { - MethodChannel? _channel; - - bool get _usesNative => isIos(); - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_usesNative) { - if (widget.label == null || widget.label!.isEmpty) { - return IconButton( - onPressed: widget.onConfirmed, - icon: Icon(widget.icon, size: 18), - tooltip: widget.confirmTitle, - ); - } - - return TextButton.icon( - onPressed: widget.onConfirmed, - icon: Icon(widget.icon, size: 18), - label: Text(widget.label!), - ); - } - - final width = widget.width ?? - ((widget.label == null || widget.label!.isEmpty) ? 36.0 : 92.0); - - return SizedBox( - width: width, - height: widget.height, - child: UiKitView( - key: ValueKey( - 'ios-glass-confirm-${widget.label ?? ''}-' - '${widget.confirmTitle}-${widget.confirmLabel}-' - '${widget.sfSymbol}-${widget.destructive}', - ), - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: { - 'label': widget.label, - 'confirmTitle': widget.confirmTitle, - 'confirmLabel': widget.confirmLabel, - 'sfSymbol': widget.sfSymbol, - 'destructive': widget.destructive, - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel?.setMethodCallHandler(null); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onConfirmed') return null; - widget.onConfirmed(); - return null; - }); - } -} - -const _viewType = 'techpie/native_glass_confirmation_button'; -const _channelPrefix = 'techpie/native_glass_confirmation_button'; diff --git a/lib/widgets/ios_liquid/ios_glass_dropdown_menu.dart b/lib/widgets/ios_liquid/ios_glass_dropdown_menu.dart deleted file mode 100644 index 838b463..0000000 --- a/lib/widgets/ios_liquid/ios_glass_dropdown_menu.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:techpie/utils/platform.dart'; - -class IosGlassDropdownMenuItem { - const IosGlassDropdownMenuItem({ - required this.value, - required this.label, - this.checked = false, - this.destructive = false, - this.children, - }); - - final String value; - final String label; - final bool checked; - final bool destructive; - final List? children; -} - -class IosGlassDropdownMenu extends StatefulWidget { - const IosGlassDropdownMenu({ - super.key, - required this.icon, - required this.sfSymbol, - required this.items, - required this.onSelected, - this.label, - this.tooltip, - this.width = 40, - this.height = 40, - }); - - final IconData icon; - final String sfSymbol; - final List items; - final ValueChanged onSelected; - final String? label; - final String? tooltip; - final double width; - final double height; - - @override - State createState() => _IosGlassDropdownMenuState(); -} - -class _IosGlassDropdownMenuState extends State { - MethodChannel? _channel; - - bool get _usesNative => isIos(); - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_usesNative) { - return _buildFallback(); - } - - final signature = widget.items.map(_itemSignature).join(';'); - - return SizedBox( - width: widget.width, - height: widget.height, - child: UiKitView( - key: ValueKey( - 'ios-glass-dropdown-$signature-${widget.sfSymbol}-${widget.label ?? ''}', - ), - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: { - 'sfSymbol': widget.sfSymbol, - 'label': widget.label, - 'items': [for (final item in widget.items) _encodeItem(item)], - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - Widget _buildFallback() { - return PopupMenuButton( - tooltip: widget.tooltip, - icon: Icon(widget.icon), - onSelected: widget.onSelected, - itemBuilder: (context) => [ - for (final item in widget.items) - item.checked - ? CheckedPopupMenuItem( - value: item.value, - checked: true, - child: Text( - item.label, - style: item.destructive - ? TextStyle(color: Theme.of(context).colorScheme.error) - : null, - ), - ) - : PopupMenuItem( - value: item.value, - child: Text( - item.label, - style: item.destructive - ? TextStyle(color: Theme.of(context).colorScheme.error) - : null, - ), - ), - ], - ); - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onSelect') return null; - - final arguments = call.arguments; - final value = arguments is Map ? arguments['value'] : null; - - if (value is String && value.isNotEmpty) { - widget.onSelected(value); - } - - return null; - }); - } - - Map _encodeItem(IosGlassDropdownMenuItem item) { - return { - 'value': item.value, - 'title': item.label, - 'checked': item.checked, - 'destructive': item.destructive, - if (item.children != null) - 'children': [for (final child in item.children!) _encodeItem(child)], - }; - } - - String _itemSignature(IosGlassDropdownMenuItem item) { - final childrenSignature = item.children == null - ? '' - : item.children!.map(_itemSignature).join(','); - return '${item.value}|${item.label}|${item.checked}|${item.destructive}|$childrenSignature'; - } -} - -const _viewType = 'techpie/native_glass_dropdown_menu'; -const _channelPrefix = 'techpie/native_glass_dropdown_menu'; diff --git a/lib/widgets/ios_liquid/ios_glass_select.dart b/lib/widgets/ios_liquid/ios_glass_select.dart deleted file mode 100644 index cf58eab..0000000 --- a/lib/widgets/ios_liquid/ios_glass_select.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:techpie/utils/platform.dart'; - -class IosGlassSelectOption { - const IosGlassSelectOption({required this.value, required this.label}); - - final String value; - final String label; -} - -class IosGlassSelect extends StatefulWidget { - const IosGlassSelect({ - super.key, - required this.options, - required this.onChanged, - this.value, - this.placeholder = 'Select', - this.sfSymbol = 'chevron.up.chevron.down', - this.width = 150, - this.height = 36, - }); - - final List options; - final String? value; - final ValueChanged onChanged; - final String placeholder; - final String sfSymbol; - final double width; - final double height; - - @override - State createState() => _IosGlassSelectState(); -} - -class _IosGlassSelectState extends State { - MethodChannel? _channel; - - bool get _usesNative => isIos(); - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_usesNative) { - return _buildFallback(context); - } - - final signature = widget.options - .map((option) => '${option.value}|${option.label}') - .join(';'); - - return SizedBox( - width: widget.width, - height: widget.height, - child: UiKitView( - key: ValueKey( - 'ios-glass-select-$signature-${widget.value ?? ''}-${widget.placeholder}', - ), - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: { - 'value': widget.value, - 'placeholder': widget.placeholder, - 'sfSymbol': widget.sfSymbol, - 'options': [ - for (final option in widget.options) - {'value': option.value, 'label': option.label}, - ], - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - Widget _buildFallback(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: widget.options.any((option) => option.value == widget.value) - ? widget.value - : null, - hint: Text(widget.placeholder), - items: [ - for (final option in widget.options) - DropdownMenuItem( - value: option.value, - child: Text(option.label, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: (value) { - if (value != null) { - widget.onChanged(value); - } - }, - ), - ), - ), - ); - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onChanged') return null; - - final arguments = call.arguments; - final value = arguments is Map ? arguments['value'] : null; - - if (value is String && value.isNotEmpty) { - widget.onChanged(value); - } - - return null; - }); - } -} - -const _viewType = 'techpie/native_glass_select'; -const _channelPrefix = 'techpie/native_glass_select'; diff --git a/lib/widgets/ios_liquid/ios_glass_switch.dart b/lib/widgets/ios_liquid/ios_glass_switch.dart deleted file mode 100644 index 7a511ca..0000000 --- a/lib/widgets/ios_liquid/ios_glass_switch.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -class IosGlassSwitch extends StatefulWidget { - const IosGlassSwitch({ - super.key, - required this.value, - required this.onChanged, - this.enabled = true, - }); - - final bool value; - final ValueChanged onChanged; - final bool enabled; - - @override - State createState() => _IosGlassSwitchState(); -} - -class _IosGlassSwitchState extends State { - MethodChannel? _channel; - - bool get _usesNative => - !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; - - @override - void didUpdateWidget(covariant IosGlassSwitch oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.value != widget.value) { - unawaited(_sendValueUpdate(widget.value)); - } - - if (oldWidget.enabled != widget.enabled) { - unawaited(_sendEnabledUpdate(widget.enabled)); - } - } - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_usesNative) { - return Switch( - value: widget.value, - onChanged: widget.enabled ? widget.onChanged : null, - ); - } - - return SizedBox( - width: 52, - height: 32, - child: UiKitView( - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: { - 'value': widget.value, - 'enabled': widget.enabled, - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onChanged') return null; - - final arguments = call.arguments; - final value = arguments is Map ? arguments['value'] : null; - - if (value is bool) { - widget.onChanged(value); - } - - return null; - }); - } - - Future _sendValueUpdate(bool value) async { - final channel = _channel; - if (channel == null) return; - - try { - await channel.invokeMethod('updateValue', { - 'value': value, - }); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } - - Future _sendEnabledUpdate(bool enabled) async { - final channel = _channel; - if (channel == null) return; - - try { - await channel.invokeMethod('updateEnabled', { - 'enabled': enabled, - }); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } -} - -const _viewType = 'techpie/native_glass_switch'; -const _channelPrefix = 'techpie/native_glass_switch'; diff --git a/lib/widgets/ios_liquid/ios_native_segmented_control.dart b/lib/widgets/ios_liquid/ios_native_segmented_control.dart deleted file mode 100644 index e943f3e..0000000 --- a/lib/widgets/ios_liquid/ios_native_segmented_control.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../utils/platform.dart'; - -class IosNativeSegmentedControl extends StatefulWidget { - const IosNativeSegmentedControl({ - super.key, - required this.value, - required this.segments, - required this.onChanged, - }); - - final int value; - final List segments; - final ValueChanged onChanged; - - @override - State createState() => - _IosNativeSegmentedControlState(); -} - -class _IosNativeSegmentedControlState extends State { - MethodChannel? _channel; - - @override - void didUpdateWidget(covariant IosNativeSegmentedControl oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.value != widget.value || - oldWidget.segments.length != widget.segments.length || - !_sameSegments(oldWidget.segments, widget.segments)) { - unawaited(_sendConfigurationUpdate()); - } - } - - @override - void dispose() { - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!isIos()) { - return SegmentedButton( - segments: [ - for (var index = 0; index < widget.segments.length; index++) - ButtonSegment( - value: index, - label: Text(widget.segments[index]), - ), - ], - selected: {widget.value}, - onSelectionChanged: (selection) { - if (selection.isNotEmpty) widget.onChanged(selection.first); - }, - ); - } - - return SizedBox( - height: 34, - child: UiKitView( - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: _configuration, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - Map get _configuration => { - 'value': widget.value, - 'segments': widget.segments, - }; - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - if (call.method != 'onChanged') return null; - - final arguments = call.arguments as Map?; - final value = arguments?['value']; - if (value is int) widget.onChanged(value); - return null; - }); - } - - Future _sendConfigurationUpdate() async { - final channel = _channel; - if (channel == null) return; - - try { - await channel.invokeMethod('updateConfiguration', _configuration); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } - - bool _sameSegments(List a, List b) { - if (a.length != b.length) return false; - for (var index = 0; index < a.length; index++) { - if (a[index] != b[index]) return false; - } - return true; - } -} - -const _viewType = 'techpie/native_segmented_control'; -const _channelPrefix = 'techpie/native_segmented_control'; diff --git a/lib/widgets/ios_liquid/ios_native_text_field_group.dart b/lib/widgets/ios_liquid/ios_native_text_field_group.dart deleted file mode 100644 index d92d9ff..0000000 --- a/lib/widgets/ios_liquid/ios_native_text_field_group.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../utils/platform.dart'; - -class IosNativeTextFieldGroupItem { - const IosNativeTextFieldGroupItem({ - required this.controller, - required this.placeholder, - this.keyboardType = TextInputType.text, - this.textInputAction = TextInputAction.next, - this.obscureText = false, - this.enabled = true, - this.onSubmitted, - }); - - final TextEditingController controller; - final String placeholder; - final TextInputType keyboardType; - final TextInputAction textInputAction; - final bool obscureText; - final bool enabled; - final ValueChanged? onSubmitted; -} - -class IosNativeTextFieldGroup extends StatefulWidget { - const IosNativeTextFieldGroup({ - super.key, - required this.items, - }); - - final List items; - - @override - State createState() => - _IosNativeTextFieldGroupState(); -} - -class _IosNativeTextFieldGroupState extends State { - MethodChannel? _channel; - bool _updatingFromNative = false; - - @override - void initState() { - super.initState(); - for (final item in widget.items) { - item.controller.addListener(_sendTextUpdate); - } - } - - @override - void didUpdateWidget(covariant IosNativeTextFieldGroup oldWidget) { - super.didUpdateWidget(oldWidget); - - for (final item in oldWidget.items) { - item.controller.removeListener(_sendTextUpdate); - } - for (final item in widget.items) { - item.controller.addListener(_sendTextUpdate); - } - unawaited(_sendConfigurationUpdate()); - } - - @override - void dispose() { - for (final item in widget.items) { - item.controller.removeListener(_sendTextUpdate); - } - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!isIos()) { - return Column( - children: [ - for (final item in widget.items) - TextField( - controller: item.controller, - decoration: InputDecoration(hintText: item.placeholder), - keyboardType: item.keyboardType, - textInputAction: item.textInputAction, - obscureText: item.obscureText, - enabled: item.enabled, - onSubmitted: item.onSubmitted, - ), - ], - ); - } - - final separatorCount = - widget.items.length > 1 ? widget.items.length - 1 : 0; - return SizedBox( - height: widget.items.length * 56.0 + separatorCount * 0.5, - child: UiKitView( - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: _configuration, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - Map get _configuration { - return { - 'items': [ - for (final item in widget.items) - { - 'text': item.controller.text, - 'placeholder': item.placeholder, - 'keyboardType': _keyboardTypeName(item.keyboardType), - 'textInputAction': item.textInputAction.name, - 'obscureText': item.obscureText, - 'enabled': item.enabled, - }, - ], - }; - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - final arguments = call.arguments as Map?; - final index = arguments?['index']; - if (index is! int || index < 0 || index >= widget.items.length) { - return null; - } - - switch (call.method) { - case 'onChanged': - final text = arguments?['text'] as String? ?? ''; - final controller = widget.items[index].controller; - if (text == controller.text) return null; - _updatingFromNative = true; - controller.value = controller.value.copyWith( - text: text, - selection: TextSelection.collapsed(offset: text.length), - composing: TextRange.empty, - ); - _updatingFromNative = false; - case 'onSubmitted': - final text = arguments?['text'] as String?; - final item = widget.items[index]; - if (text != null && text != item.controller.text) { - _updatingFromNative = true; - item.controller.value = item.controller.value.copyWith( - text: text, - selection: TextSelection.collapsed(offset: text.length), - composing: TextRange.empty, - ); - _updatingFromNative = false; - } - item.onSubmitted?.call(item.controller.text); - } - - return null; - }); - } - - void _sendTextUpdate() { - if (_updatingFromNative) return; - final channel = _channel; - if (channel == null) return; - unawaited( - channel.invokeMethod('updateTexts', { - 'texts': [for (final item in widget.items) item.controller.text], - }), - ); - } - - Future _sendConfigurationUpdate() async { - final channel = _channel; - if (channel == null) return; - - try { - await channel.invokeMethod('updateConfiguration', _configuration); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } - - String _keyboardTypeName(TextInputType keyboardType) { - if (keyboardType == TextInputType.emailAddress) return 'emailAddress'; - if (keyboardType == TextInputType.phone) return 'phone'; - if (keyboardType == TextInputType.url) return 'url'; - if (keyboardType == TextInputType.number) return 'number'; - return 'text'; - } -} - -const _viewType = 'techpie/native_text_field_group'; -const _channelPrefix = 'techpie/native_text_field_group'; diff --git a/lib/widgets/ios_liquid/ios_native_text_view.dart b/lib/widgets/ios_liquid/ios_native_text_view.dart deleted file mode 100644 index ffe91c7..0000000 --- a/lib/widgets/ios_liquid/ios_native_text_view.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../utils/platform.dart'; - -class IosNativeTextView extends StatefulWidget { - const IosNativeTextView({ - super.key, - required this.controller, - required this.placeholder, - this.keyboardType = TextInputType.text, - this.textInputAction = TextInputAction.next, - this.minLines = 1, - this.maxLines = 1, - this.enabled = true, - }); - - final TextEditingController controller; - final String placeholder; - final TextInputType keyboardType; - final TextInputAction textInputAction; - final int minLines; - final int maxLines; - final bool enabled; - - @override - State createState() => _IosNativeTextViewState(); -} - -class _IosNativeTextViewState extends State { - MethodChannel? _channel; - bool _updatingFromNative = false; - - @override - void initState() { - super.initState(); - widget.controller.addListener(_sendTextUpdate); - } - - @override - void didUpdateWidget(covariant IosNativeTextView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_sendTextUpdate); - widget.controller.addListener(_sendTextUpdate); - } - unawaited(_sendConfigurationUpdate()); - } - - @override - void dispose() { - widget.controller.removeListener(_sendTextUpdate); - _channel?.setMethodCallHandler(null); - _channel = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!isIos()) { - return TextField( - controller: widget.controller, - decoration: InputDecoration( - hintText: widget.placeholder, - ), - keyboardType: widget.keyboardType, - textInputAction: widget.textInputAction, - minLines: widget.minLines, - maxLines: widget.maxLines, - enabled: widget.enabled, - ); - } - - return SizedBox( - height: 108, - child: UiKitView( - viewType: _viewType, - layoutDirection: Directionality.of(context), - creationParams: _configuration, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: _onPlatformViewCreated, - ), - ); - } - - Map get _configuration { - return { - 'text': widget.controller.text, - 'placeholder': widget.placeholder, - 'keyboardType': _keyboardTypeName(widget.keyboardType), - 'textInputAction': widget.textInputAction.name, - 'minLines': widget.minLines, - 'maxLines': widget.maxLines, - 'enabled': widget.enabled, - }; - } - - void _onPlatformViewCreated(int viewId) { - final channel = MethodChannel('$_channelPrefix/$viewId'); - _channel = channel; - - channel.setMethodCallHandler((call) async { - final arguments = call.arguments as Map?; - switch (call.method) { - case 'onChanged': - final text = arguments?['text'] as String? ?? ''; - if (text == widget.controller.text) return null; - _updatingFromNative = true; - widget.controller.value = widget.controller.value.copyWith( - text: text, - selection: TextSelection.collapsed(offset: text.length), - composing: TextRange.empty, - ); - _updatingFromNative = false; - } - return null; - }); - } - - void _sendTextUpdate() { - if (_updatingFromNative) return; - final channel = _channel; - if (channel == null) return; - unawaited( - channel.invokeMethod('updateText', { - 'text': widget.controller.text, - }), - ); - } - - Future _sendConfigurationUpdate() async { - final channel = _channel; - if (channel == null) return; - try { - await channel.invokeMethod('updateConfiguration', _configuration); - } on PlatformException { - // Platform view may be tearing down. - } on MissingPluginException { - // Platform view may not be wired yet. - } - } - - String _keyboardTypeName(TextInputType keyboardType) { - if (keyboardType == TextInputType.emailAddress) return 'emailAddress'; - if (keyboardType == TextInputType.phone) return 'phone'; - if (keyboardType == TextInputType.url) return 'url'; - if (keyboardType == TextInputType.number) return 'number'; - return 'text'; - } -} - -const _viewType = 'techpie/native_text_view'; -const _channelPrefix = 'techpie/native_text_view'; diff --git a/lib/widgets/ios_liquid/ios_platform_view_page_transitions.dart b/lib/widgets/ios_liquid/ios_platform_view_page_transitions.dart deleted file mode 100644 index aa63883..0000000 --- a/lib/widgets/ios_liquid/ios_platform_view_page_transitions.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../utils/platform.dart'; - -const _presenterChannel = MethodChannel('techpie/native_glass_presenter'); -const _interactiveTransitionChannel = MethodChannel( - 'techpie/native_page_transition', -); - -final List<_InteractivePopEntry> _interactivePopEntries = []; -bool _interactivePopHandlerInstalled = false; - -class _InteractivePopEntry { - const _InteractivePopEntry(this.navigator, this.route); - - final NavigatorState navigator; - final Route route; -} - -/// 推入包含 iOS Platform View 的页面。 -/// -/// Flutter 3.27 的 Cupertino 路由会分别变换每个 [UiKitView],可能在 -/// iOS 上产生黑白矩形与控件碎片。iOS 端改为对完整应用视图执行一次原生 -/// 转场,Flutter 路由本身瞬时切换,从而保持 Liquid Glass 的真实绘制。 -Future pushPlatformViewPage( - BuildContext context, { - required WidgetBuilder builder, - RouteSettings? settings, -}) async { - final navigator = Navigator.of(context); - - if (!isIos()) { - return navigator.push( - MaterialPageRoute(settings: settings, builder: builder), - ); - } - - await _beginNativeTransition('push'); - if (!navigator.mounted) { - await _cancelNativeTransition(); - return null; - } - - _installInteractivePopHandler(); - final route = PageRouteBuilder( - settings: settings, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (context, animation, secondaryAnimation) => builder(context), - ); - final entry = _InteractivePopEntry(navigator, route); - _interactivePopEntries.add(entry); - - try { - final result = navigator.push(route); - unawaited(_finishNativeTransitionAfterFrame()); - return await result; - } finally { - _interactivePopEntries.remove(entry); - } -} - -/// 返回包含 iOS Platform View 的上一页。 -Future maybePopPlatformViewPage( - BuildContext context, [ - T? result, -]) async { - final navigator = Navigator.of(context); - if (!navigator.canPop()) return false; - - if (isIos()) { - await _beginNativeTransition('pop'); - if (!navigator.mounted) { - await _cancelNativeTransition(); - return false; - } - } - - final didPop = await navigator.maybePop(result); - if (isIos()) { - if (didPop) { - await _finishNativeTransitionAfterFrame(); - } else { - await _cancelNativeTransition(); - } - } - return didPop; -} - -Future _beginNativeTransition(String direction) async { - try { - await _presenterChannel.invokeMethod( - 'beginPageTransition', - {'direction': direction}, - ); - } on MissingPluginException { - // 仍使用无 Flutter Transform 的路由,避免恢复有问题的合成路径。 - } on PlatformException { - // 原生转场不可用时保持瞬时切换,页面功能仍可正常使用。 - } -} - -Future _finishNativeTransitionAfterFrame() async { - await WidgetsBinding.instance.endOfFrame; - await Future.delayed(const Duration(milliseconds: 32)); - try { - await _presenterChannel.invokeMethod('finishPageTransition'); - } on MissingPluginException { - // 平台插件不可用时,零时长 Flutter 路由仍能安全完成切换。 - } on PlatformException { - await _cancelNativeTransition(); - } -} - -Future _cancelNativeTransition() async { - try { - await _presenterChannel.invokeMethod('cancelPageTransition'); - } on MissingPluginException { - // 无待清理的原生快照。 - } on PlatformException { - // 原生端会在下一次 begin 时清理残留快照。 - } -} - -void _installInteractivePopHandler() { - if (_interactivePopHandlerInstalled) return; - _interactivePopHandlerInstalled = true; - - _interactiveTransitionChannel.setMethodCallHandler((call) async { - if (call.method != 'interactivePop') { - throw MissingPluginException('Unknown method ${call.method}'); - } - - for (final entry in _interactivePopEntries.reversed) { - if (!entry.navigator.mounted || !entry.route.isCurrent) continue; - - final didPop = await entry.navigator.maybePop(); - if (didPop) { - await WidgetsBinding.instance.endOfFrame; - await Future.delayed(const Duration(milliseconds: 32)); - } - return didPop; - } - - return false; - }); -} diff --git a/lib/widgets/swipeable_card.dart b/lib/widgets/swipeable_card.dart index e426424..c16d161 100644 --- a/lib/widgets/swipeable_card.dart +++ b/lib/widgets/swipeable_card.dart @@ -1,4 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; + +import '../utils/platform.dart'; class SwipeAction { final IconData icon; @@ -129,6 +134,10 @@ class _SwipeableCardState extends State await _springBack(); return; } + await _dismissToEnd(); + } + + Future _dismissToEnd() async { await _flyOff(-1); if (!mounted) return; await _collapseDown(); @@ -137,22 +146,39 @@ class _SwipeableCardState extends State } Future _springBack() { + if (_reduceMotion) { + _move.value = 0; + return Future.value(); + } return _move.animateTo( 0, - duration: const Duration(milliseconds: 280), - curve: _emphasized, + duration: Duration(milliseconds: isIos() ? 250 : 280), + curve: isIos() ? Curves.easeOut : _emphasized, ); } Future _flyOff(int sign) { + if (_reduceMotion) { + _move.value = sign * 1.25; + return Future.value(); + } return _move.animateTo( sign * 1.25, - duration: const Duration(milliseconds: 220), - curve: _emphasizedAccelerate, + duration: Duration(milliseconds: isIos() ? 200 : 220), + curve: isIos() ? Curves.easeIn : _emphasizedAccelerate, ); } - Future _collapseDown() => _collapse.forward(); + Future _collapseDown() { + if (_reduceMotion) { + _collapse.value = 1; + return Future.value(); + } + return _collapse.forward(); + } + + bool get _reduceMotion => + MediaQuery.maybeOf(context)?.disableAnimations ?? false; // ------------------------------------------------------------------ @@ -163,7 +189,7 @@ class _SwipeableCardState extends State builder: (context, child) { final t = _collapse.value; // Use Curves.easeInOutCubicEmphasized via direct curve evaluation. - final eased = _emphasized.transform(t); + final eased = (isIos() ? Curves.easeInOut : _emphasized).transform(t); return ClipRect( child: Align( heightFactor: (1 - eased).clamp(0.0, 1.0), @@ -204,14 +230,17 @@ class _SwipeableCardState extends State Transform.translate( offset: Offset(value * _width * 0.92, 0), child: Transform.scale( - scale: 1 - progress * 0.04, + scale: isIos() ? 1 : 1 - progress * 0.04, alignment: Alignment.center, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: _onStart, - onHorizontalDragUpdate: _onUpdate, - onHorizontalDragEnd: _onEnd, - child: widget.child, + child: Semantics( + customSemanticsActions: _semanticActions, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: _onStart, + onHorizontalDragUpdate: _onUpdate, + onHorizontalDragEnd: _onEnd, + child: widget.child, + ), ), ), ), @@ -222,6 +251,20 @@ class _SwipeableCardState extends State ), ); } + + Map? get _semanticActions { + if (!widget.enabled) return null; + final actions = {}; + if (widget.startAction != null && widget.onStartSwipe != null) { + actions[CustomSemanticsAction(label: widget.startAction!.label)] = + widget.onStartSwipe!; + } + if (widget.endAction != null && widget.onDismissed != null) { + actions[CustomSemanticsAction(label: widget.endAction!.label)] = + () => unawaited(_dismissToEnd()); + } + return actions.isEmpty ? null : actions; + } } /// Animated entry wrapper that runs a height-expand + fade-in + slight @@ -251,7 +294,18 @@ class _CardEnterAnimationState extends State late final AnimationController _ctrl = AnimationController( vsync: this, duration: widget.duration, - )..forward(); + ); + bool _started = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + if (!(MediaQuery.maybeOf(context)?.disableAnimations ?? false)) { + _ctrl.forward(); + } + } @override void dispose() { diff --git a/test/adaptive_alert_dialog_test.dart b/test/adaptive_alert_dialog_test.dart new file mode 100644 index 0000000..ea4af77 --- /dev/null +++ b/test/adaptive_alert_dialog_test.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_alert_dialog.dart'; + +void main() { + const presenterChannel = MethodChannel('techpie/native_glass_presenter'); + + testWidgets('iOS fallback keeps Cupertino actions and returns selection', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + String? result; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + presenterChannel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + showAdaptiveAlertDialog( + context: context, + title: 'Remove account?', + message: 'This cannot be undone.', + actions: const [ + AdaptiveAlertAction( + label: 'Cancel', + value: 'cancel', + ), + AdaptiveAlertAction( + label: 'Remove', + value: 'remove', + isDestructive: true, + ), + ], + ).then((value) => result = value), + ), + child: const Text('Show dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Show dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + final destructiveAction = tester.widget( + find.widgetWithText(CupertinoDialogAction, 'Remove'), + ); + expect(destructiveAction.isDestructiveAction, isTrue); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(result, 'remove'); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(presenterChannel, null); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_button_test.dart b/test/adaptive_button_test.dart new file mode 100644 index 0000000..1c46883 --- /dev/null +++ b/test/adaptive_button_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_button.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('non-iOS action button uses Material control and handles taps', ( + WidgetTester tester, + ) async { + var tapCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveButton( + label: 'Back up now', + subtitle: 'Upload encrypted bindings', + icon: Icons.cloud_upload_outlined, + sfSymbol: 'arrow.up.circle', + role: AdaptiveButtonRole.prominent, + onPressed: () => tapCount++, + ), + ), + ), + ); + + expect(find.byType(FilledButton), findsOneWidget); + await tester.tap(find.text('Back up now')); + expect(tapCount, 1); + }); + + testWidgets('iOS action button uses composited Cupertino state', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AdaptiveButton( + label: 'Disable sync', + subtitle: 'Remove the cloud backup', + icon: Icons.cloud_off_outlined, + sfSymbol: 'icloud.slash', + role: AdaptiveButtonRole.destructive, + loading: true, + onPressed: null, + accessibilityLabel: 'Disable cloud sync', + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + final button = tester.widget(find.byType(CupertinoButton)); + expect(button.onPressed, isNull); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_confirmation_button_test.dart b/test/adaptive_confirmation_button_test.dart new file mode 100644 index 0000000..1ed1960 --- /dev/null +++ b/test/adaptive_confirmation_button_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_confirmation_button.dart'; + +void main() { + const presenterChannel = MethodChannel('techpie/native_glass_presenter'); + + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS confirmation executes only after system confirmation', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var confirmed = false; + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(presenterChannel, (call) async { + nativeCall = call; + return 1; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(presenterChannel, null), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveConfirmationButton( + label: 'Logout', + confirmTitle: '退出登录?', + confirmLabel: '退出', + destructive: true, + onConfirmed: () => confirmed = true, + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + expect(confirmed, isFalse); + + await tester.tap(find.text('Logout')); + await tester.pump(); + + expect(nativeCall?.method, 'showAlert'); + expect(confirmed, isTrue); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_layout_test.dart b/test/adaptive_layout_test.dart new file mode 100644 index 0000000..203e141 --- /dev/null +++ b/test/adaptive_layout_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/utils/adaptive_layout.dart'; +import 'package:techpie/widgets/app_shell/app_shell_metrics.dart'; + +void main() { + test('window size classes use stable navigation breakpoints', () { + expect(appWindowSizeClassFor(599), AppWindowSizeClass.compact); + expect(appWindowSizeClassFor(600), AppWindowSizeClass.medium); + expect(appWindowSizeClassFor(959), AppWindowSizeClass.medium); + expect(appWindowSizeClassFor(960), AppWindowSizeClass.expanded); + }); + + testWidgets('shell metrics expose navigation obstruction to page content', ( + WidgetTester tester, + ) async { + late double bottomPadding; + + await tester.pumpWidget( + MaterialApp( + home: AppShellMetrics( + bottomObstruction: 83, + child: Builder( + builder: (context) { + bottomPadding = AppShellMetrics.bottomContentPaddingOf(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(bottomPadding, 99); + }); +} diff --git a/test/adaptive_motion_test.dart b/test/adaptive_motion_test.dart new file mode 100644 index 0000000..7394296 --- /dev/null +++ b/test/adaptive_motion_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/utils/adaptive_motion.dart'; + +void main() { + testWidgets('system Reduce Motion disables app animation durations', ( + WidgetTester tester, + ) async { + Duration? effectiveDuration; + bool? animationsEnabled; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: Builder( + builder: (context) { + animationsEnabled = appAnimationsEnabled(context); + effectiveDuration = appAnimationDuration( + context, + const Duration(milliseconds: 350), + ); + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + expect(animationsEnabled, isFalse); + expect(effectiveDuration, Duration.zero); + }); +} diff --git a/test/adaptive_page_navigation_test.dart b/test/adaptive_page_navigation_test.dart new file mode 100644 index 0000000..0e6e152 --- /dev/null +++ b/test/adaptive_page_navigation_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_page_navigation.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS navigation uses CupertinoPageRoute', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final observer = _RouteObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + pushAdaptivePage( + context, + builder: (_) => const Scaffold(body: Text('Next page')), + ), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + + expect(observer.lastPushedRoute, isA>()); + debugDefaultTargetPlatformOverride = null; + + await tester.pumpAndSettle(); + expect(find.text('Next page'), findsOneWidget); + }); + + testWidgets('non-iOS navigation uses MaterialPageRoute', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final observer = _RouteObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + pushAdaptivePage( + context, + builder: (_) => const Scaffold(body: Text('Next page')), + ), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + + expect(observer.lastPushedRoute, isA>()); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _RouteObserver extends NavigatorObserver { + Route? lastPushedRoute; + + @override + void didPush(Route route, Route? previousRoute) { + lastPushedRoute = route; + super.didPush(route, previousRoute); + } +} diff --git a/test/adaptive_select_test.dart b/test/adaptive_select_test.dart new file mode 100644 index 0000000..20e9a9f --- /dev/null +++ b/test/adaptive_select_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_select.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS select uses a composited Cupertino action sheet', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + String? selectedValue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveSelect( + value: 'system', + options: const [ + AdaptiveSelectOption(value: 'system', label: 'System'), + AdaptiveSelectOption(value: 'dark', label: 'Dark'), + ], + onChanged: (value) => selectedValue = value, + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + await tester.tap(find.text('System')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoActionSheet), findsOneWidget); + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(selectedValue, 'dark'); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_selection_controls_test.dart b/test/adaptive_selection_controls_test.dart new file mode 100644 index 0000000..3131cf1 --- /dev/null +++ b/test/adaptive_selection_controls_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_segmented_control.dart'; +import 'package:techpie/widgets/adaptive_switch.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS switch is composited and reports changes', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + bool? changedValue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveSwitch( + value: false, + onChanged: (value) => changedValue = value, + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + await tester.tap(find.byType(CupertinoSwitch)); + expect(changedValue, isTrue); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('iOS segmented control is composited and reports selection', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + int? selectedValue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveSegmentedControl( + value: 0, + segments: const ['密码登录', '短信登录'], + onChanged: (value) => selectedValue = value, + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + await tester.tap(find.text('短信登录')); + await tester.pumpAndSettle(); + expect(selectedValue, 1); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_text_controls_test.dart b/test/adaptive_text_controls_test.dart new file mode 100644 index 0000000..f9d523c --- /dev/null +++ b/test/adaptive_text_controls_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_text_area.dart'; +import 'package:techpie/widgets/adaptive_text_field_group.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets( + 'iOS grouped fields use Flutter text input and update controllers', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final account = TextEditingController(); + final password = TextEditingController(); + addTearDown(account.dispose); + addTearDown(password.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveTextFieldGroup( + items: [ + AdaptiveTextFieldGroupItem( + controller: account, + placeholder: '学号', + ), + AdaptiveTextFieldGroupItem( + controller: password, + placeholder: '密码', + obscureText: true, + textInputAction: TextInputAction.done, + ), + ], + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(CupertinoTextField), findsNWidgets(2)); + await tester.enterText(find.byType(CupertinoTextField).first, '20260001'); + expect(account.text, '20260001'); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('iOS multiline field stays in the Flutter scene', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AdaptiveTextArea( + controller: controller, + placeholder: 'Domain', + minLines: 2, + maxLines: 6, + ), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + await tester.enterText(find.byType(CupertinoTextField), 'SI100B_2026'); + expect(controller.text, 'SI100B_2026'); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/adaptive_text_input_dialog_test.dart b/test/adaptive_text_input_dialog_test.dart new file mode 100644 index 0000000..1be8573 --- /dev/null +++ b/test/adaptive_text_input_dialog_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/adaptive_text_input_dialog.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS text input dialog uses Cupertino controls and returns text', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + String? result; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + showAdaptiveTextInputDialog( + context: context, + title: 'Name', + fieldLabel: 'Calendar name', + confirmLabel: 'Import', + trimResult: true, + ).then((value) => result = value), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + expect(find.byType(EditableText), findsOneWidget); + expect(find.text('Import'), findsOneWidget); + + await tester.enterText(find.byType(EditableText), ' Spring '); + await tester.pump(); + final importAction = tester.widget( + find.widgetWithText(CupertinoDialogAction, 'Import'), + ); + expect(importAction.onPressed, isNotNull); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoAlertDialog), findsNothing); + expect(result, 'Spring'); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('confirmation must match before the dialog submits', ( + WidgetTester tester, + ) async { + String? result; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + showAdaptiveTextInputDialog( + context: context, + title: 'Password', + fieldLabel: 'Password', + confirmationFieldLabel: 'Confirm password', + mismatchMessage: 'Mismatch', + confirmLabel: 'OK', + obscureText: true, + ).then((value) => result = value), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), 'secret'); + await tester.enterText(fields.at(1), 'different'); + await tester.pump(); + expect(find.text('Mismatch'), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pump(); + expect(find.text('Password'), findsWidgets); + expect(result, isNull); + + await tester.enterText(fields.at(1), 'secret'); + await tester.pump(); + final submitButton = tester.widget( + find.widgetWithText(FilledButton, 'OK'), + ); + expect(submitButton.onPressed, isNotNull); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(result, 'secret'); + }); +} diff --git a/test/ios_design_tokens_test.dart b/test/ios_design_tokens_test.dart new file mode 100644 index 0000000..89e816c --- /dev/null +++ b/test/ios_design_tokens_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/utils/platform.dart'; + +void main() { + test('iOS interactive controls use the Apple minimum hit target', () { + expect(iosMinimumInteractiveDimension, 44); + }); +} diff --git a/test/ios_native_navigation_bar_test.dart b/test/ios_native_navigation_bar_test.dart new file mode 100644 index 0000000..6931a8c --- /dev/null +++ b/test/ios_native_navigation_bar_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/ios/ios_native_navigation_bar.dart'; + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + testWidgets('iOS header is backed by the native navigation bar', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + appBar: IosNativeNavigationBar( + title: 'Cloud sync', + leadingItems: [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Settings', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Settings', + ), + ], + ), + body: SizedBox(), + ), + ), + ); + + final platformView = tester.widget(find.byType(UiKitView)); + expect(platformView.viewType, 'techpie/native_navigation_bar'); + expect(platformView.creationParams, { + 'title': 'Cloud sync', + 'subtitle': null, + 'leadingItems': [ + { + 'id': 'back', + 'title': 'Settings', + 'sfSymbol': 'chevron.left', + 'role': 'normal', + 'enabled': true, + 'hidden': false, + 'accessibilityLabel': '返回 Settings', + 'placementGroup': null, + 'menuItems': [], + }, + ], + 'trailingItems': [], + 'selectionMode': false, + 'largeTitleMode': false, + }); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/ios_native_tab_bar_test.dart b/test/ios_native_tab_bar_test.dart new file mode 100644 index 0000000..9bffae9 --- /dev/null +++ b/test/ios_native_tab_bar_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/widgets/ios/ios_native_tab_bar.dart'; + +void main() { + testWidgets('tab bar is backed by the native UIKit implementation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: IosNativeTabBar( + selectedIndex: 0, + items: const [ + IosNativeTabBarItem( + label: 'Home', + icon: Icons.home_outlined, + selectedIcon: Icons.home, + sfSymbol: 'house', + selectedSfSymbol: 'house.fill', + ), + IosNativeTabBarItem( + label: 'Settings', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + sfSymbol: 'gearshape', + selectedSfSymbol: 'gearshape.fill', + ), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + final platformView = tester.widget(find.byType(UiKitView)); + expect(platformView.viewType, 'techpie/native_glass_tab_bar'); + expect(platformView.creationParams, { + 'selectedIndex': 0, + 'items': [ + { + 'label': 'Home', + 'sfSymbol': 'house', + 'selectedSfSymbol': 'house.fill', + }, + { + 'label': 'Settings', + 'sfSymbol': 'gearshape', + 'selectedSfSymbol': 'gearshape.fill', + }, + ], + }); + }); +} diff --git a/test/theme_service_test.dart b/test/theme_service_test.dart new file mode 100644 index 0000000..75d63d9 --- /dev/null +++ b/test/theme_service_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:techpie/services/storage_service.dart'; +import 'package:techpie/services/theme_service.dart'; + +void main() { + test('iOS keeps a consistent system tint across Flutter and UIKit', () async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final themeService = ThemeService(StorageService(preferences)); + + expect(themeService.supportsColorSchemeSelection, isFalse); + expect( + themeService.lightTheme.colorScheme.primary, + const Color(0xFF007AFF), + ); + + await themeService.setColorScheme(AppColorScheme.techRed); + expect( + themeService.lightTheme.colorScheme.primary, + const Color(0xFF007AFF), + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 4811f70..60543db 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -14,9 +14,53 @@ import 'package:techpie/services/sync_service.dart'; import 'package:techpie/services/theme_service.dart'; import 'package:techpie/services/third_party_auth_service.dart'; import 'package:techpie/services/uni_auth_service.dart'; +import 'package:techpie/widgets/app_shell/app_shell.dart'; import 'package:techpie/widgets/app_shell/tg_bottom_nav_bar.dart'; void main() { + testWidgets('iOS-style destination switching lazily preserves visited pages', + ( + WidgetTester tester, + ) async { + var selectedIndex = 0; + late StateSetter setHostState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + setHostState = setState; + return AppDestinationSwitcher( + pages: const [ + _StatefulDestination(label: 'First'), + _StatefulDestination(label: 'Second'), + ], + selectedIndex: selectedIndex, + previousSelectedIndex: 0, + preserveVisitedPages: true, + animationsEnabled: true, + ); + }, + ), + ), + ); + + expect(find.text('First: 0'), findsOneWidget); + expect(find.text('Second: 0'), findsNothing); + await tester.tap(find.text('First: 0')); + await tester.pump(); + + setHostState(() => selectedIndex = 1); + await tester.pump(); + expect(find.text('First: 1'), findsNothing); + expect(find.text('Second: 0'), findsOneWidget); + + setHostState(() => selectedIndex = 0); + await tester.pump(); + expect(find.text('First: 1'), findsOneWidget); + expect(find.text('Second: 0'), findsNothing); + }); + testWidgets('App shell renders with desktop sidebar', ( WidgetTester tester, ) async { @@ -111,6 +155,7 @@ void main() { await tester.tap(find.text('Deadlines').first); await tester.pumpAndSettle(); expect(find.text('No upcoming deadlines'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsNothing); // Tap Settings await tester.tap(find.text('Settings').first); @@ -118,3 +163,24 @@ void main() { expect(find.text('Appearance'), findsOneWidget); }); } + +class _StatefulDestination extends StatefulWidget { + const _StatefulDestination({required this.label}); + + final String label; + + @override + State<_StatefulDestination> createState() => _StatefulDestinationState(); +} + +class _StatefulDestinationState extends State<_StatefulDestination> { + var _count = 0; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () => setState(() => _count++), + child: Text('${widget.label}: $_count'), + ); + } +}