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])