diff --git a/composeApp/src/main/AndroidManifest.xml b/composeApp/src/main/AndroidManifest.xml
index a2d79cb..d0bd92a 100644
--- a/composeApp/src/main/AndroidManifest.xml
+++ b/composeApp/src/main/AndroidManifest.xml
@@ -32,6 +32,11 @@
+
+
+
diff --git a/composeApp/src/main/kotlin/com/github/worn/App.kt b/composeApp/src/main/kotlin/com/github/worn/App.kt
index c297606..8e742a1 100644
--- a/composeApp/src/main/kotlin/com/github/worn/App.kt
+++ b/composeApp/src/main/kotlin/com/github/worn/App.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.window.core.layout.WindowWidthSizeClass
+import com.github.worn.domain.model.AppShortcut
import com.github.worn.ui.components.Tab
import com.github.worn.ui.components.WornBottomBar
import com.github.worn.ui.screen.GapsScreen
@@ -26,6 +27,7 @@ import com.github.worn.ui.screen.WardrobeScreen
import com.github.worn.ui.theme.WornColors
import com.github.worn.ui.theme.WornTheme
import com.github.worn.ui.util.SharedPhoto
+import com.github.worn.ui.util.ShortcutCommand
import kotlinx.coroutines.launch
private val tabs = Tab.entries.toList()
@@ -33,7 +35,12 @@ private val tabs = Tab.entries.toList()
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("FunctionNaming")
@Composable
-fun App(sharedPhoto: SharedPhoto? = null, onSharedPhotoConsumed: () -> Unit = {}) {
+fun App(
+ sharedPhoto: SharedPhoto? = null,
+ onSharedPhotoConsumed: () -> Unit = {},
+ shortcut: ShortcutCommand? = null,
+ onShortcutConsumed: () -> Unit = {},
+) {
WornTheme {
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
@@ -52,6 +59,19 @@ fun App(sharedPhoto: SharedPhoto? = null, onSharedPhotoConsumed: () -> Unit = {}
if (sharedPhoto != null) onTabSelected(Tab.TRY_IT)
}
+ LaunchedEffect(shortcut) {
+ when (shortcut?.shortcut) {
+ // The tab switch is the whole job, so this one is done here.
+ AppShortcut.TRY_IT -> {
+ onTabSelected(Tab.TRY_IT)
+ onShortcutConsumed()
+ }
+ // WardrobeScreen consumes this once the sheet is actually open.
+ AppShortcut.ADD_ITEM -> onTabSelected(Tab.WARDROBE)
+ null -> Unit
+ }
+ }
+
Box(
modifier = Modifier
.fillMaxSize()
@@ -64,7 +84,11 @@ fun App(sharedPhoto: SharedPhoto? = null, onSharedPhotoConsumed: () -> Unit = {}
modifier = Modifier.fillMaxSize(),
) { page ->
when (tabs[page]) {
- Tab.WARDROBE -> WardrobeScreen(onTabSelected = onTabSelected)
+ Tab.WARDROBE -> WardrobeScreen(
+ onTabSelected = onTabSelected,
+ openAddSheet = shortcut?.takeIf { it.shortcut == AppShortcut.ADD_ITEM },
+ onAddSheetOpened = onShortcutConsumed,
+ )
Tab.OUTFITS -> OutfitsScreen(onTabSelected = onTabSelected)
Tab.GAPS -> GapsScreen(onTabSelected = onTabSelected)
Tab.TRY_IT -> TryItScreen(
diff --git a/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt b/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt
index 3fdf76b..9079f58 100644
--- a/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt
+++ b/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt
@@ -15,32 +15,46 @@ import androidx.core.content.IntentCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.github.worn.ui.util.SharedPhoto
+import com.github.worn.ui.util.ShortcutCommand
import com.github.worn.ui.util.readImageBytes
+import com.github.worn.ui.util.shortcutCommandFor
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private var sharedPhoto by mutableStateOf(null)
+ private var shortcut by mutableStateOf(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- handleShareIntent(intent)
+ handleIntent(intent)
setContent {
App(
sharedPhoto = sharedPhoto,
onSharedPhotoConsumed = { sharedPhoto = null },
+ shortcut = shortcut,
+ onShortcutConsumed = { shortcut = null },
)
}
}
- /** singleTask launch mode routes a share into the running instance rather than a new one. */
+ /**
+ * singleTask launch mode routes a share or a launcher shortcut into the running instance rather
+ * than a new one.
+ */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
+ handleIntent(intent)
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ if (intent == null) return
+ shortcutCommandFor(intent.action)?.let { shortcut = it }
handleShareIntent(intent)
}
diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt
index e0bb176..3e71449 100644
--- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt
+++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt
@@ -74,6 +74,7 @@ import com.github.worn.ui.components.Tab
import com.github.worn.ui.theme.WornColors
import com.github.worn.ui.theme.WornDimens
import com.github.worn.ui.theme.WornTheme
+import com.github.worn.ui.util.ShortcutCommand
import org.koin.compose.viewmodel.koinViewModel
private val GRID_MIN_CELL_WIDTH = 160.dp
@@ -82,6 +83,8 @@ private val GRID_GAP_EXPANDED = 16.dp
@Composable
fun WardrobeScreen(
onTabSelected: (Tab) -> Unit = {},
+ openAddSheet: ShortcutCommand? = null,
+ onAddSheetOpened: () -> Unit = {},
viewModel: WardrobeViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -89,6 +92,18 @@ fun WardrobeScreen(
var detailItem by remember { mutableStateOf(null) }
var editItem by remember { mutableStateOf(null) }
+ /**
+ * [onAddSheetOpened] clears the shortcut so it is handled exactly once: the pager disposes this
+ * screen when it is more than a page away, and without this the sheet would reopen on every
+ * return to the tab.
+ */
+ LaunchedEffect(openAddSheet) {
+ if (openAddSheet == null) return@LaunchedEffect
+ editItem = null
+ showAddSheet = true
+ onAddSheetOpened()
+ }
+
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/util/ShortcutCommand.kt b/composeApp/src/main/kotlin/com/github/worn/ui/util/ShortcutCommand.kt
new file mode 100644
index 0000000..d55f926
--- /dev/null
+++ b/composeApp/src/main/kotlin/com/github/worn/ui/util/ShortcutCommand.kt
@@ -0,0 +1,19 @@
+package com.github.worn.ui.util
+
+import com.github.worn.domain.model.AppShortcut
+
+private const val ACTION_PREFIX = "com.github.worn.action."
+
+/**
+ * One launcher shortcut tap, waiting to be routed.
+ *
+ * Deliberately not a data class: identity equality makes every tap a distinct value, so
+ * `LaunchedEffect` re-fires even when the same shortcut is tapped twice in a row.
+ */
+class ShortcutCommand(val shortcut: AppShortcut)
+
+/** The intent action declared for this shortcut in `res/xml/shortcuts.xml`. */
+fun AppShortcut.action(): String = ACTION_PREFIX + id
+
+fun shortcutCommandFor(action: String?): ShortcutCommand? =
+ AppShortcut.entries.firstOrNull { it.action() == action }?.let(::ShortcutCommand)
diff --git a/composeApp/src/main/res/drawable/ic_shortcut_add_item.xml b/composeApp/src/main/res/drawable/ic_shortcut_add_item.xml
new file mode 100644
index 0000000..09413f8
--- /dev/null
+++ b/composeApp/src/main/res/drawable/ic_shortcut_add_item.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/composeApp/src/main/res/drawable/ic_shortcut_add_item_foreground.xml b/composeApp/src/main/res/drawable/ic_shortcut_add_item_foreground.xml
new file mode 100644
index 0000000..c70eacd
--- /dev/null
+++ b/composeApp/src/main/res/drawable/ic_shortcut_add_item_foreground.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
diff --git a/composeApp/src/main/res/drawable/ic_shortcut_try_it.xml b/composeApp/src/main/res/drawable/ic_shortcut_try_it.xml
new file mode 100644
index 0000000..ffd20f0
--- /dev/null
+++ b/composeApp/src/main/res/drawable/ic_shortcut_try_it.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/composeApp/src/main/res/drawable/ic_shortcut_try_it_foreground.xml b/composeApp/src/main/res/drawable/ic_shortcut_try_it_foreground.xml
new file mode 100644
index 0000000..94f4731
--- /dev/null
+++ b/composeApp/src/main/res/drawable/ic_shortcut_try_it_foreground.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
diff --git a/composeApp/src/main/res/values-pt-rBR/strings.xml b/composeApp/src/main/res/values-pt-rBR/strings.xml
index 217fe12..83d8e8e 100644
--- a/composeApp/src/main/res/values-pt-rBR/strings.xml
+++ b/composeApp/src/main/res/values-pt-rBR/strings.xml
@@ -165,6 +165,12 @@
Ver em mim
Não foi possível abrir essa foto
+
+ Adicionar
+ Adicionar ao armário
+ Testar
+ Experimentar uma peça
+
Desbloquear funções de IA
Adicione sua chave da API Claude nos Ajustes para habilitar.
diff --git a/composeApp/src/main/res/values/strings.xml b/composeApp/src/main/res/values/strings.xml
index 1866fdb..7915685 100644
--- a/composeApp/src/main/res/values/strings.xml
+++ b/composeApp/src/main/res/values/strings.xml
@@ -165,6 +165,12 @@
See it on me
Couldn\'t open that photo
+
+ Add item
+ Add to wardrobe
+ Try it
+ Try on an item
+
Unlock AI features
Add your Claude API key in Settings to enable this.
diff --git a/composeApp/src/main/res/xml/shortcuts.xml b/composeApp/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..04dd21e
--- /dev/null
+++ b/composeApp/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/iosApp/Screens/WardrobeScreen.swift b/iosApp/iosApp/Screens/WardrobeScreen.swift
index 2790caa..df34c9f 100644
--- a/iosApp/iosApp/Screens/WardrobeScreen.swift
+++ b/iosApp/iosApp/Screens/WardrobeScreen.swift
@@ -8,6 +8,8 @@ struct WardrobeScreen: View {
@State private var detailItem: ClothingItem?
@State private var editItem: ClothingItem?
var onTabSelected: (WornTab) -> Void = { _ in }
+ var openAddSheet: ShortcutCommand?
+ var onAddSheetOpened: () -> Void = {}
var body: some View {
WardrobeContent(
@@ -62,6 +64,14 @@ struct WardrobeScreen: View {
}
)
}
+ // task(id:), not onChange: this also runs on first appearance, so a quick action that
+ // cold-launched the app is not missed. onAddSheetOpened clears it so it fires exactly once.
+ .task(id: openAddSheet) {
+ guard openAddSheet != nil else { return }
+ editItem = nil
+ showAddSheet = true
+ onAddSheetOpened()
+ }
}
}
diff --git a/iosApp/iosApp/Services/QuickActions.swift b/iosApp/iosApp/Services/QuickActions.swift
new file mode 100644
index 0000000..48fff55
--- /dev/null
+++ b/iosApp/iosApp/Services/QuickActions.swift
@@ -0,0 +1,87 @@
+import Shared
+import SwiftUI
+
+/// One Home Screen quick-action tap, waiting to be routed.
+///
+/// The `id` makes every tap distinct, so tapping the same action twice in a row still reads as a
+/// change — the same reason `SharedPhoto` carries one.
+struct ShortcutCommand: Equatable {
+ let id = UUID()
+ let shortcut: AppShortcut
+}
+
+/// Receives quick-action taps from `SceneDelegate` and hands them to SwiftUI.
+///
+/// SwiftUI has no hook for `performActionFor`, so the delegates below are the only way in.
+@MainActor
+final class QuickActionInbox: ObservableObject {
+ static let shared = QuickActionInbox()
+
+ @Published var pending: ShortcutCommand?
+
+ private init() {}
+
+ func receive(_ item: UIApplicationShortcutItem) {
+ guard let shortcut = AppShortcut.companion.fromId(id: item.type) else { return }
+ pending = ShortcutCommand(shortcut: shortcut)
+ }
+
+ /// Registered at launch rather than declared in `Info.plist` so the titles come from
+ /// `Localizable.strings` like every other user-facing string, and the icons are SF Symbols like
+ /// the rest of the app. The trade-off: the actions appear after the first launch, not at install.
+ static func register() {
+ UIApplication.shared.shortcutItems = [
+ UIApplicationShortcutItem(
+ type: AppShortcut.addItem.id,
+ localizedTitle: String(localized: "shortcut_add_item_short"),
+ localizedSubtitle: String(localized: "shortcut_add_item_long"),
+ icon: UIApplicationShortcutIcon(systemImageName: "plus")
+ ),
+ UIApplicationShortcutItem(
+ type: AppShortcut.tryIt.id,
+ localizedTitle: String(localized: "shortcut_try_it_short"),
+ localizedSubtitle: String(localized: "shortcut_try_it_long"),
+ icon: UIApplicationShortcutIcon(systemImageName: "viewfinder")
+ ),
+ ]
+ }
+}
+
+/// Exists only to attach `SceneDelegate`; SwiftUI still owns the window.
+final class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ configurationForConnecting connectingSceneSession: UISceneSession,
+ options: UIScene.ConnectionOptions
+ ) -> UISceneConfiguration {
+ let configuration = UISceneConfiguration(
+ name: connectingSceneSession.configuration.name,
+ sessionRole: connectingSceneSession.role
+ )
+ configuration.delegateClass = SceneDelegate.self
+ return configuration
+ }
+}
+
+final class SceneDelegate: NSObject, UIWindowSceneDelegate {
+
+ /// Cold launch: the tap arrives here, not in `performActionFor`.
+ func scene(
+ _ scene: UIScene,
+ willConnectTo session: UISceneSession,
+ options connectionOptions: UIScene.ConnectionOptions
+ ) {
+ guard let item = connectionOptions.shortcutItem else { return }
+ QuickActionInbox.shared.receive(item)
+ }
+
+ /// The app was already running.
+ func windowScene(
+ _ windowScene: UIWindowScene,
+ performActionFor shortcutItem: UIApplicationShortcutItem,
+ completionHandler: @escaping (Bool) -> Void
+ ) {
+ QuickActionInbox.shared.receive(shortcutItem)
+ completionHandler(true)
+ }
+}
diff --git a/iosApp/iosApp/en.lproj/Localizable.strings b/iosApp/iosApp/en.lproj/Localizable.strings
index c4e78c5..dd79397 100644
--- a/iosApp/iosApp/en.lproj/Localizable.strings
+++ b/iosApp/iosApp/en.lproj/Localizable.strings
@@ -162,6 +162,12 @@
"share_choose_try_on" = "See it on me";
"share_photo_read_failed" = "Couldn't open that photo";
+/* App Shortcuts */
+"shortcut_add_item_short" = "Add item";
+"shortcut_add_item_long" = "Add to wardrobe";
+"shortcut_try_it_short" = "Try it";
+"shortcut_try_it_long" = "Try on an item";
+
/* AI Locked Sheet */
"ai_locked_title" = "Unlock AI features";
"ai_locked_description" = "Add your Claude API key in Settings to enable this.";
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index f104a04..3b130db 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -3,19 +3,28 @@ import Shared
@main
struct iOSApp: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
+ @StateObject private var quickActions = QuickActionInbox.shared
@State private var activeTab: WornTab = .wardrobe
@State private var sharedPhoto: SharedPhoto?
+ @State private var openAddSheet: ShortcutCommand?
+ @State private var handledShortcutId: UUID?
init() {
KoinHelperKt.initKoin()
+ QuickActionInbox.register()
}
var body: some Scene {
WindowGroup {
VStack(spacing: 0) {
TabView(selection: $activeTab) {
- WardrobeScreen(onTabSelected: selectTab)
+ WardrobeScreen(
+ onTabSelected: selectTab,
+ openAddSheet: openAddSheet,
+ onAddSheetOpened: { openAddSheet = nil }
+ )
.tag(WornTab.wardrobe)
OutfitsScreen(onTabSelected: selectTab)
.tag(WornTab.outfits)
@@ -35,6 +44,28 @@ struct iOSApp: App {
// Covers the case where the extension wrote the file but could not launch us.
if phase == .active { receiveSharedPhoto() }
}
+ // onReceive, not onChange: on a cold launch the scene delegate fills the inbox before
+ // this view installs its observers, and @Published replays its current value to a new
+ // subscriber whereas onChange would only see later ones.
+ //
+ // The tap is marked handled here rather than cleared on the inbox, because that replay
+ // can land mid-update and SwiftUI forbids publishing changes from there. Every tap
+ // carries a fresh id, so a repeat of the same action still gets through.
+ .onReceive(quickActions.$pending) { command in
+ guard let command, command.id != handledShortcutId else { return }
+ handledShortcutId = command.id
+ perform(command)
+ }
+ }
+ }
+
+ private func perform(_ command: ShortcutCommand) {
+ // A Kotlin enum bridges to Swift as a class, so it is compared rather than switched on.
+ if command.shortcut == .addItem {
+ selectTab(.wardrobe)
+ openAddSheet = command
+ } else if command.shortcut == .tryIt {
+ selectTab(.tryIt)
}
}
diff --git a/iosApp/iosApp/pt-BR.lproj/Localizable.strings b/iosApp/iosApp/pt-BR.lproj/Localizable.strings
index f5891ac..ee52155 100644
--- a/iosApp/iosApp/pt-BR.lproj/Localizable.strings
+++ b/iosApp/iosApp/pt-BR.lproj/Localizable.strings
@@ -162,6 +162,12 @@
"share_choose_try_on" = "Ver em mim";
"share_photo_read_failed" = "Não foi possível abrir essa foto";
+/* App Shortcuts */
+"shortcut_add_item_short" = "Adicionar";
+"shortcut_add_item_long" = "Adicionar ao armário";
+"shortcut_try_it_short" = "Testar";
+"shortcut_try_it_long" = "Experimentar uma peça";
+
/* AI Locked Sheet */
"ai_locked_title" = "Desbloquear funções de IA";
"ai_locked_description" = "Adicione sua chave da API Claude nos Ajustes para habilitar.";
diff --git a/shared/src/commonMain/kotlin/com/github/worn/domain/model/AppShortcut.kt b/shared/src/commonMain/kotlin/com/github/worn/domain/model/AppShortcut.kt
new file mode 100644
index 0000000..1180ac0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/github/worn/domain/model/AppShortcut.kt
@@ -0,0 +1,22 @@
+package com.github.worn.domain.model
+
+/**
+ * Actions the OS can launch straight from the app icon: Android App Shortcuts and iOS Home Screen
+ * Quick Actions.
+ *
+ * [id] is the iOS `UIApplicationShortcutItem.type` and the suffix of the Android intent action, so
+ * the two platforms cannot drift apart.
+ */
+enum class AppShortcut(val id: String) {
+ /** Opens the Wardrobe tab with the Add Item sheet already up. */
+ ADD_ITEM("add_item"),
+
+ /** Opens the Try It tab in its idle state. */
+ TRY_IT("try_it"),
+
+ ;
+
+ companion object {
+ fun fromId(id: String?): AppShortcut? = entries.firstOrNull { it.id == id }
+ }
+}
diff --git a/shared/src/commonTest/kotlin/com/github/worn/model/AppShortcutTest.kt b/shared/src/commonTest/kotlin/com/github/worn/model/AppShortcutTest.kt
new file mode 100644
index 0000000..cec0648
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/github/worn/model/AppShortcutTest.kt
@@ -0,0 +1,37 @@
+package com.github.worn.model
+
+import com.github.worn.domain.model.AppShortcut
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class AppShortcutTest {
+
+ @Test
+ fun fromId_resolves_the_add_item_shortcut() {
+ assertEquals(AppShortcut.ADD_ITEM, AppShortcut.fromId("add_item"))
+ }
+
+ @Test
+ fun fromId_resolves_the_try_it_shortcut() {
+ assertEquals(AppShortcut.TRY_IT, AppShortcut.fromId("try_it"))
+ }
+
+ @Test
+ fun fromId_returns_null_for_an_unknown_id() {
+ assertNull(AppShortcut.fromId("outfits"))
+ }
+
+ @Test
+ fun fromId_returns_null_for_a_missing_id() {
+ assertNull(AppShortcut.fromId(null))
+ }
+
+ /** The ids cross a platform boundary as bare strings, so a rename must not go unnoticed. */
+ @Test
+ fun every_shortcut_round_trips_through_its_id() {
+ AppShortcut.entries.forEach { shortcut ->
+ assertEquals(shortcut, AppShortcut.fromId(shortcut.id))
+ }
+ }
+}