diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6161e56..5ac3517 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -89,6 +89,19 @@
android:resource="@xml/tree_widget_info" />
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/resurrect/grove/CheckInWidgetProvider.kt b/android/app/src/main/kotlin/com/resurrect/grove/CheckInWidgetProvider.kt
new file mode 100644
index 0000000..5f61b41
--- /dev/null
+++ b/android/app/src/main/kotlin/com/resurrect/grove/CheckInWidgetProvider.kt
@@ -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()
+ 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()
+ 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 {
+ 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 }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/resurrect/grove/MainActivity.kt b/android/app/src/main/kotlin/com/resurrect/grove/MainActivity.kt
index 0e37a00..8977a05 100644
--- a/android/app/src/main/kotlin/com/resurrect/grove/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/resurrect/grove/MainActivity.kt
@@ -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()) {
diff --git a/android/app/src/main/res/layout/widget_checkin.xml b/android/app/src/main/res/layout/widget_checkin.xml
new file mode 100644
index 0000000..b1faa57
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_checkin.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 64fb728..b1cd9ec 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -3,4 +3,5 @@
Grove
Grove monthly calendar
Grove habit tree
+ Grove quick check-in
diff --git a/android/app/src/main/res/xml/checkin_widget_info.xml b/android/app/src/main/res/xml/checkin_widget_info.xml
new file mode 100644
index 0000000..5a505bc
--- /dev/null
+++ b/android/app/src/main/res/xml/checkin_widget_info.xml
@@ -0,0 +1,14 @@
+
+
+
diff --git a/lib/providers/grove_model.dart b/lib/providers/grove_model.dart
index e5ea0fd..ed3e326 100644
--- a/lib/providers/grove_model.dart
+++ b/lib/providers/grove_model.dart
@@ -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 _habits = [];
List get habits => List.unmodifiable(_habits);
@@ -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 _reloadFromPrefs() async {
+ if (_prefs == null) return;
+ await _prefs!.reload();
+ _load();
+ }
+
void _load() {
if (_prefs == null) return;
final ids = _prefs!.getStringList(_idsKey) ?? [];
diff --git a/lib/services/widget_bridge.dart b/lib/services/widget_bridge.dart
index a63c875..dec9ab9 100644
--- a/lib/services/widget_bridge.dart
+++ b/lib/services/widget_bridge.dart
@@ -13,24 +13,27 @@ class GroveWidgetBridge {
Future renderAndUpdate(List habits) async {
try {
final filesDir = await _channel.invokeMethod('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('saveTreeImage', {
- 'habitId': habit.id,
- 'bytes': bytes,
- });
+ await _channel.invokeMethod('saveTreeImage', {
+ 'habitId': habit.id,
+ 'bytes': bytes,
+ });
+ } catch (e) {
+ debugPrint('GroveWidgetBridge: failed to render habit ${habit.id}: $e');
+ }
+ }
}
-
- await _channel.invokeMethod('updateWidgets');
} on MissingPluginException {
-
} catch (e) {
debugPrint('GroveWidgetBridge.renderAndUpdate error: $e');
}
+
+ await requestUpdate();
}
Future requestUpdate() async {