From 224fb9edd4ca19401c2ba1a4c719c3edd5e1838b Mon Sep 17 00:00:00 2001 From: Olof Hedman Date: Wed, 22 Oct 2025 14:18:14 +0200 Subject: [PATCH] Implement "toasts" for iOS for UiFeedback.showMessage() --- .../app/klottr/platform/AppNavigator.ios.kt | 135 +++++++++++++++++- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/shared/src/iosMain/kotlin/app/klottr/platform/AppNavigator.ios.kt b/shared/src/iosMain/kotlin/app/klottr/platform/AppNavigator.ios.kt index 94ba876..cddea96 100644 --- a/shared/src/iosMain/kotlin/app/klottr/platform/AppNavigator.ios.kt +++ b/shared/src/iosMain/kotlin/app/klottr/platform/AppNavigator.ios.kt @@ -2,6 +2,7 @@ package app.klottr.platform +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents import platform.Foundation.NSURL @@ -11,14 +12,59 @@ import platform.Foundation.NSURLQueryItem import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue import platform.Foundation.* +import platform.Foundation.NSTimeInterval +import platform.UIKit.* +import platform.darwin.dispatch_after +import platform.darwin.dispatch_time +private fun runOnMain(block: () -> Unit) { + if (NSThread.isMainThread) block() else { + dispatch_async(dispatch_get_main_queue(), block) + } +} + +private fun activeWindow(): UIWindow? { + val app = UIApplication.sharedApplication + + // iOS 13+ multi-scene: iterate foreground-active scenes + val scenes = app.connectedScenes // NSSet? + for (sceneAny in scenes) { // fast-enumeration over NSSet + val scene = sceneAny as? UIScene ?: continue + if (scene.activationState != UISceneActivationStateForegroundActive) continue + + val windowScene = scene as? UIWindowScene ?: continue + val windows = windowScene.windows.mapNotNull { it as? UIWindow } + + // Prefer key window + windows.firstOrNull { it.keyWindow }?.let { return it } + // Then any visible, normal-level window + windows.firstOrNull { !it.isHidden() && it.alpha > 0.0 && it.windowLevel == UIWindowLevelNormal }?.let { return it } + // Finally, any window in that scene + windows.firstOrNull()?.let { return it } + } + + // Fallback (pre-iOS 13 or no active scene): scan application windows + val appWindows = app.windows.mapNotNull { it as? UIWindow } + appWindows.firstOrNull { it.keyWindow }?.let { return it } + return appWindows.firstOrNull() +} + + +private fun seconds(duration: UiFeedbackDuration): NSTimeInterval = + when (duration) { + UiFeedbackDuration.SHORT -> 2.0 + UiFeedbackDuration.LONG -> 3.5 + } + +@OptIn(BetaInteropApi::class) private fun urlEncode(query: String): String { // Percent-encode for use in URL query - val encoded = (query as NSString) + // Create an NSString instance; avoid `as NSString` + val nsQuery: NSString = NSString.create(string = query) + return nsQuery .stringByAddingPercentEncodingWithAllowedCharacters( NSCharacterSet.URLQueryAllowedCharacterSet() - ) - return encoded ?: query + ) ?: query } @@ -80,13 +126,88 @@ actual object AppNavigator { "maps://?daddr=$encoded&dirflg=d" } - openUrlOnMain((NSURL(string = urlString))) + runOnMain { + UIApplication.sharedApplication.openURL( + NSURL(string = urlString), + options = emptyMap(), + completionHandler = null + ) + } } } actual object UiFeedback { actual fun showMessage(text: String, duration: UiFeedbackDuration) { - // No-op or implement a lightweight HUD; keeping no-op by default. - } -} + runOnMain { + val window = activeWindow() ?: return@runOnMain + + // Container view (rounded, semi-opaque) + val container = UIView().apply { + backgroundColor = UIColor.blackColor.colorWithAlphaComponent(0.85) + layer.cornerRadius = 12.0 + clipsToBounds = true + alpha = 0.0 + translatesAutoresizingMaskIntoConstraints = false + accessibilityLabel = "Toast" + accessibilityTraits = UIAccessibilityTraitStaticText + } + + // Label + val label = UILabel().apply { + textColor = UIColor.whiteColor + font = UIFont.systemFontOfSize(15.0) + numberOfLines = 0 + textAlignment = NSTextAlignmentCenter + this.text = text + translatesAutoresizingMaskIntoConstraints = false + setContentCompressionResistancePriority(UILayoutPriorityRequired, UILayoutConstraintAxisHorizontal) + setContentHuggingPriority(UILayoutPriorityRequired, UILayoutConstraintAxisHorizontal) + } + + container.addSubview(label) + window.addSubview(container) + + // Layout: centerX, above bottom safe area; label has padding + val safe = window.safeAreaLayoutGuide + NSLayoutConstraint.activateConstraints(listOf( + container.centerXAnchor.constraintEqualToAnchor(window.centerXAnchor), + container.bottomAnchor.constraintEqualToAnchor(safe.bottomAnchor, constant = -64.0), + container.leadingAnchor.constraintGreaterThanOrEqualToAnchor(window.leadingAnchor, constant = 20.0), + container.trailingAnchor.constraintLessThanOrEqualToAnchor(window.trailingAnchor, constant = -20.0), + + label.topAnchor.constraintEqualToAnchor(container.topAnchor, constant = 10.0), + label.bottomAnchor.constraintEqualToAnchor(container.bottomAnchor, constant = -10.0), + label.leadingAnchor.constraintEqualToAnchor(container.leadingAnchor, constant = 14.0), + label.trailingAnchor.constraintEqualToAnchor(container.trailingAnchor, constant = -14.0) + )) + + // Also cap max width to ~90% of window + container.widthAnchor.constraintLessThanOrEqualToAnchor( + window.widthAnchor, multiplier = 0.9 + ).active = true + + // Fade in + UIView.animateWithDuration( + duration = 0.2, + animations = { container.alpha = 1.0 }, + completion = null + ) + + // Schedule fade out + removal + val delay = seconds(duration) + dispatch_after( + // dispatch_time takes nanoseconds + dispatch_time(0u, (delay * 1_000_000_000L).toLong()), + dispatch_get_main_queue() + ) { + UIView.animateWithDuration( + duration = 0.25, + animations = { container.alpha = 0.0 }, + completion = { _ -> + container.removeFromSuperview() + } + ) + } + } + }}