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
31 changes: 31 additions & 0 deletions apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<HabitFormFields
formHelpers={formHelpers}
tags={tags}
selectedGoalIds={[]}
atGoalLimit={false}
onToggleGoal={vi.fn()}
reminderTimes={[]}
onReminderTimesChange={vi.fn()}
/>,
)
})

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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
Expand Down Expand Up @@ -76,6 +76,7 @@ export function FrequencyTypeCards({
}: Readonly<FrequencyTypeCardsProps>) {
const { t } = useTranslation();
const scrollRef = useRef<ScrollView>(null);
const hasPositionedRef = useRef(false);
const [pageWidth, setPageWidth] = useState(0);

const activeIndex = isOneTime ? 0 : isGeneral ? 3 : isFlexible ? 2 : 1;
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<View
Expand All @@ -153,9 +163,10 @@ export function FrequencyTypeCards({
>
<Pressable
style={styles.frequencyCardCarousel}
onPress={frequencyHandlers[activeIndex]}
onPress={frequencyHandlers[index]}
accessibilityRole="button"
accessibilityState={{ selected: true }}
accessibilityLabel={t(card.titleKey)}
accessibilityState={{ selected: index === activeIndex }}
>
<View style={styles.frequencyCardIconWell}>
<CardIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class OrbitWidgetProvider : AppWidgetProvider() {
val streak = prefs.getInt("user_streak", 0)
val isSignedOut = OrbitWidgetModule.getToken(context) == null
val lang = prefs.getString("lang", "en") ?: "en"
val syncedOnce = prefs.getLong("habits_updated_at", 0L) > 0L

// Apply dynamic text colors
views.setTextColor(R.id.widget_header, colors.textPrimary)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -296,37 +325,40 @@ 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
)
}
}

override fun onDataSetChanged() {
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)
}
}
}
}

private fun loadWidgetData() {
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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#14F8FAFC" />
<corners android:radius="16dp" />
</shape>
Loading
Loading