diff --git a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx index c24993c05..3e88c48ff 100644 --- a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx @@ -508,6 +508,37 @@ describe('HabitFormFields (mobile)', () => { expect(setOneTime).toHaveBeenCalled() }) + it('selects the tapped frequency card instead of re-applying the active one', async () => { + const setOneTime = vi.fn() + const formHelpers = createMockFormHelpers(undefined, { setOneTime }) + const tags = createMockTags() + let tree: any + + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + }) + + const oneTimeCard = tree.root.findByProps({ + accessibilityLabel: 'habits.form.oneTimeTask', + }) + + await TestRenderer.act(async () => { + oneTimeCard.props.onPress() + }) + + expect(setOneTime).toHaveBeenCalled() + }) + it('commits the title to the form as it is typed so the shared schema gates submit', async () => { const formHelpers = createMockFormHelpers({ title: '' }) const tags = createMockTags() diff --git a/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx b/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx index c2f43d9b6..1918be146 100644 --- a/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx +++ b/apps/mobile/components/habits/habit-form-fields/frequency-type-cards.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, @@ -76,6 +76,7 @@ export function FrequencyTypeCards({ }: Readonly) { const { t } = useTranslation(); const scrollRef = useRef(null); + const hasPositionedRef = useRef(false); const [pageWidth, setPageWidth] = useState(0); const activeIndex = isOneTime ? 0 : isGeneral ? 3 : isFlexible ? 2 : 1; @@ -85,12 +86,20 @@ export function FrequencyTypeCards({ [onSetOneTime, onSetRecurring, onSetFlexible, onSetGeneral], ); + const scrollToActive = useCallback( + (animated: boolean) => { + if (pageWidth === 0) { + return; + } + scrollRef.current?.scrollTo({ x: activeIndex * pageWidth, animated }); + }, + [activeIndex, pageWidth], + ); + useEffect(() => { - if (pageWidth === 0) { - return; - } - scrollRef.current?.scrollTo({ x: activeIndex * pageWidth, animated: true }); - }, [activeIndex, pageWidth]); + scrollToActive(hasPositionedRef.current); + hasPositionedRef.current = true; + }, [scrollToActive]); function handleLayout(event: LayoutChangeEvent) { setPageWidth(event.nativeEvent.layout.width); @@ -139,12 +148,13 @@ export function FrequencyTypeCards({ ref={scrollRef} style={styles.frequencyScroll} onLayout={handleLayout} + onContentSizeChange={() => scrollToActive(false)} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onMomentumScrollEnd={handleMomentumScrollEnd} > - {FREQUENCY_TYPE_CARDS.map((card) => { + {FREQUENCY_TYPE_CARDS.map((card, index) => { const CardIcon = card.icon; return ( 0L // Apply dynamic text colors views.setTextColor(R.id.widget_header, colors.textPrimary) @@ -95,7 +96,11 @@ class OrbitWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_empty_text, OrbitWidgetFactory.tr(lang, "signIn")) } else { views.setTextViewText(R.id.widget_header, headerLabel) - val subtitleText = "$completedCount ${OrbitWidgetFactory.tr(lang, "of")} $habitCount ${OrbitWidgetFactory.tr(lang, "completed")}" + val subtitleText = if (syncedOnce) { + "$completedCount ${OrbitWidgetFactory.tr(lang, "of")} $habitCount ${OrbitWidgetFactory.tr(lang, "completed")}" + } else { + "" + } views.setTextViewText(R.id.widget_subtitle, subtitleText) val streakVisible = if (streak > 0) View.VISIBLE else View.GONE views.setImageViewBitmap(R.id.widget_flame, flameBitmap) @@ -105,6 +110,12 @@ class OrbitWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_empty_text, OrbitWidgetFactory.tr(lang, "allClear")) } + // Show the loading skeleton until habits have synced at least once, so a + // freshly added widget never paints as a blank card. The factory hides it + // once its own load resolves (covers the case where no app push re-renders). + val showSkeleton = !isSignedOut && !syncedOnce + views.setViewVisibility(R.id.widget_loading, if (showSkeleton) View.VISIBLE else View.GONE) + // Set up the RemoteViews adapter for the list val serviceIntent = Intent(context, OrbitWidgetService::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) @@ -137,6 +148,7 @@ class OrbitWidgetProvider : AppWidgetProvider() { views.setOnClickPendingIntent(R.id.widget_header_container, openAppPendingIntent) views.setOnClickPendingIntent(R.id.widget_header, openAppPendingIntent) views.setOnClickPendingIntent(R.id.widget_empty, openAppPendingIntent) + views.setOnClickPendingIntent(R.id.widget_loading, openAppPendingIntent) appWidgetManager.updateAppWidget(appWidgetId, views) } diff --git a/apps/mobile/modules/orbit-widget/android/src/main/java/org/useorbit/app/widget/OrbitWidgetService.kt b/apps/mobile/modules/orbit-widget/android/src/main/java/org/useorbit/app/widget/OrbitWidgetService.kt index 28c30422a..5deb7bddb 100644 --- a/apps/mobile/modules/orbit-widget/android/src/main/java/org/useorbit/app/widget/OrbitWidgetService.kt +++ b/apps/mobile/modules/orbit-widget/android/src/main/java/org/useorbit/app/widget/OrbitWidgetService.kt @@ -91,6 +91,13 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo private const val TAG = "OrbitWidget" private const val FRESH_WINDOW_MS = 15_000L + // Cap rasterized background bitmaps. Android rejects a widget update whose + // summed RemoteViews bitmap memory (card background + every inlined list-item + // background) exceeds ~6x the screen; full-resolution rasters blew past it + // ("exceeds maximum bitmap memory usage"). Consumer ImageViews are fitXY, so a + // capped bitmap upscales to fill with the radius/stroke scaled to match. + private const val MAX_BITMAP_DIMENSION = 512 + /** Terminal color fallback (navy-dark surface) when a value is blank or malformed. */ private const val SAFE_FALLBACK = 0xFF020618.toInt() @@ -229,14 +236,19 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo width: Int, height: Int, color: Int, cornerRadius: Float, strokeWidth: Float = 0f, strokeColor: Int = 0 ): Bitmap { - val safeWidth = width.coerceAtLeast(1) - val safeHeight = height.coerceAtLeast(1) + val rawWidth = width.coerceAtLeast(1) + val rawHeight = height.coerceAtLeast(1) + val scale = minOf(1f, MAX_BITMAP_DIMENSION.toFloat() / maxOf(rawWidth, rawHeight)) + val safeWidth = (rawWidth * scale).toInt().coerceAtLeast(1) + val safeHeight = (rawHeight * scale).toInt().coerceAtLeast(1) + val scaledRadius = cornerRadius * scale + val scaledStroke = if (strokeWidth > 0f) (strokeWidth * scale).coerceAtLeast(1f) else 0f val bitmap = Bitmap.createBitmap(safeWidth, safeHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val drawable = GradientDrawable().apply { setColor(color) - setCornerRadius(cornerRadius) - if (strokeWidth > 0f) setStroke(strokeWidth.toInt(), strokeColor) + setCornerRadius(scaledRadius) + if (scaledStroke > 0f) setStroke(scaledStroke.toInt().coerceAtLeast(1), strokeColor) } drawable.setBounds(0, 0, safeWidth, safeHeight) drawable.draw(canvas) @@ -278,11 +290,28 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo override fun onCreate() {} /** - * Degrades the widget to the empty/sign-in state without throwing: clears the - * habit list, resets cached counts, and restores the refresh button. Used both - * for the signed-out path and as the catch-all when data loading fails. + * Applies a partial RemoteViews update to every mounted widget. Partial (not full + * updateWidgetLayout) on purpose: a full update recreates the RemoteAdapter and + * resets the factory mid-load. */ - private fun renderEmptyState() { + private fun updateWidgets(mutate: (RemoteViews) -> Unit) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, OrbitWidgetProvider::class.java) + ) + for (id in widgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_layout) + mutate(views) + appWidgetManager.partiallyUpdateAppWidget(id, views) + } + } + + /** + * Clears the habit list and restores the idle header. With showSkeleton=true the + * loading skeleton stays up (signed in, no data yet) so the widget never paints + * blank; with false it yields to the empty/sign-in view (signed out). + */ + private fun renderPlaceholder(showSkeleton: Boolean) { habits = emptyList() lang = detectLanguage(null) headerLabel = tr(lang, "today") @@ -296,15 +325,13 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo .putString("lang", lang) .apply() - val appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( - ComponentName(context, OrbitWidgetProvider::class.java) - ) - for (id in widgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_layout) + updateWidgets { views -> views.setViewVisibility(R.id.widget_refresh, android.view.View.VISIBLE) views.setViewVisibility(R.id.widget_refresh_loading, android.view.View.GONE) - appWidgetManager.partiallyUpdateAppWidget(id, views) + views.setViewVisibility( + R.id.widget_loading, + if (showSkeleton) android.view.View.VISIBLE else android.view.View.GONE + ) } } @@ -312,7 +339,12 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo try { loadWidgetData() } catch (_: Exception) { - runCatching { renderEmptyState() } + runCatching { + updateWidgets { views -> + views.setViewVisibility(R.id.widget_refresh, android.view.View.VISIBLE) + views.setViewVisibility(R.id.widget_refresh_loading, android.view.View.GONE) + } + } } } @@ -320,13 +352,13 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo colors = getThemeColors(context) val token = OrbitWidgetModule.getToken(context) if (token == null) { - renderEmptyState() + renderPlaceholder(showSkeleton = false) return } val widgetData = resolveWidgetData(token) if (widgetData == null) { - renderEmptyState() + renderPlaceholder(showSkeleton = true) return } @@ -355,18 +387,11 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo .putString("lang", lang) .apply() - // Partial update for header text only (do NOT call updateWidgetLayout here - // as it recreates the RemoteAdapter, causing Android to reset the factory) - val appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( - ComponentName(context, OrbitWidgetProvider::class.java) - ) val colors = getThemeColors(context) val density = context.resources.displayMetrics.density val streakVisible = if (streak > 0) android.view.View.VISIBLE else android.view.View.GONE val flameBitmap = createFlameBitmap(density, colors.streak) - for (id in widgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_layout) + updateWidgets { views -> views.setTextViewText(R.id.widget_header, headerLabel) views.setTextColor(R.id.widget_header, colors.textPrimary) views.setTextViewText(R.id.widget_subtitle, subtitleText) @@ -376,10 +401,10 @@ class OrbitWidgetFactory(private val context: Context) : RemoteViewsService.Remo views.setImageViewBitmap(R.id.widget_flame, flameBitmap) views.setViewVisibility(R.id.widget_flame, streakVisible) views.setViewVisibility(R.id.widget_streak, streakVisible) - // Restore refresh button, hide loading spinner + // Restore refresh button, hide loading spinner and skeleton views.setViewVisibility(R.id.widget_refresh, android.view.View.VISIBLE) views.setViewVisibility(R.id.widget_refresh_loading, android.view.View.GONE) - appWidgetManager.partiallyUpdateAppWidget(id, views) + views.setViewVisibility(R.id.widget_loading, android.view.View.GONE) } } diff --git a/apps/mobile/modules/orbit-widget/android/src/main/res/drawable/widget_skeleton_bar.xml b/apps/mobile/modules/orbit-widget/android/src/main/res/drawable/widget_skeleton_bar.xml new file mode 100644 index 000000000..933a8abf8 --- /dev/null +++ b/apps/mobile/modules/orbit-widget/android/src/main/res/drawable/widget_skeleton_bar.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apps/mobile/modules/orbit-widget/android/src/main/res/layout/widget_layout.xml b/apps/mobile/modules/orbit-widget/android/src/main/res/layout/widget_layout.xml index 173d351b8..5cf027a22 100644 --- a/apps/mobile/modules/orbit-widget/android/src/main/res/layout/widget_layout.xml +++ b/apps/mobile/modules/orbit-widget/android/src/main/res/layout/widget_layout.xml @@ -131,37 +131,80 @@ android:layout_width="match_parent" android:layout_height="10dp" /> - - - - - + + android:layout_height="match_parent"> + + + + + + - - + + + + + + + + + + + + + + diff --git a/apps/web/components/habits/habit-form-fields.tsx b/apps/web/components/habits/habit-form-fields.tsx index b3b691402..02a1c4771 100644 --- a/apps/web/components/habits/habit-form-fields.tsx +++ b/apps/web/components/habits/habit-form-fields.tsx @@ -968,14 +968,33 @@ export function HabitFormFields({ }), [setOneTime, setRecurring, setFlexible, setGeneral]) const frequencyTrackRef = useRef(null) + const hasPositionedFrequencyRef = useRef(false) const activeFrequencyIndex = FREQUENCY_TYPE_CARDS.findIndex((card) => card.key === activeFrequencyKey) useEffect(() => { const track = frequencyTrackRef.current if (!track) return - const target = activeFrequencyIndex * track.clientWidth - if (Math.abs(track.scrollLeft - target) > 1) { - track.scrollTo({ left: target, behavior: 'smooth' }) + + let frame = 0 + const positionToActive = () => { + const width = track.clientWidth + if (width === 0) { + frame = requestAnimationFrame(positionToActive) + return + } + const target = activeFrequencyIndex * width + if (Math.abs(track.scrollLeft - target) > 1) { + track.scrollTo({ + left: target, + behavior: hasPositionedFrequencyRef.current ? 'smooth' : 'auto', + }) + } + hasPositionedFrequencyRef.current = true + } + + positionToActive() + return () => { + if (frame) cancelAnimationFrame(frame) } }, [activeFrequencyIndex])