Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions composeApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@

<data android:mimeType="image/*" />
</intent-filter>

<!-- Long-press shortcuts on the launcher icon: Add item and Try it. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
</application>

Expand Down
28 changes: 26 additions & 2 deletions composeApp/src/main/kotlin/com/github/worn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,14 +27,20 @@ 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()

@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()
Expand All @@ -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()
Expand All @@ -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(
Expand Down
18 changes: 16 additions & 2 deletions composeApp/src/main/kotlin/com/github/worn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SharedPhoto?>(null)
private var shortcut by mutableStateOf<ShortcutCommand?>(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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -82,13 +83,27 @@ 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()
var showAddSheet by remember { mutableStateOf(false) }
var detailItem by remember { mutableStateOf<ClothingItem?>(null) }
var editItem by remember { mutableStateOf<ClothingItem?>(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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions composeApp/src/main/res/drawable/ic_shortcut_add_item.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive, so the shortcut owns its background instead of being scaled onto the launcher's white
badge: a white glyph then stays legible in light and dark, and pinned shortcuts match the app
icon. The monochrome layer opts into themed icons on Android 13+.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_shortcut_add_item_foreground" />
<monochrome android:drawable="@drawable/ic_shortcut_add_item_foreground" />
</adaptive-icon>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!--
Adaptive icon foreground: 108dp canvas, glyph scaled to 42dp and centred so it stays inside the
66dp safe zone whatever mask the launcher applies.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.75"
android:scaleY="1.75"
android:translateX="33"
android:translateY="33">
<path
android:pathData="M5,12 L19,12 M12,5 L12,19"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="@android:color/transparent" />
</group>
</vector>
11 changes: 11 additions & 0 deletions composeApp/src/main/res/drawable/ic_shortcut_try_it.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive, so the shortcut owns its background instead of being scaled onto the launcher's white
badge: a white glyph then stays legible in light and dark, and pinned shortcuts match the app
icon. The monochrome layer opts into themed icons on Android 13+.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_shortcut_try_it_foreground" />
<monochrome android:drawable="@drawable/ic_shortcut_try_it_foreground" />
</adaptive-icon>
23 changes: 23 additions & 0 deletions composeApp/src/main/res/drawable/ic_shortcut_try_it_foreground.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!--
Adaptive icon foreground: 108dp canvas, glyph scaled to 42dp and centred so it stays inside the
66dp safe zone whatever mask the launcher applies.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.75"
android:scaleY="1.75"
android:translateX="33"
android:translateY="33">
<path
android:pathData="M3,7 L3,5 A2,2,0,0,1,5,3 L7,3 M17,3 L19,3 A2,2,0,0,1,21,5 L21,7 M21,17 L21,19 A2,2,0,0,1,19,21 L17,21 M7,21 L5,21 A2,2,0,0,1,3,19 L3,17"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="@android:color/transparent" />
</group>
</vector>
6 changes: 6 additions & 0 deletions composeApp/src/main/res/values-pt-rBR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@
<string name="share_choose_try_on">Ver em mim</string>
<string name="share_photo_read_failed">Não foi possível abrir essa foto</string>

<!-- App Shortcuts -->
<string name="shortcut_add_item_short">Adicionar</string>
<string name="shortcut_add_item_long">Adicionar ao armário</string>
<string name="shortcut_try_it_short">Testar</string>
<string name="shortcut_try_it_long">Experimentar uma peça</string>

<!-- AI Locked Sheet -->
<string name="ai_locked_title">Desbloquear funções de IA</string>
<string name="ai_locked_description">Adicione sua chave da API Claude nos Ajustes para habilitar.</string>
Expand Down
6 changes: 6 additions & 0 deletions composeApp/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@
<string name="share_choose_try_on">See it on me</string>
<string name="share_photo_read_failed">Couldn\'t open that photo</string>

<!-- App Shortcuts -->
<string name="shortcut_add_item_short">Add item</string>
<string name="shortcut_add_item_long">Add to wardrobe</string>
<string name="shortcut_try_it_short">Try it</string>
<string name="shortcut_try_it_long">Try on an item</string>

<!-- AI Locked Sheet -->
<string name="ai_locked_title">Unlock AI features</string>
<string name="ai_locked_description">Add your Claude API key in Settings to enable this.</string>
Expand Down
32 changes: 32 additions & 0 deletions composeApp/src/main/res/xml/shortcuts.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The intents are explicit (targetPackage + targetClass), so the custom actions are a payload for
MainActivity to read rather than something the system has to resolve: no intent-filter needed.

Launchers stack shortcuts bottom-up, so the last one declared sits nearest the app icon.
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="try_it"
android:enabled="true"
android:icon="@drawable/ic_shortcut_try_it"
android:shortcutShortLabel="@string/shortcut_try_it_short"
android:shortcutLongLabel="@string/shortcut_try_it_long">
<intent
android:action="com.github.worn.action.try_it"
android:targetPackage="com.github.worn"
android:targetClass="com.github.worn.MainActivity" />
</shortcut>

<shortcut
android:shortcutId="add_item"
android:enabled="true"
android:icon="@drawable/ic_shortcut_add_item"
android:shortcutShortLabel="@string/shortcut_add_item_short"
android:shortcutLongLabel="@string/shortcut_add_item_long">
<intent
android:action="com.github.worn.action.add_item"
android:targetPackage="com.github.worn"
android:targetClass="com.github.worn.MainActivity" />
</shortcut>
</shortcuts>
10 changes: 10 additions & 0 deletions iosApp/iosApp/Screens/WardrobeScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
}
}
}

Expand Down
Loading
Loading