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