Skip to content
13 changes: 13 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@
android:resource="@xml/tree_widget_info" />
</receiver>

<!-- ── Grove Check-In Widget (1x1) ──────────────────────────── -->
<receiver
android:name=".CheckInWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.resurrect.grove.ACTION_CHECKIN_TOGGLE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/checkin_widget_info" />
</receiver>

<meta-data
android:name="flutterEmbedding"
android:value="2" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package com.resurrect.grove

import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*

class CheckInWidgetProvider : AppWidgetProvider() {

companion object {
const val ACTION_TOGGLE_CHECKIN = "com.resurrect.grove.ACTION_CHECKIN_TOGGLE"
const val PREFS_NAME = "FlutterSharedPreferences"
const val NAV_PREFS = "grove_checkin_nav"
const val KEY_HABITS = "flutter.grove_v2_ids"
private const val LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu"
}

override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
ids.forEach { updateWidget(ctx, mgr, it) }
}

override fun onReceive(ctx: Context, intent: Intent) {
super.onReceive(ctx, intent)

val mgr = AppWidgetManager.getInstance(ctx)

when (intent.action) {
ACTION_TOGGLE_CHECKIN -> {
val widgetId = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
toggleCheckIn(ctx, widgetId)
}
}
}

val allIds = mgr.getAppWidgetIds(ComponentName(ctx, CheckInWidgetProvider::class.java))
allIds.forEach { updateWidget(ctx, mgr, it) }
}

private fun toggleCheckIn(ctx: Context, widgetId: Int) {
val flutterPrefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val navPrefs = ctx.getSharedPreferences(NAV_PREFS, Context.MODE_PRIVATE)

val habitIds = loadHabitIds(flutterPrefs)
if (habitIds.isEmpty()) return

val selId = navPrefs.getString("checkin_habit_id_$widgetId", habitIds.firstOrNull())
?: return

val habit = loadHabit(flutterPrefs, selId) ?: return

val mode = habit.optInt("mode", 0)
if (mode != 1) return

val todayKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())

val checkInDays = habit.optJSONArray("checkInDays") ?: JSONArray()
val existing = mutableListOf<String>()
for (i in 0 until checkInDays.length()) {
val iso = checkInDays.optString(i, "")
if (iso.length >= 10) {
existing.add(iso.substring(0, 10))
}
}

val isCheckedInToday = existing.contains(todayKey)

val newArray = JSONArray()
if (isCheckedInToday) {
for (day in existing) {
if (day != todayKey) {
newArray.put(day)
}
}
} else {
for (day in existing) {
newArray.put(day)
}
newArray.put(todayKey)
}

habit.put("checkInDays", newArray)
flutterPrefs.edit().putString("flutter.$selId", habit.toString()).apply()
}

private fun updateWidget(ctx: Context, mgr: AppWidgetManager, widgetId: Int) {
val flutterPrefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val navPrefs = ctx.getSharedPreferences(NAV_PREFS, Context.MODE_PRIVATE)

val habitIds = loadHabitIds(flutterPrefs)
val selId = navPrefs.getString("checkin_habit_id_$widgetId", habitIds.firstOrNull())
val habit = selId?.let { loadHabit(flutterPrefs, it) }

val views = RemoteViews(ctx.packageName, R.layout.widget_checkin)

val habitName = habit?.optString("name", "—") ?: "Tap to pick"
val isCheckIn = (habit?.optInt("mode", 0) ?: 0) == 1
val streak = if (isCheckIn) computeCheckInStreak(habit!!) else 0

val todayKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
val checkInDays = habit?.optJSONArray("checkInDays")
var checkedToday = false
if (checkInDays != null && isCheckIn) {
for (i in 0 until checkInDays.length()) {
val iso = checkInDays.optString(i, "")
if (iso.length >= 10 && iso.substring(0, 10) == todayKey) {
checkedToday = true
break
}
}
}

views.setTextViewText(R.id.checkin_habit_name, habitName)
views.setTextViewText(R.id.checkin_streak, "$streak")

if (isCheckIn) {
val color = habit?.optInt("color", 0xFF4E8B5F.toInt()) ?: 0xFF4E8B5F.toInt()
val alpha = if (checkedToday) color or (0xFF shl 24) else (0x44E0EBE0.toInt())

views.setTextViewText(R.id.checkin_icon, if (checkedToday) "✓" else "○")
views.setInt(R.id.checkin_icon, "setTextColor", alpha.toInt())
} else {
views.setTextViewText(R.id.checkin_icon, "—")
views.setInt(R.id.checkin_icon, "setTextColor", 0x448AA88C.toInt())
}

views.setOnClickPendingIntent(
R.id.checkin_icon,
makeBroadcast(ctx, ACTION_TOGGLE_CHECKIN, widgetId, widgetId * 10 + 1)
)

mgr.updateAppWidget(widgetId, views)
}

private fun computeCheckInStreak(habit: JSONObject): Int {
val arr = habit.optJSONArray("checkInDays") ?: return 0
if (arr.length() == 0) return 0

val days = mutableSetOf<String>()
for (i in 0 until arr.length()) {
val iso = arr.optString(i, "")
if (iso.length >= 10) days.add(iso.substring(0, 10))
}

val cal = Calendar.getInstance()
var streak = 0
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)

while (true) {
val key = sdf.format(cal.time)
if (days.contains(key)) {
streak++
cal.add(Calendar.DAY_OF_YEAR, -1)
} else {
break
}
}
return streak
}

private fun makeBroadcast(ctx: Context, action: String,
widgetId: Int, requestCode: Int): PendingIntent {
val intent = Intent(action).apply {
component = ComponentName(ctx, CheckInWidgetProvider::class.java)
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
}
return PendingIntent.getBroadcast(
ctx, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}

private fun loadHabitIds(prefs: android.content.SharedPreferences): List<String> {
try {
val set = prefs.getStringSet(KEY_HABITS, null)
if (!set.isNullOrEmpty()) return set.toList()
} catch (_: ClassCastException) {}

val raw = prefs.getString(KEY_HABITS, null) ?: return emptyList()

if (raw.startsWith(LIST_PREFIX)) {
val bang = raw.indexOf('!')
if (bang >= 0) {
return try {
val arr = JSONArray(raw.substring(bang + 1))
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
}

return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}

private fun loadHabit(prefs: android.content.SharedPreferences, id: String): JSONObject? {
val json = prefs.getString("flutter.$id", null) ?: return null
return try { JSONObject(json) } catch (_: Exception) { null }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ class MainActivity : FlutterFragmentActivity() {
val mgr = AppWidgetManager.getInstance(this)
listOf(
CalendarWidgetProvider::class.java,
TreeWidgetProvider::class.java
TreeWidgetProvider::class.java,
CheckInWidgetProvider::class.java
).forEach { cls ->
val ids = mgr.getAppWidgetIds(ComponentName(this, cls))
if (ids.isNotEmpty()) {
Expand Down
44 changes: 44 additions & 0 deletions android/app/src/main/res/layout/widget_checkin.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/checkin_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_bg"
android:padding="6dp">

<TextView
android:id="@+id/checkin_habit_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:gravity="center"
android:text="Tap to pick"
android:textColor="#FF8AA88C"
android:textSize="8sp"
android:ellipsize="end"
android:singleLine="true"
android:maxLines="1" />

<TextView
android:id="@+id/checkin_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="✓"
android:textSize="28sp"
android:textStyle="bold"
android:gravity="center" />

<TextView
android:id="@+id/checkin_streak"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:gravity="center"
android:text="0"
android:textColor="#FFE0EBE0"
android:textSize="10sp"
android:textStyle="bold" />

</RelativeLayout>
1 change: 1 addition & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
<string name="app_name">Grove</string>
<string name="calendar_widget_description">Grove monthly calendar</string>
<string name="tree_widget_description">Grove habit tree</string>
<string name="checkin_widget_description">Grove quick check-in</string>
</resources>
14 changes: 14 additions & 0 deletions android/app/src/main/res/xml/checkin_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_checkin"
android:resizeMode="none"
android:widgetCategory="home_screen"
android:configure="com.resurrect.grove.HabitPickerActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/checkin_widget_description" />
22 changes: 21 additions & 1 deletion lib/providers/grove_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:grove/models/grove_models.dart';
import 'package:grove/services/grove_notifications.dart';
import 'package:grove/services/widget_bridge.dart';

class GroveModel extends ChangeNotifier {
class GroveModel extends ChangeNotifier with WidgetsBindingObserver {
List<HabitTree> _habits = [];
List<HabitTree> get habits => List.unmodifiable(_habits);

Expand All @@ -16,11 +16,31 @@ class GroveModel extends ChangeNotifier {
try {
_prefs = await SharedPreferences.getInstance();
_load();
WidgetsBinding.instance.addObserver(this);
} catch (e) {
debugPrint('GroveModel init error: $e');
}
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_reloadFromPrefs();
}
}

Future<void> _reloadFromPrefs() async {
if (_prefs == null) return;
await _prefs!.reload();
_load();
}

void _load() {
if (_prefs == null) return;
final ids = _prefs!.getStringList(_idsKey) ?? [];
Expand Down
27 changes: 15 additions & 12 deletions lib/services/widget_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,27 @@ class GroveWidgetBridge {
Future<void> renderAndUpdate(List<HabitTree> habits) async {
try {
final filesDir = await _channel.invokeMethod<String>('getFilesDir');
if (filesDir == null) return;
if (filesDir != null) {
for (final habit in habits) {
try {
final bytes = await _renderHabit(habit);
if (bytes == null) continue;

for (final habit in habits) {
final bytes = await _renderHabit(habit);
if (bytes == null) continue;

await _channel.invokeMethod<void>('saveTreeImage', {
'habitId': habit.id,
'bytes': bytes,
});
await _channel.invokeMethod<void>('saveTreeImage', {
'habitId': habit.id,
'bytes': bytes,
});
} catch (e) {
debugPrint('GroveWidgetBridge: failed to render habit ${habit.id}: $e');
}
}
}

await _channel.invokeMethod<void>('updateWidgets');
} on MissingPluginException {

} catch (e) {
debugPrint('GroveWidgetBridge.renderAndUpdate error: $e');
}

await requestUpdate();
}

Future<void> requestUpdate() async {
Expand Down
Loading