diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4846b6f..5e9dc43 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -37,6 +37,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ WidgetStore.setIncludeOthers(this, appWidgetId, includeOthers.isChecked());
+ WidgetUi.refreshAll(this);
+ setResult(RESULT_OK, resultIntent());
+ finish();
+ });
+ }
+
+ private Intent resultIntent() {
+ return new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ }
+}
diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java b/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java
new file mode 100644
index 0000000..110762e
--- /dev/null
+++ b/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java
@@ -0,0 +1,291 @@
+package com.donetick.app.widget;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import androidx.core.content.ContextCompat;
+
+import com.donetick.app.R;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/** Feeds task rows, day-group headers, and people rows to the widget ListView. */
+public class WidgetListService extends RemoteViewsService {
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ String mode = intent.getStringExtra(WidgetUi.EXTRA_MODE);
+ int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID);
+ return new WidgetListFactory(getApplicationContext(),
+ mode != null ? mode : WidgetUi.MODE_TODAY, appWidgetId);
+ }
+
+ static class Row {
+ final String header; // non-null for day-group headers
+ final WidgetStore.Task task; // non-null for task rows
+ final WidgetStore.Member person; // non-null for people rows
+ int todayCount;
+ int weekCount;
+
+ Row(String header, WidgetStore.Task task, WidgetStore.Member person) {
+ this.header = header;
+ this.task = task;
+ this.person = person;
+ }
+ }
+
+ static class WidgetListFactory implements RemoteViewsFactory {
+ private final Context context;
+ private final String mode;
+ private final int appWidgetId;
+ private List rows = new ArrayList<>();
+ private final Map avatars = new HashMap<>();
+ private String myUserId;
+ private boolean includeOthers;
+
+ WidgetListFactory(Context context, String mode, int appWidgetId) {
+ this.context = context;
+ this.mode = mode;
+ this.appWidgetId = appWidgetId;
+ }
+
+ @Override
+ public void onCreate() {}
+
+ @Override
+ public void onDataSetChanged() {
+ // Runs on a binder thread, so synchronous network work is allowed
+ // here. This is the background-refresh path: the 30-minute
+ // updatePeriodMillis cycle lands here via notifyAppWidgetViewDataChanged.
+ boolean refreshed = WidgetStore.refreshFromServerIfStale(context);
+
+ myUserId = WidgetStore.userId(context);
+ includeOthers = WidgetStore.includeOthers(context, appWidgetId);
+ List allTasks = WidgetStore.loadTasks(context);
+ List members = WidgetStore.loadMembers(context);
+
+ if (WidgetUi.MODE_PEOPLE.equals(mode)) {
+ rows = buildPeopleRows(allTasks, members);
+ loadAvatars(members);
+ } else {
+ List tasks =
+ WidgetStore.visibleTasks(context, allTasks, includeOthers);
+ rows = WidgetUi.MODE_TODAY.equals(mode)
+ ? buildTodayRows(tasks)
+ : buildWeekRows(tasks);
+ if (includeOthers) loadAvatars(members);
+ }
+
+ if (refreshed) {
+ // Counts and "Updated …" line live outside the list.
+ WidgetUi.updateHeaders(context);
+ }
+ }
+
+ /** Resolve avatars for everyone up front — getViewAt must not block. */
+ private void loadAvatars(List members) {
+ avatars.clear();
+ for (WidgetStore.Member member : members) {
+ avatars.put(member.id, AvatarCache.get(context, member));
+ }
+ }
+
+ private List buildTodayRows(List tasks) {
+ List result = new ArrayList<>();
+ for (WidgetStore.Task task : WidgetStore.todaySubset(tasks)) {
+ result.add(new Row(null, task, null));
+ }
+ return result;
+ }
+
+ private List buildWeekRows(List tasks) {
+ List result = new ArrayList<>();
+ long startOfToday = WidgetStore.endOfDay(-1) + 1;
+ SimpleDateFormat dayFormat = new SimpleDateFormat("EEE, MMM d", Locale.getDefault());
+
+ List approvals = new ArrayList<>();
+ List scheduled = new ArrayList<>();
+ for (WidgetStore.Task task : tasks) {
+ if (task.approval) approvals.add(task);
+ else if (task.dueDate != null) scheduled.add(task);
+ }
+
+ if (!approvals.isEmpty()) {
+ result.add(new Row(context.getString(R.string.widget_group_approval), null, null));
+ for (WidgetStore.Task task : approvals) result.add(new Row(null, task, null));
+ }
+
+ String currentGroup = null;
+ for (WidgetStore.Task task : scheduled) {
+ String group;
+ if (task.dueDate < startOfToday) {
+ group = context.getString(R.string.widget_group_overdue);
+ } else if (task.dueDate <= WidgetStore.endOfDay(0)) {
+ group = context.getString(R.string.widget_group_today);
+ } else if (task.dueDate <= WidgetStore.endOfDay(1)) {
+ group = context.getString(R.string.widget_group_tomorrow);
+ } else {
+ group = dayFormat.format(new Date(task.dueDate));
+ }
+ if (!group.equals(currentGroup)) {
+ result.add(new Row(group, null, null));
+ currentGroup = group;
+ }
+ result.add(new Row(null, task, null));
+ }
+ return result;
+ }
+
+ /** One row per member with today/week workloads, busiest first. */
+ private List buildPeopleRows(List tasks,
+ List members) {
+ List result = new ArrayList<>();
+ List todayTasks = WidgetStore.todaySubset(tasks);
+ for (WidgetStore.Member member : members) {
+ Row row = new Row(null, null, member);
+ for (WidgetStore.Task task : tasks) {
+ if (member.id.equals(task.assignedTo)) row.weekCount++;
+ }
+ for (WidgetStore.Task task : todayTasks) {
+ if (member.id.equals(task.assignedTo)) row.todayCount++;
+ }
+ result.add(row);
+ }
+ java.util.Collections.sort(result, (a, b) -> {
+ if (a.todayCount != b.todayCount) return b.todayCount - a.todayCount;
+ if (a.weekCount != b.weekCount) return b.weekCount - a.weekCount;
+ return a.person.name.compareToIgnoreCase(b.person.name);
+ });
+ return result;
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ if (position < 0 || position >= rows.size()) return null;
+ Row row = rows.get(position);
+
+ if (row.header != null) {
+ RemoteViews views = new RemoteViews(context.getPackageName(),
+ R.layout.widget_row_day_header);
+ views.setTextViewText(R.id.row_day, row.header);
+ return views;
+ }
+
+ if (row.person != null) {
+ return buildPersonRow(row);
+ }
+
+ WidgetStore.Task task = row.task;
+ RemoteViews views = new RemoteViews(context.getPackageName(),
+ R.layout.widget_row_task);
+ views.setTextViewText(R.id.row_title, task.name);
+
+ boolean overdue = !task.approval && task.dueDate != null
+ && task.dueDate < System.currentTimeMillis();
+ int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
+ int warning = ContextCompat.getColor(context, R.color.widget_warning);
+ int danger = ContextCompat.getColor(context, R.color.widget_overdue);
+
+ String meta;
+ int metaColor;
+ if (task.approval) {
+ meta = context.getString(R.string.widget_meta_approval);
+ metaColor = warning;
+ } else if (overdue) {
+ meta = context.getString(R.string.widget_meta_overdue);
+ metaColor = danger;
+ } else {
+ meta = android.text.format.DateFormat.getTimeFormat(context)
+ .format(new Date(task.dueDate));
+ metaColor = secondary;
+ }
+ views.setTextViewText(R.id.row_meta, meta);
+ views.setTextColor(R.id.row_meta, metaColor);
+
+ int ringColor;
+ if (task.approval) ringColor = warning;
+ else if (overdue || task.priority == 1) ringColor = danger;
+ else if (task.priority == 2) ringColor = warning;
+ else ringColor = ContextCompat.getColor(context, R.color.widget_ring_neutral);
+ views.setInt(R.id.row_ring, "setColorFilter", ringColor);
+
+ // In "everyone" mode, show who a task belongs to (own tasks stay clean).
+ Bitmap avatar = includeOthers && task.assignedTo != null
+ && !task.assignedTo.equals(myUserId)
+ ? avatars.get(task.assignedTo)
+ : null;
+ if (avatar != null) {
+ views.setImageViewBitmap(R.id.row_avatar, avatar);
+ views.setViewVisibility(R.id.row_avatar, View.VISIBLE);
+ } else {
+ views.setViewVisibility(R.id.row_avatar, View.GONE);
+ }
+
+ Intent fillIn = new Intent();
+ fillIn.setData(Uri.parse("donetick://chores/" + task.id));
+ views.setOnClickFillInIntent(R.id.row_root, fillIn);
+ return views;
+ }
+
+ private RemoteViews buildPersonRow(Row row) {
+ RemoteViews views = new RemoteViews(context.getPackageName(),
+ R.layout.widget_row_person);
+ Bitmap avatar = avatars.get(row.person.id);
+ if (avatar == null) avatar = AvatarCache.initials(row.person);
+ views.setImageViewBitmap(R.id.person_avatar, avatar);
+ views.setTextViewText(R.id.person_name, row.person.name);
+ views.setTextViewText(R.id.person_counts, context.getString(
+ R.string.widget_person_counts, row.todayCount, row.weekCount));
+
+ // Highlight the today-count when someone has work due today.
+ int accent = ContextCompat.getColor(context, R.color.widget_accent);
+ int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
+ views.setTextColor(R.id.person_counts, row.todayCount > 0 ? accent : secondary);
+
+ Intent fillIn = new Intent();
+ fillIn.setData(Uri.parse("donetick://chores"));
+ views.setOnClickFillInIntent(R.id.person_root, fillIn);
+ return views;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 3;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public int getCount() {
+ return rows.size();
+ }
+
+ @Override
+ public void onDestroy() {}
+ }
+}
diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java b/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java
new file mode 100644
index 0000000..14a4124
--- /dev/null
+++ b/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java
@@ -0,0 +1,346 @@
+package com.donetick.app.widget;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Shared storage + refresh logic for the home-screen widgets.
+ *
+ * The web app pushes a pre-filtered snapshot through WidgetBridgePlugin
+ * whenever its chores cache changes. When the app has not run for a while,
+ * {@link #refreshFromServerIfStale} re-fetches the list directly so the
+ * widget stays current in the background (driven by the 30-minute
+ * updatePeriodMillis cycle).
+ *
+ * Snapshot JSON: {version, lastUpdated,
+ * tasks:[{id, name, dueDate, priority, approval, assignedTo}],
+ * members:[{id, name, image}]}
+ * Config JSON: {serverUrl, token, userId}
+ *
+ * Since v2 the snapshot holds every member's tasks; widgets narrow it down to
+ * the current user unless the per-widget "include others" option is on.
+ */
+public final class WidgetStore {
+ private static final String TAG = "DonetickWidget";
+ private static final String PREFS = "donetick_widget";
+ private static final String KEY_DATA = "widget_tasks";
+ private static final String KEY_CONFIG = "widget_config";
+ private static final String KEY_INCLUDE_OTHERS_PREFIX = "include_others_";
+
+ // Same filtering window as src/service/WidgetService.js
+ private static final int WINDOW_DAYS = 7;
+ private static final int MAX_TASKS = 100;
+ // Don't hit the network if the app (or a previous refresh) updated the
+ // snapshot recently; also guards against notify->refresh loops.
+ private static final long STALE_MS = 10 * 60 * 1000;
+
+ private static final Object REFRESH_LOCK = new Object();
+
+ private WidgetStore() {}
+
+ public static class Task {
+ public String id;
+ public String name;
+ public Long dueDate; // epoch millis, null when unscheduled
+ public int priority;
+ public boolean approval;
+ public String assignedTo; // member userId, null when unassigned
+ }
+
+ public static class Member {
+ public String id;
+ public String name;
+ public String image; // avatar URL, may be null
+ }
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext()
+ .getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ public static void saveData(Context context, String json) {
+ prefs(context).edit().putString(KEY_DATA, json).apply();
+ }
+
+ public static void saveConfig(Context context, String json) {
+ prefs(context).edit().putString(KEY_CONFIG, json).apply();
+ }
+
+ public static void clear(Context context) {
+ prefs(context).edit().clear().apply();
+ }
+
+ public static boolean hasConfig(Context context) {
+ return prefs(context).getString(KEY_CONFIG, null) != null;
+ }
+
+ /** The signed-in user's id from the pushed config, or null. */
+ public static String userId(Context context) {
+ try {
+ String raw = prefs(context).getString(KEY_CONFIG, null);
+ if (raw == null) return null;
+ Object id = new JSONObject(raw).opt("userId");
+ return id == null ? null : String.valueOf(id);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** Per-widget "include tasks assigned to others" option (default off). */
+ public static boolean includeOthers(Context context, int appWidgetId) {
+ return prefs(context).getBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, false);
+ }
+
+ public static void setIncludeOthers(Context context, int appWidgetId, boolean value) {
+ prefs(context).edit()
+ .putBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, value)
+ .apply();
+ }
+
+ public static void removeWidgetOptions(Context context, int appWidgetId) {
+ prefs(context).edit()
+ .remove(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId)
+ .apply();
+ }
+
+ public static long lastUpdated(Context context) {
+ try {
+ String raw = prefs(context).getString(KEY_DATA, null);
+ if (raw == null) return 0;
+ return new JSONObject(raw).optLong("lastUpdated", 0);
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ public static List loadTasks(Context context) {
+ List tasks = new ArrayList<>();
+ try {
+ String raw = prefs(context).getString(KEY_DATA, null);
+ if (raw == null) return tasks;
+ JSONArray arr = new JSONObject(raw).optJSONArray("tasks");
+ if (arr == null) return tasks;
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject obj = arr.optJSONObject(i);
+ if (obj == null) continue;
+ Task task = new Task();
+ task.id = String.valueOf(obj.opt("id"));
+ task.name = obj.optString("name", "");
+ task.dueDate = obj.isNull("dueDate") ? null : obj.optLong("dueDate");
+ task.priority = obj.optInt("priority", 0);
+ task.approval = obj.optBoolean("approval", false);
+ // v1 snapshots carried only the user's own tasks and had no
+ // assignedTo — treat those rows as "mine".
+ task.assignedTo = obj.has("assignedTo")
+ ? (obj.isNull("assignedTo") ? null : String.valueOf(obj.opt("assignedTo")))
+ : userId(context);
+ tasks.add(task);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to parse widget snapshot", e);
+ }
+ return tasks;
+ }
+
+ public static List loadMembers(Context context) {
+ List members = new ArrayList<>();
+ try {
+ String raw = prefs(context).getString(KEY_DATA, null);
+ if (raw == null) return members;
+ JSONArray arr = new JSONObject(raw).optJSONArray("members");
+ if (arr == null) return members;
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject obj = arr.optJSONObject(i);
+ if (obj == null) continue;
+ Member member = new Member();
+ member.id = String.valueOf(obj.opt("id"));
+ member.name = obj.optString("name", "");
+ member.image = obj.isNull("image") ? null : obj.optString("image", null);
+ members.add(member);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to parse widget members", e);
+ }
+ return members;
+ }
+
+ /**
+ * Tasks a today/week widget should render: everything when includeOthers,
+ * otherwise the user's own tasks plus approvals (which wait on them).
+ */
+ public static List visibleTasks(Context context, List tasks, boolean includeOthers) {
+ if (includeOthers) return tasks;
+ String me = userId(context);
+ List mine = new ArrayList<>();
+ for (Task task : tasks) {
+ if (task.approval || (me != null && me.equals(task.assignedTo))) {
+ mine.add(task);
+ }
+ }
+ return mine;
+ }
+
+ /** Tasks the Today widget shows: awaiting approval, overdue, or due today. */
+ public static List todaySubset(List tasks) {
+ long endOfToday = endOfDay(0);
+ List subset = new ArrayList<>();
+ for (Task task : tasks) {
+ if (task.approval || (task.dueDate != null && task.dueDate <= endOfToday)) {
+ subset.add(task);
+ }
+ }
+ return subset;
+ }
+
+ public static long endOfDay(int daysFromNow) {
+ Calendar cal = Calendar.getInstance();
+ cal.add(Calendar.DAY_OF_YEAR, daysFromNow);
+ cal.set(Calendar.HOUR_OF_DAY, 23);
+ cal.set(Calendar.MINUTE, 59);
+ cal.set(Calendar.SECOND, 59);
+ cal.set(Calendar.MILLISECOND, 999);
+ return cal.getTimeInMillis();
+ }
+
+ /**
+ * Fetch /chores/ from the configured server and rebuild the snapshot.
+ * Returns true when a network refresh actually happened and succeeded.
+ * Safe to call from RemoteViewsFactory.onDataSetChanged (binder thread).
+ */
+ public static boolean refreshFromServerIfStale(Context context) {
+ synchronized (REFRESH_LOCK) {
+ long age = System.currentTimeMillis() - lastUpdated(context);
+ if (age < STALE_MS) return false;
+
+ String rawConfig = prefs(context).getString(KEY_CONFIG, null);
+ if (rawConfig == null) return false;
+
+ HttpURLConnection connection = null;
+ try {
+ JSONObject config = new JSONObject(rawConfig);
+ String serverUrl = config.optString("serverUrl", "");
+ String token = config.optString("token", "");
+ if (serverUrl.isEmpty() || token.isEmpty()) return false;
+
+ URL url = new URL(serverUrl + "/chores/");
+ connection = (HttpURLConnection) url.openConnection();
+ connection.setConnectTimeout(10000);
+ connection.setReadTimeout(15000);
+ connection.setRequestProperty("Authorization", "Bearer " + token);
+ connection.setRequestProperty("Accept", "application/json");
+
+ if (connection.getResponseCode() != 200) {
+ // Expired token or server trouble — keep the last snapshot,
+ // the UI surfaces staleness via the "Updated …" line.
+ Log.w(TAG, "Widget refresh got HTTP " + connection.getResponseCode());
+ return false;
+ }
+
+ JSONArray chores = new JSONObject(readAll(connection.getInputStream()))
+ .optJSONArray("res");
+ if (chores == null) return false;
+
+ // The chores endpoint has no member profiles, so carry the
+ // member list over from the previous snapshot (it changes
+ // rarely and the app re-pushes it on every open).
+ JSONArray members = null;
+ String previous = prefs(context).getString(KEY_DATA, null);
+ if (previous != null) {
+ members = new JSONObject(previous).optJSONArray("members");
+ }
+
+ JSONObject snapshot = new JSONObject();
+ snapshot.put("version", 2);
+ snapshot.put("lastUpdated", System.currentTimeMillis());
+ snapshot.put("tasks", filterChores(chores));
+ snapshot.put("members", members == null ? new JSONArray() : members);
+ saveData(context, snapshot.toString());
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "Widget background refresh failed", e);
+ return false;
+ } finally {
+ if (connection != null) connection.disconnect();
+ }
+ }
+ }
+
+ /** Mirror of buildWidgetTasks in src/service/WidgetService.js. */
+ private static JSONArray filterChores(JSONArray chores) throws Exception {
+ long cutoff = endOfDay(WINDOW_DAYS);
+ List selected = new ArrayList<>();
+
+ for (int i = 0; i < chores.length(); i++) {
+ JSONObject chore = chores.optJSONObject(i);
+ if (chore == null || chore.opt("id") == null) continue;
+
+ boolean approval = chore.optInt("status", 0) == 3;
+ Long dueDate = parseDate(chore.optString("nextDueDate", null));
+ boolean inWindow = dueDate != null && dueDate <= cutoff;
+ if (!approval && !inWindow) continue;
+
+ JSONObject task = new JSONObject();
+ task.put("id", chore.opt("id"));
+ task.put("name", chore.optString("name", ""));
+ task.put("dueDate", dueDate == null ? JSONObject.NULL : dueDate);
+ task.put("priority", chore.optInt("priority", 0));
+ task.put("approval", approval);
+ task.put("assignedTo", chore.isNull("assignedTo")
+ ? JSONObject.NULL
+ : String.valueOf(chore.opt("assignedTo")));
+ selected.add(task);
+ }
+
+ Collections.sort(selected, (a, b) -> {
+ boolean aApproval = a.optBoolean("approval");
+ boolean bApproval = b.optBoolean("approval");
+ if (aApproval != bApproval) return aApproval ? -1 : 1;
+ long aDue = a.isNull("dueDate") ? Long.MAX_VALUE : a.optLong("dueDate");
+ long bDue = b.isNull("dueDate") ? Long.MAX_VALUE : b.optLong("dueDate");
+ return Long.compare(aDue, bDue);
+ });
+
+ JSONArray result = new JSONArray();
+ for (int i = 0; i < selected.size() && i < MAX_TASKS; i++) {
+ result.put(selected.get(i));
+ }
+ return result;
+ }
+
+ private static Long parseDate(String value) {
+ if (value == null || value.isEmpty() || "null".equals(value)) return null;
+ try {
+ return OffsetDateTime.parse(value).toInstant().toEpochMilli();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String readAll(InputStream stream) throws Exception {
+ StringBuilder builder = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line);
+ }
+ }
+ return builder.toString();
+ }
+}
diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java b/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
new file mode 100644
index 0000000..612abea
--- /dev/null
+++ b/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
@@ -0,0 +1,141 @@
+package com.donetick.app.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import com.donetick.app.MainActivity;
+import com.donetick.app.R;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+/** Builds the shell RemoteViews shared by the widgets and pushes updates. */
+public final class WidgetUi {
+ public static final String MODE_TODAY = "today";
+ public static final String MODE_WEEK = "week";
+ public static final String MODE_PEOPLE = "people";
+ public static final String EXTRA_MODE = "com.donetick.app.widget.MODE";
+
+ private WidgetUi() {}
+
+ /** Full refresh: redraw headers and reload list content (used by the JS bridge). */
+ public static void refreshAll(Context context) {
+ updateHeaders(context);
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ manager.notifyAppWidgetViewDataChanged(
+ widgetIds(context, manager, TodayWidgetProvider.class), R.id.widget_list);
+ manager.notifyAppWidgetViewDataChanged(
+ widgetIds(context, manager, WeekWidgetProvider.class), R.id.widget_list);
+ manager.notifyAppWidgetViewDataChanged(
+ widgetIds(context, manager, PeopleWidgetProvider.class), R.id.widget_list);
+ }
+
+ /** Redraw title/count/subtitle only — safe to call from the list factory. */
+ public static void updateHeaders(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ for (int id : widgetIds(context, manager, TodayWidgetProvider.class)) {
+ manager.updateAppWidget(id, build(context, MODE_TODAY, id));
+ }
+ for (int id : widgetIds(context, manager, WeekWidgetProvider.class)) {
+ manager.updateAppWidget(id, build(context, MODE_WEEK, id));
+ }
+ for (int id : widgetIds(context, manager, PeopleWidgetProvider.class)) {
+ manager.updateAppWidget(id, build(context, MODE_PEOPLE, id));
+ }
+ }
+
+ private static int[] widgetIds(Context context, AppWidgetManager manager, Class> provider) {
+ return manager.getAppWidgetIds(new ComponentName(context, provider));
+ }
+
+ public static RemoteViews build(Context context, String mode, int appWidgetId) {
+ boolean today = MODE_TODAY.equals(mode);
+ boolean people = MODE_PEOPLE.equals(mode);
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_shell);
+
+ int titleRes = today ? R.string.widget_today_title
+ : people ? R.string.widget_people_title
+ : R.string.widget_week_title;
+ views.setTextViewText(R.id.widget_title, context.getString(titleRes));
+
+ if (people) {
+ views.setViewVisibility(R.id.widget_count, View.GONE);
+ } else {
+ boolean includeOthers = WidgetStore.includeOthers(context, appWidgetId);
+ List tasks = WidgetStore.visibleTasks(
+ context, WidgetStore.loadTasks(context), includeOthers);
+ int count = (today ? WidgetStore.todaySubset(tasks) : tasks).size();
+ views.setTextViewText(R.id.widget_count, String.valueOf(count));
+ views.setViewVisibility(R.id.widget_count, count > 0 ? View.VISIBLE : View.GONE);
+ }
+
+ views.setTextViewText(R.id.widget_subtitle, subtitle(context));
+
+ if (!WidgetStore.hasConfig(context)) {
+ views.setTextViewText(R.id.widget_empty,
+ context.getString(R.string.widget_signed_out));
+ } else {
+ int emptyRes = today ? R.string.widget_empty_today
+ : people ? R.string.widget_empty_people
+ : R.string.widget_empty_week;
+ views.setTextViewText(R.id.widget_empty, context.getString(emptyRes));
+ }
+
+ // The Today widget gets a quick-add button that deep links straight
+ // into the in-app AddTaskModal.
+ if (today) {
+ Intent addTask = new Intent(context, MainActivity.class);
+ addTask.setAction(Intent.ACTION_VIEW);
+ addTask.setData(Uri.parse("donetick://chores/add"));
+ views.setOnClickPendingIntent(R.id.widget_add, PendingIntent.getActivity(
+ context, 2, addTask,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
+ views.setViewVisibility(R.id.widget_add, View.VISIBLE);
+ } else {
+ views.setViewVisibility(R.id.widget_add, View.GONE);
+ }
+
+ // List content comes from WidgetListService; mode travels in the intent
+ // and the unique data URI keeps the adapters from being collapsed.
+ Intent adapterIntent = new Intent(context, WidgetListService.class);
+ adapterIntent.putExtra(EXTRA_MODE, mode);
+ adapterIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ adapterIntent.setData(Uri.parse("donetickwidget://" + mode + "/" + appWidgetId));
+ views.setRemoteAdapter(R.id.widget_list, adapterIntent);
+ views.setEmptyView(R.id.widget_list, R.id.widget_empty);
+
+ // Row taps deep link into the chore view; the row's fill-in intent
+ // supplies the donetick://chores/ data URI.
+ Intent rowTemplate = new Intent(context, MainActivity.class);
+ rowTemplate.setAction(Intent.ACTION_VIEW);
+ views.setPendingIntentTemplate(R.id.widget_list, PendingIntent.getActivity(
+ context, 1, rowTemplate,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE));
+
+ // Anywhere else on the widget simply opens the app.
+ Intent openApp = new Intent(context, MainActivity.class);
+ views.setOnClickPendingIntent(R.id.widget_container, PendingIntent.getActivity(
+ context, 0, openApp,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
+
+ return views;
+ }
+
+ private static String subtitle(Context context) {
+ String date = new SimpleDateFormat("EEE, MMM d", Locale.getDefault())
+ .format(new Date());
+ long lastUpdated = WidgetStore.lastUpdated(context);
+ if (lastUpdated <= 0) return date;
+ String time = android.text.format.DateFormat.getTimeFormat(context)
+ .format(new Date(lastUpdated));
+ return date + " · " + context.getString(R.string.widget_updated_at, time);
+ }
+}
diff --git a/android/app/src/main/res/.impeccable/hook.cache.json b/android/app/src/main/res/.impeccable/hook.cache.json
new file mode 100644
index 0000000..ebc7c5d
--- /dev/null
+++ b/android/app/src/main/res/.impeccable/hook.cache.json
@@ -0,0 +1 @@
+{"version":1,"sessions":{}}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable-v31/widget_background.xml b/android/app/src/main/res/drawable-v31/widget_background.xml
new file mode 100644
index 0000000..b4e2a53
--- /dev/null
+++ b/android/app/src/main/res/drawable-v31/widget_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_add.xml b/android/app/src/main/res/drawable/ic_widget_add.xml
new file mode 100644
index 0000000..5bd0032
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_add.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml
new file mode 100644
index 0000000..adc0fad
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_count_bg.xml b/android/app/src/main/res/drawable/widget_count_bg.xml
new file mode 100644
index 0000000..ebd82cc
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_count_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_ring.xml b/android/app/src/main/res/drawable/widget_ring.xml
new file mode 100644
index 0000000..87d344a
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_ring.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_config.xml b/android/app/src/main/res/layout/widget_config.xml
new file mode 100644
index 0000000..329bbf8
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_config.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_row_day_header.xml b/android/app/src/main/res/layout/widget_row_day_header.xml
new file mode 100644
index 0000000..873af84
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_row_day_header.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_row_person.xml b/android/app/src/main/res/layout/widget_row_person.xml
new file mode 100644
index 0000000..ed9592b
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_row_person.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_row_task.xml b/android/app/src/main/res/layout/widget_row_task.xml
new file mode 100644
index 0000000..38113b1
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_row_task.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_shell.xml b/android/app/src/main/res/layout/widget_shell.xml
new file mode 100644
index 0000000..5c87600
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_shell.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..5c51693
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,12 @@
+
+
+ #181C20
+ #F0F4F8
+ #9FA6AD
+ #4B9BE8
+ #12395F
+ #F09898
+ #F3C896
+ #555E68
+ #252B31
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..63c3724
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,13 @@
+
+
+
+ #FFFFFF
+ #171A1C
+ #6B7681
+ #0B6BCB
+ #E3EFFB
+ #C41C1C
+ #B26A00
+ #B8C0C9
+ #EDF1F5
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e8d8b94..507bc36 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -4,4 +4,32 @@
Donetick
com.donetick.app
com.donetick.app
+
+
+ Today
+ Next 7 Days
+ Your tasks due today, plus anything waiting on you.
+ Your tasks for the next 7 days, grouped by day.
+ Today
+ Next 7 days
+ All caught up!\nNothing due today
+ All caught up!\nNothing due this week
+ Open Donetick and sign in\nto see your tasks
+ Overdue
+ Approve
+ Needs approval
+ Overdue
+ Today
+ Tomorrow
+ Updated %s
+ Add task
+ People
+ Everyone in your circle with their tasks for today and the week ahead.
+ People
+ No circle members yet
+ %1$d today · %2$d this week
+ Widget options
+ Show everyone\'s tasks
+ Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.
+ Save
diff --git a/android/app/src/main/res/xml/widget_people_info.xml b/android/app/src/main/res/xml/widget_people_info.xml
new file mode 100644
index 0000000..f55b9d1
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_people_info.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_today_info.xml b/android/app/src/main/res/xml/widget_today_info.xml
new file mode 100644
index 0000000..c06429d
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_today_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_week_info.xml b/android/app/src/main/res/xml/widget_week_info.xml
new file mode 100644
index 0000000..dc71ee1
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_week_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 75d79ab..f3fcaf7 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -26,6 +26,7 @@ platform :ios do
signingStyle: "manual",
provisioningProfiles: {
"com.donetick.app" => "Donetick App Store(fastline)",
+ "com.donetick.app.widget" => "Donetick Widget App Store(fastline)",
},
},
)
diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj
index 00304ab..bedc462 100644
--- a/ios/App/App.xcodeproj/project.pbxproj
+++ b/ios/App/App.xcodeproj/project.pbxproj
@@ -17,6 +17,10 @@
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D1115B482C653D60004C6043 /* GoogleService-Info.plist */; };
+ D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */; };
+ D0AC000000000000000000B2 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A2 /* MainViewController.swift */; };
+ D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A3 /* DonetickWidget.swift */; };
+ D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -35,8 +39,38 @@
D1115B482C653D60004C6043 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
D1115B4A2C659D51004C6043 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; };
+ D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = ""; };
+ D0AC000000000000000000A2 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; };
+ D0AC000000000000000000A3 /* DonetickWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonetickWidget.swift; sourceTree = ""; };
+ D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DonetickWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ D0AC000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ D0AC000000000000000000A6 /* DonetickWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DonetickWidget.entitlements; sourceTree = ""; };
/* End PBXFileReference section */
+/* Begin PBXCopyFilesBuildPhase section */
+ D0AC000000000000000000C2 /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXContainerItemProxy section */
+ D0AC000000000000000000E2 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 504EC2FC1FED79650016851F /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D0AC000000000000000000D1;
+ remoteInfo = DonetickWidgetExtension;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -46,6 +80,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D0AC000000000000000000D3 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -61,6 +102,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
+ D0AC000000000000000000C1 /* DonetickWidget */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
@@ -68,10 +110,21 @@
);
sourceTree = "";
};
+ D0AC000000000000000000C1 /* DonetickWidget */ = {
+ isa = PBXGroup;
+ children = (
+ D0AC000000000000000000A3 /* DonetickWidget.swift */,
+ D0AC000000000000000000A5 /* Info.plist */,
+ D0AC000000000000000000A6 /* DonetickWidget.entitlements */,
+ );
+ path = DonetickWidget;
+ sourceTree = "";
+ };
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
+ D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */,
);
name = Products;
sourceTree = "";
@@ -82,6 +135,8 @@
D1115B4A2C659D51004C6043 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
+ D0AC000000000000000000A2 /* MainViewController.swift */,
+ D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
@@ -114,16 +169,35 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
+ D0AC000000000000000000C2 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
+ D0AC000000000000000000E1 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
+ D0AC000000000000000000D1 /* DonetickWidgetExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */;
+ buildPhases = (
+ D0AC000000000000000000D2 /* Sources */,
+ D0AC000000000000000000D3 /* Frameworks */,
+ D0AC000000000000000000D4 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = DonetickWidgetExtension;
+ productName = DonetickWidgetExtension;
+ productReference = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -138,6 +212,10 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
+ D0AC000000000000000000D1 = {
+ CreatedOnToolsVersion = 15.0;
+ ProvisioningStyle = Automatic;
+ };
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -156,6 +234,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
+ D0AC000000000000000000D1 /* DonetickWidgetExtension */,
);
};
/* End PBXProject section */
@@ -176,6 +255,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D0AC000000000000000000D4 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -220,11 +306,29 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
+ D0AC000000000000000000B2 /* MainViewController.swift in Sources */,
+ D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D0AC000000000000000000D2 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ D0AC000000000000000000E1 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = D0AC000000000000000000D1 /* DonetickWidgetExtension */;
+ targetProxy = D0AC000000000000000000E2 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -395,6 +499,48 @@
};
name = Release;
};
+ D0AC000000000000000000F2 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 37;
+ DEVELOPMENT_TEAM = 6UJJ78R3BS;
+ INFOPLIST_FILE = DonetickWidget/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
+ MARKETING_VERSION = 1.2.16;
+ PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D0AC000000000000000000F3 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Distribution";
+ CODE_SIGN_STYLE = Manual;
+ PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
+ CURRENT_PROJECT_VERSION = 37;
+ DEVELOPMENT_TEAM = 6UJJ78R3BS;
+ INFOPLIST_FILE = DonetickWidget/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
+ MARKETING_VERSION = 1.2.16;
+ PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -416,6 +562,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D0AC000000000000000000F2 /* Debug */,
+ D0AC000000000000000000F3 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements
index 8a0ffcc..aa63eb6 100644
--- a/ios/App/App/App.entitlements
+++ b/ios/App/App/App.entitlements
@@ -4,6 +4,10 @@
aps-environment
development
+ com.apple.security.application-groups
+
+ group.com.donetick.app
+
com.apple.developer.applesignin
Default
diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard
index b44df7b..b09af01 100644
--- a/ios/App/App/Base.lproj/Main.storyboard
+++ b/ios/App/App/Base.lproj/Main.storyboard
@@ -11,7 +11,7 @@
-
+
diff --git a/ios/App/App/MainViewController.swift b/ios/App/App/MainViewController.swift
new file mode 100644
index 0000000..26f3b81
--- /dev/null
+++ b/ios/App/App/MainViewController.swift
@@ -0,0 +1,9 @@
+import Capacitor
+import UIKit
+
+/// Registers app-local Capacitor plugins; referenced from Main.storyboard.
+class MainViewController: CAPBridgeViewController {
+ override func capacitorDidLoad() {
+ bridge?.registerPluginInstance(WidgetBridgePlugin())
+ }
+}
diff --git a/ios/App/App/WidgetBridgePlugin.swift b/ios/App/App/WidgetBridgePlugin.swift
new file mode 100644
index 0000000..e469149
--- /dev/null
+++ b/ios/App/App/WidgetBridgePlugin.swift
@@ -0,0 +1,46 @@
+import Capacitor
+import Foundation
+import WidgetKit
+
+/// JS bridge for the home-screen widgets (see src/service/WidgetService.js).
+/// Persists the task snapshot + API config in the shared App Group so the
+/// widget extension can read them, then asks WidgetKit to redraw.
+@objc(WidgetBridgePlugin)
+public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
+ public let identifier = "WidgetBridgePlugin"
+ public let jsName = "WidgetBridge"
+ public let pluginMethods: [CAPPluginMethod] = [
+ CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
+ ]
+
+ static let appGroup = "group.com.donetick.app"
+ static let dataKey = "widget_tasks"
+ static let configKey = "widget_config"
+
+ @objc func update(_ call: CAPPluginCall) {
+ guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
+ call.reject("App Group \(Self.appGroup) unavailable")
+ return
+ }
+ if let data = call.getString("data") {
+ defaults.set(data, forKey: Self.dataKey)
+ }
+ if let config = call.getString("config") {
+ defaults.set(config, forKey: Self.configKey)
+ }
+ WidgetCenter.shared.reloadAllTimelines()
+ call.resolve()
+ }
+
+ @objc func clear(_ call: CAPPluginCall) {
+ guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
+ call.reject("App Group \(Self.appGroup) unavailable")
+ return
+ }
+ defaults.removeObject(forKey: Self.dataKey)
+ defaults.removeObject(forKey: Self.configKey)
+ WidgetCenter.shared.reloadAllTimelines()
+ call.resolve()
+ }
+}
diff --git a/ios/App/DonetickWidget/DonetickWidget.entitlements b/ios/App/DonetickWidget/DonetickWidget.entitlements
new file mode 100644
index 0000000..b929343
--- /dev/null
+++ b/ios/App/DonetickWidget/DonetickWidget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.donetick.app
+
+
+
diff --git a/ios/App/DonetickWidget/DonetickWidget.swift b/ios/App/DonetickWidget/DonetickWidget.swift
new file mode 100644
index 0000000..aefa958
--- /dev/null
+++ b/ios/App/DonetickWidget/DonetickWidget.swift
@@ -0,0 +1,1007 @@
+import AppIntents
+import SwiftUI
+import WidgetKit
+
+// MARK: - Palette
+
+private func dynamicColor(light: UInt32, dark: UInt32) -> Color {
+ func uiColor(_ hex: UInt32) -> UIColor {
+ UIColor(
+ red: CGFloat((hex >> 16) & 0xFF) / 255,
+ green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255,
+ alpha: 1
+ )
+ }
+ return Color(UIColor { trait in
+ trait.userInterfaceStyle == .dark ? uiColor(dark) : uiColor(light)
+ })
+}
+
+enum Palette {
+ static let accent = dynamicColor(light: 0x0B6BCB, dark: 0x4B9BE8)
+ static let accentSoft = dynamicColor(light: 0xE3EFFB, dark: 0x12395F)
+ static let danger = dynamicColor(light: 0xC41C1C, dark: 0xF09898)
+ static let warning = dynamicColor(light: 0xB26A00, dark: 0xF3C896)
+ static let ringNeutral = dynamicColor(light: 0xB8C0C9, dark: 0x555E68)
+
+ // Initials-disc colors; indexed by a stable hash of the member id so each
+ // person keeps their color (mirror of AvatarCache.java).
+ static let avatarColors: [Color] = [
+ Color(red: 0x0B / 255, green: 0x6B / 255, blue: 0xCB / 255),
+ Color(red: 0x14 / 255, green: 0x7D / 255, blue: 0x57 / 255),
+ Color(red: 0x9C / 255, green: 0x4D / 255, blue: 0xD3 / 255),
+ Color(red: 0xC2 / 255, green: 0x41 / 255, blue: 0x0C / 255),
+ Color(red: 0x0E / 255, green: 0x74 / 255, blue: 0x90 / 255),
+ Color(red: 0xB0 / 255, green: 0x2A / 255, blue: 0x5B / 255),
+ Color(red: 0x5B / 255, green: 0x21 / 255, blue: 0xB6 / 255),
+ Color(red: 0x93 / 255, green: 0x78 / 255, blue: 0x00 / 255),
+ ]
+}
+
+// MARK: - Model
+
+struct WidgetTask: Identifiable {
+ let id: String
+ let name: String
+ let dueDate: Date?
+ let priority: Int
+ let approval: Bool
+ let assignedTo: String?
+
+ var overdue: Bool {
+ guard !approval, let due = dueDate else { return false }
+ return due < Date()
+ }
+
+ var deepLink: URL? {
+ URL(string: "donetick://chores/\(id)")
+ }
+}
+
+struct WidgetMember: Identifiable {
+ let id: String
+ let name: String
+ let image: String?
+
+ var color: Color {
+ let hash = id.unicodeScalars.reduce(0) { $0 + Int($1.value) }
+ return Palette.avatarColors[hash % Palette.avatarColors.count]
+ }
+
+ var initial: String {
+ name.trimmingCharacters(in: .whitespaces).first.map(String.init)?.uppercased() ?? "?"
+ }
+}
+
+// MARK: - Shared store (App Group)
+
+enum WidgetStore {
+ static let appGroup = "group.com.donetick.app"
+ static let dataKey = "widget_tasks"
+ static let configKey = "widget_config"
+
+ // Same filtering window as src/service/WidgetService.js
+ static let windowDays = 7
+ static let maxTasks = 100
+ static let staleInterval: TimeInterval = 10 * 60
+
+ static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) }
+
+ static var signedIn: Bool {
+ defaults?.string(forKey: configKey) != nil
+ }
+
+ static var lastUpdated: Date? {
+ guard let snapshot = snapshotDict(),
+ let millis = snapshot["lastUpdated"] as? Double, millis > 0
+ else { return nil }
+ return Date(timeIntervalSince1970: millis / 1000)
+ }
+
+ static var userId: String? {
+ guard let raw = defaults?.string(forKey: configKey),
+ let data = raw.data(using: .utf8),
+ let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let id = config["userId"]
+ else { return nil }
+ return "\(id)"
+ }
+
+ private static func snapshotDict() -> [String: Any]? {
+ guard let raw = defaults?.string(forKey: dataKey),
+ let data = raw.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return nil }
+ return json
+ }
+
+ static func loadTasks() -> [WidgetTask] {
+ guard let items = snapshotDict()?["tasks"] as? [[String: Any]] else { return [] }
+ return items.compactMap { item in
+ guard let rawId = item["id"] else { return nil }
+ var dueDate: Date?
+ if let millis = item["dueDate"] as? Double {
+ dueDate = Date(timeIntervalSince1970: millis / 1000)
+ }
+ // v1 snapshots carried only the user's own tasks and had no
+ // assignedTo — treat those rows as "mine".
+ var assignedTo: String?
+ if let raw = item["assignedTo"], !(raw is NSNull) {
+ assignedTo = "\(raw)"
+ } else if item.index(forKey: "assignedTo") == nil {
+ assignedTo = userId
+ }
+ return WidgetTask(
+ id: "\(rawId)",
+ name: item["name"] as? String ?? "",
+ dueDate: dueDate,
+ priority: item["priority"] as? Int ?? 0,
+ approval: item["approval"] as? Bool ?? false,
+ assignedTo: assignedTo
+ )
+ }
+ }
+
+ static func loadMembers() -> [WidgetMember] {
+ guard let items = snapshotDict()?["members"] as? [[String: Any]] else { return [] }
+ return items.compactMap { item in
+ guard let rawId = item["id"] else { return nil }
+ return WidgetMember(
+ id: "\(rawId)",
+ name: item["name"] as? String ?? "",
+ image: item["image"] as? String
+ )
+ }
+ }
+
+ /// Tasks a today/week widget should render: everything when includeOthers,
+ /// otherwise the user's own tasks plus approvals (which wait on them).
+ static func visibleTasks(_ tasks: [WidgetTask], includeOthers: Bool) -> [WidgetTask] {
+ guard !includeOthers else { return tasks }
+ let me = userId
+ return tasks.filter { $0.approval || (me != nil && $0.assignedTo == me) }
+ }
+
+ /// Tasks the Today widget shows: awaiting approval, overdue, or due today.
+ static func todaySubset(_ tasks: [WidgetTask]) -> [WidgetTask] {
+ let endOfToday = endOfDay(daysFromNow: 0)
+ return tasks.filter { $0.approval || ($0.dueDate.map { $0 <= endOfToday } ?? false) }
+ }
+
+ static func endOfDay(daysFromNow: Int) -> Date {
+ let calendar = Calendar.current
+ let day = calendar.date(byAdding: .day, value: daysFromNow, to: Date()) ?? Date()
+ let start = calendar.startOfDay(for: day)
+ return calendar.date(byAdding: DateComponents(day: 1, second: -1), to: start) ?? day
+ }
+
+ // MARK: Background refresh
+
+ /// Re-fetch /chores/ when the app has not pushed a snapshot recently, so
+ /// the widget stays current while the app is closed. On any failure the
+ /// last snapshot stays; the UI shows staleness via "Updated …".
+ static func refreshIfStale() async {
+ if let updated = lastUpdated, Date().timeIntervalSince(updated) < staleInterval {
+ return
+ }
+ guard let raw = defaults?.string(forKey: configKey),
+ let configData = raw.data(using: .utf8),
+ let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
+ let serverUrl = config["serverUrl"] as? String,
+ let token = config["token"] as? String,
+ let url = URL(string: serverUrl + "/chores/")
+ else { return }
+
+ var request = URLRequest(url: url, timeoutInterval: 15)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ guard let (data, response) = try? await URLSession.shared.data(for: request),
+ (response as? HTTPURLResponse)?.statusCode == 200,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let chores = json["res"] as? [[String: Any]]
+ else { return }
+
+ // The chores endpoint has no member profiles, so carry the member
+ // list over from the previous snapshot (it changes rarely and the
+ // app re-pushes it on every open).
+ let members = snapshotDict()?["members"] ?? [[String: Any]]()
+
+ let snapshot: [String: Any] = [
+ "version": 2,
+ "lastUpdated": Date().timeIntervalSince1970 * 1000,
+ "tasks": filterChores(chores),
+ "members": members,
+ ]
+ if let encoded = try? JSONSerialization.data(withJSONObject: snapshot),
+ let string = String(data: encoded, encoding: .utf8) {
+ defaults?.set(string, forKey: dataKey)
+ }
+ }
+
+ /// Mirror of buildWidgetTasks in src/service/WidgetService.js.
+ private static func filterChores(_ chores: [[String: Any]]) -> [[String: Any]] {
+ let cutoff = endOfDay(daysFromNow: windowDays)
+
+ var selected: [[String: Any]] = []
+ for chore in chores {
+ guard let id = chore["id"] else { continue }
+ let approval = (chore["status"] as? Int ?? 0) == 3
+ let dueDate = parseDate(chore["nextDueDate"] as? String)
+ let inWindow = dueDate != nil && dueDate! <= cutoff
+ guard approval || inWindow else { continue }
+
+ var task: [String: Any] = [
+ "id": id,
+ "name": chore["name"] as? String ?? "",
+ "priority": chore["priority"] as? Int ?? 0,
+ "approval": approval,
+ ]
+ task["dueDate"] = dueDate.map { $0.timeIntervalSince1970 * 1000 } ?? NSNull()
+ if let assignee = chore["assignedTo"], !(assignee is NSNull) {
+ task["assignedTo"] = "\(assignee)"
+ } else {
+ task["assignedTo"] = NSNull()
+ }
+ selected.append(task)
+ }
+
+ selected.sort { a, b in
+ let aApproval = a["approval"] as? Bool ?? false
+ let bApproval = b["approval"] as? Bool ?? false
+ if aApproval != bApproval { return aApproval }
+ let aDue = a["dueDate"] as? Double ?? .greatestFiniteMagnitude
+ let bDue = b["dueDate"] as? Double ?? .greatestFiniteMagnitude
+ return aDue < bDue
+ }
+ return Array(selected.prefix(maxTasks))
+ }
+
+ private static func parseDate(_ value: String?) -> Date? {
+ guard let value = value, !value.isEmpty else { return nil }
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = fractional.date(from: value) { return date }
+ return ISO8601DateFormatter().date(from: value)
+ }
+}
+
+// MARK: - Avatars
+
+/// Downloads member profile photos and caches them in the App Group container
+/// for a day. Members without a photo (or failed downloads) render as colored
+/// initials discs instead — see AvatarView.
+enum AvatarStore {
+ private static let maxAge: TimeInterval = 24 * 60 * 60
+ private static let sizePx: CGFloat = 96
+
+ private static var cacheDir: URL? {
+ FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: WidgetStore.appGroup)?
+ .appendingPathComponent("widget_avatars", isDirectory: true)
+ }
+
+ static func loadAll(_ members: [WidgetMember]) async -> [String: UIImage] {
+ var images: [String: UIImage] = [:]
+ for member in members {
+ if let image = await load(member) {
+ images[member.id] = image
+ }
+ }
+ return images
+ }
+
+ private static func load(_ member: WidgetMember) async -> UIImage? {
+ guard let urlString = member.image, urlString.hasPrefix("http"),
+ let url = URL(string: urlString), let dir = cacheDir
+ else { return nil }
+
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let file = dir.appendingPathComponent("\(member.id).png")
+
+ if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path),
+ let modified = attrs[.modificationDate] as? Date,
+ Date().timeIntervalSince(modified) < maxAge,
+ let cached = UIImage(contentsOfFile: file.path) {
+ return cached
+ }
+
+ guard let (data, response) = try? await URLSession.shared.data(from: url),
+ (response as? HTTPURLResponse)?.statusCode == 200,
+ let raw = UIImage(data: data)
+ else {
+ // Keep serving a stale copy rather than nothing.
+ return UIImage(contentsOfFile: file.path)
+ }
+
+ let scaled = downscale(raw)
+ if let png = scaled.pngData() {
+ try? png.write(to: file)
+ }
+ return scaled
+ }
+
+ private static func downscale(_ image: UIImage) -> UIImage {
+ let size = CGSize(width: sizePx, height: sizePx)
+ let renderer = UIGraphicsImageRenderer(size: size)
+ return renderer.image { _ in
+ image.draw(in: CGRect(origin: .zero, size: size))
+ }
+ }
+}
+
+// MARK: - Configuration intent (long-press → Edit Widget)
+
+struct WidgetOptionsIntent: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource = "Widget Options"
+ static var description = IntentDescription("Choose whose tasks the widget shows.")
+
+ @Parameter(title: "Show everyone's tasks", default: false)
+ var includeOthers: Bool
+}
+
+// MARK: - Timeline
+
+struct TaskEntry: TimelineEntry {
+ let date: Date
+ let tasks: [WidgetTask]
+ let members: [WidgetMember]
+ let avatars: [String: UIImage]
+ let lastUpdated: Date?
+ let signedIn: Bool
+ let includeOthers: Bool
+ let myUserId: String?
+
+ static func sample() -> TaskEntry {
+ let calendar = Calendar.current
+ let today = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: Date())!
+ return TaskEntry(
+ date: Date(),
+ tasks: [
+ WidgetTask(id: "1", name: "Take out the trash", dueDate: today, priority: 1, approval: false, assignedTo: "1"),
+ WidgetTask(id: "2", name: "Water the plants", dueDate: today, priority: 0, approval: false, assignedTo: "1"),
+ WidgetTask(id: "3", name: "Vacuum living room", dueDate: calendar.date(byAdding: .day, value: 1, to: today), priority: 2, approval: false, assignedTo: "2"),
+ WidgetTask(id: "4", name: "Clean the garage", dueDate: calendar.date(byAdding: .day, value: 3, to: today), priority: 0, approval: false, assignedTo: "1"),
+ ],
+ members: [
+ WidgetMember(id: "1", name: "Alex", image: nil),
+ WidgetMember(id: "2", name: "Sam", image: nil),
+ ],
+ avatars: [:],
+ lastUpdated: Date(),
+ signedIn: true,
+ includeOthers: false,
+ myUserId: "1"
+ )
+ }
+}
+
+private func makeEntry(includeOthers: Bool) async -> TaskEntry {
+ await WidgetStore.refreshIfStale()
+ let members = WidgetStore.loadMembers()
+ let avatars = includeOthers ? await AvatarStore.loadAll(members) : [:]
+ return TaskEntry(
+ date: Date(),
+ tasks: WidgetStore.loadTasks(),
+ members: members,
+ avatars: avatars,
+ lastUpdated: WidgetStore.lastUpdated,
+ signedIn: WidgetStore.signedIn,
+ includeOthers: includeOthers,
+ myUserId: WidgetStore.userId
+ )
+}
+
+private func makeTimeline(includeOthers: Bool) async -> Timeline {
+ Timeline(
+ entries: [await makeEntry(includeOthers: includeOthers)],
+ policy: .after(Date().addingTimeInterval(30 * 60))
+ )
+}
+
+struct DonetickProvider: AppIntentTimelineProvider {
+ func placeholder(in context: Context) -> TaskEntry {
+ .sample()
+ }
+
+ func snapshot(for configuration: WidgetOptionsIntent, in context: Context) async -> TaskEntry {
+ if context.isPreview { return .sample() }
+ return await makeEntry(includeOthers: configuration.includeOthers)
+ }
+
+ func timeline(for configuration: WidgetOptionsIntent, in context: Context) async -> Timeline {
+ await makeTimeline(includeOthers: configuration.includeOthers)
+ }
+}
+
+/// The People widget always covers the whole circle, so it needs no intent.
+struct PeopleProvider: TimelineProvider {
+ func placeholder(in context: Context) -> TaskEntry {
+ .sample()
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
+ if context.isPreview {
+ completion(.sample())
+ return
+ }
+ Task { completion(await makeEntry(includeOthers: true)) }
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ Task { completion(await makeTimeline(includeOthers: true)) }
+ }
+}
+
+// MARK: - Formatting helpers
+
+private let timeFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.timeStyle = .short
+ formatter.dateStyle = .none
+ return formatter
+}()
+
+private let dayFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.setLocalizedDateFormatFromTemplate("EEEMMMd")
+ return formatter
+}()
+
+private func dayLabel(for date: Date) -> String {
+ let calendar = Calendar.current
+ if calendar.isDateInToday(date) { return "Today" }
+ if calendar.isDateInTomorrow(date) { return "Tomorrow" }
+ if date < calendar.startOfDay(for: Date()) { return "Overdue" }
+ return dayFormatter.string(from: date)
+}
+
+private let addTaskURL = URL(string: "donetick://chores/add")
+
+// MARK: - Shared views
+
+extension View {
+ func widgetShell() -> some View {
+ containerBackground(for: .widget) { Color(UIColor.systemBackground) }
+ }
+}
+
+struct AvatarView: View {
+ let member: WidgetMember
+ let image: UIImage?
+ var size: CGFloat = 18
+
+ var body: some View {
+ if let image = image {
+ Image(uiImage: image)
+ .resizable()
+ .scaledToFill()
+ .frame(width: size, height: size)
+ .clipShape(Circle())
+ } else {
+ ZStack {
+ Circle().fill(member.color)
+ Text(member.initial)
+ .font(.system(size: size * 0.48, weight: .bold))
+ .foregroundColor(.white)
+ }
+ .frame(width: size, height: size)
+ }
+ }
+}
+
+struct TaskRow: View {
+ let task: WidgetTask
+ var showDay = false
+ var assignee: WidgetMember?
+ var assigneeImage: UIImage?
+
+ private var ringColor: Color {
+ if task.approval { return Palette.warning }
+ if task.overdue || task.priority == 1 { return Palette.danger }
+ if task.priority == 2 { return Palette.warning }
+ return Palette.ringNeutral
+ }
+
+ private var meta: (text: String, color: Color) {
+ if task.approval { return ("Approve", Palette.warning) }
+ guard let due = task.dueDate else { return ("", .secondary) }
+ if task.overdue { return ("Overdue", Palette.danger) }
+ if showDay && !Calendar.current.isDateInToday(due) {
+ return (dayLabel(for: due), .secondary)
+ }
+ return (timeFormatter.string(from: due), .secondary)
+ }
+
+ var body: some View {
+ let row = HStack(spacing: 9) {
+ Circle()
+ .strokeBorder(ringColor, lineWidth: 2)
+ .frame(width: 15, height: 15)
+ Text(task.name)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ Spacer(minLength: 6)
+ Text(meta.text)
+ .font(.system(size: 11))
+ .foregroundColor(meta.color)
+ if let assignee = assignee {
+ AvatarView(member: assignee, image: assigneeImage)
+ }
+ }
+ .frame(minHeight: 22)
+
+ if let url = task.deepLink {
+ Link(destination: url) { row }
+ } else {
+ row
+ }
+ }
+}
+
+struct WidgetHeader: View {
+ let title: String
+ let count: Int
+ let lastUpdated: Date?
+ var showAdd = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 6) {
+ Text(title)
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.primary)
+ Spacer()
+ if count > 0 {
+ Text("\(count)")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundColor(Palette.accent)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 2)
+ .background(Palette.accentSoft)
+ .clipShape(Capsule())
+ }
+ if showAdd, let url = addTaskURL {
+ Link(destination: url) {
+ Image(systemName: "plus")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundColor(Palette.accent)
+ .frame(width: 22, height: 22)
+ .background(Palette.accentSoft)
+ .clipShape(Circle())
+ }
+ }
+ }
+ Text(subtitle)
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var subtitle: String {
+ let date = dayFormatter.string(from: Date())
+ guard let updated = lastUpdated else { return date }
+ return "\(date) · Updated \(timeFormatter.string(from: updated))"
+ }
+}
+
+struct StateMessage: View {
+ let systemImage: String
+ let title: String
+ let detail: String
+
+ var body: some View {
+ VStack(spacing: 5) {
+ Image(systemName: systemImage)
+ .font(.system(size: 22))
+ .foregroundColor(Palette.accent)
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.primary)
+ Text(detail)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private extension TaskEntry {
+ /// Tasks this widget instance shows, respecting its includeOthers option.
+ var visibleTasks: [WidgetTask] {
+ WidgetStore.visibleTasks(tasks, includeOthers: includeOthers)
+ }
+
+ /// Assignee decoration for a row — only in "everyone" mode, and only for
+ /// tasks that are someone else's (own tasks stay clean).
+ func assignee(for task: WidgetTask) -> WidgetMember? {
+ guard includeOthers, let owner = task.assignedTo, owner != myUserId else { return nil }
+ return members.first { $0.id == owner }
+ }
+}
+
+// MARK: - Today widget
+
+struct TodayWidgetView: View {
+ let entry: TaskEntry
+ @Environment(\.widgetFamily) private var family
+
+ private var tasks: [WidgetTask] { WidgetStore.todaySubset(entry.visibleTasks) }
+
+ var body: some View {
+ if !entry.signedIn {
+ StateMessage(
+ systemImage: "person.crop.circle.badge.exclamationmark",
+ title: "Sign in",
+ detail: "Open Donetick to see your tasks"
+ )
+ } else if family == .systemSmall {
+ smallView
+ } else if tasks.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ WidgetHeader(title: "Today", count: 0, lastUpdated: entry.lastUpdated, showAdd: true)
+ StateMessage(
+ systemImage: "checkmark.circle",
+ title: "All caught up!",
+ detail: "Nothing due today"
+ )
+ }
+ } else {
+ listView
+ }
+ }
+
+ private var smallView: some View {
+ let overdueCount = tasks.filter(\.overdue).count
+ return VStack(alignment: .leading, spacing: 2) {
+ Text("Today")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.secondary)
+ Text("\(tasks.count)")
+ .font(.system(size: 40, weight: .bold, design: .rounded))
+ .foregroundColor(tasks.isEmpty ? .secondary : Palette.accent)
+ Text(tasks.isEmpty ? "all caught up" : (tasks.count == 1 ? "task left" : "tasks left"))
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ Spacer(minLength: 2)
+ if overdueCount > 0 {
+ Text("\(overdueCount) overdue")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(Palette.danger)
+ } else if let first = tasks.first {
+ Text(first.name)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ } else if let updated = entry.lastUpdated {
+ Text("Updated \(timeFormatter.string(from: updated))")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .widgetURL(URL(string: "donetick://chores"))
+ }
+
+ private var listView: some View {
+ let limit = family == .systemLarge ? 9 : 3
+ let visible = Array(tasks.prefix(limit))
+ let remaining = tasks.count - visible.count
+
+ return VStack(alignment: .leading, spacing: 4) {
+ WidgetHeader(title: "Today", count: tasks.count, lastUpdated: entry.lastUpdated, showAdd: true)
+ Spacer(minLength: 2)
+ ForEach(visible) { task in
+ TaskRow(
+ task: task,
+ assignee: entry.assignee(for: task),
+ assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] }
+ )
+ }
+ if remaining > 0 {
+ Text("+\(remaining) more")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+}
+
+struct TodayWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "DonetickTodayWidget",
+ intent: WidgetOptionsIntent.self,
+ provider: DonetickProvider()
+ ) { entry in
+ TodayWidgetView(entry: entry).widgetShell()
+ }
+ .configurationDisplayName("Today")
+ .description("Tasks due today, plus anything waiting on you.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+// MARK: - Next 7 days widget
+
+struct WeekWidgetView: View {
+ let entry: TaskEntry
+ @Environment(\.widgetFamily) private var family
+
+ private var tasks: [WidgetTask] { entry.visibleTasks }
+
+ private enum WeekRow: Identifiable {
+ case header(String)
+ case task(WidgetTask)
+
+ var id: String {
+ switch self {
+ case .header(let label): return "header-\(label)"
+ case .task(let task): return "task-\(task.id)"
+ }
+ }
+ }
+
+ var body: some View {
+ if !entry.signedIn {
+ StateMessage(
+ systemImage: "person.crop.circle.badge.exclamationmark",
+ title: "Sign in",
+ detail: "Open Donetick to see your tasks"
+ )
+ } else if tasks.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ WidgetHeader(title: "Next 7 days", count: 0, lastUpdated: entry.lastUpdated)
+ StateMessage(
+ systemImage: "checkmark.circle",
+ title: "All caught up!",
+ detail: "Nothing due this week"
+ )
+ }
+ } else if family == .systemMedium {
+ compactView
+ } else {
+ groupedView
+ }
+ }
+
+ // Medium: flat rows with the day in the meta column.
+ private var compactView: some View {
+ let visible = Array(tasks.prefix(3))
+ let remaining = tasks.count - visible.count
+
+ return VStack(alignment: .leading, spacing: 4) {
+ WidgetHeader(title: "Next 7 days", count: tasks.count, lastUpdated: entry.lastUpdated)
+ Spacer(minLength: 2)
+ ForEach(visible) { task in
+ TaskRow(
+ task: task,
+ showDay: true,
+ assignee: entry.assignee(for: task),
+ assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] }
+ )
+ }
+ if remaining > 0 {
+ Text("+\(remaining) more")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+
+ // Large: rows grouped under day headers.
+ private var groupedView: some View {
+ var rows: [WeekRow] = []
+ var currentGroup: String?
+
+ let approvals = tasks.filter(\.approval)
+ if !approvals.isEmpty {
+ rows.append(.header("Needs approval"))
+ rows.append(contentsOf: approvals.map(WeekRow.task))
+ }
+ for task in tasks where !task.approval {
+ guard let due = task.dueDate else { continue }
+ let group = dayLabel(for: due)
+ if group != currentGroup {
+ rows.append(.header(group))
+ currentGroup = group
+ }
+ rows.append(.task(task))
+ }
+
+ let visible = Array(rows.prefix(12))
+ let remainingTasks = rows.dropFirst(12).filter {
+ if case .task = $0 { return true }
+ return false
+ }.count
+
+ return VStack(alignment: .leading, spacing: 3) {
+ WidgetHeader(title: "Next 7 days", count: tasks.count, lastUpdated: entry.lastUpdated)
+ Spacer(minLength: 2)
+ ForEach(visible) { row in
+ switch row {
+ case .header(let label):
+ Text(label.uppercased())
+ .font(.system(size: 9, weight: .bold))
+ .foregroundColor(.secondary)
+ .kerning(0.8)
+ .padding(.top, 3)
+ case .task(let task):
+ TaskRow(
+ task: task,
+ assignee: entry.assignee(for: task),
+ assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] }
+ )
+ }
+ }
+ if remainingTasks > 0 {
+ Text("+\(remainingTasks) more")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+}
+
+struct WeekWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "DonetickWeekWidget",
+ intent: WidgetOptionsIntent.self,
+ provider: DonetickProvider()
+ ) { entry in
+ WeekWidgetView(entry: entry).widgetShell()
+ }
+ .configurationDisplayName("Next 7 Days")
+ .description("Tasks for the next 7 days, grouped by day.")
+ .supportedFamilies([.systemMedium, .systemLarge])
+ }
+}
+
+// MARK: - People widget
+
+private struct PersonLoad: Identifiable {
+ let member: WidgetMember
+ let todayCount: Int
+ let weekCount: Int
+
+ var id: String { member.id }
+}
+
+struct PeopleWidgetView: View {
+ let entry: TaskEntry
+ @Environment(\.widgetFamily) private var family
+
+ private var people: [PersonLoad] {
+ let todayTasks = WidgetStore.todaySubset(entry.tasks)
+ return entry.members
+ .map { member in
+ PersonLoad(
+ member: member,
+ todayCount: todayTasks.filter { $0.assignedTo == member.id }.count,
+ weekCount: entry.tasks.filter { $0.assignedTo == member.id }.count
+ )
+ }
+ .sorted { a, b in
+ if a.todayCount != b.todayCount { return a.todayCount > b.todayCount }
+ if a.weekCount != b.weekCount { return a.weekCount > b.weekCount }
+ return a.member.name.localizedCaseInsensitiveCompare(b.member.name) == .orderedAscending
+ }
+ }
+
+ var body: some View {
+ if !entry.signedIn {
+ StateMessage(
+ systemImage: "person.crop.circle.badge.exclamationmark",
+ title: "Sign in",
+ detail: "Open Donetick to see your circle"
+ )
+ } else if entry.members.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated)
+ StateMessage(
+ systemImage: "person.2",
+ title: "No members yet",
+ detail: "Invite your circle in Donetick"
+ )
+ }
+ } else if family == .systemMedium {
+ mediumView
+ } else {
+ largeView
+ }
+ }
+
+ // Medium: up to four members side by side, avatar first.
+ private var mediumView: some View {
+ let visible = Array(people.prefix(4))
+ return VStack(alignment: .leading, spacing: 6) {
+ WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated)
+ Spacer(minLength: 2)
+ HStack(alignment: .top, spacing: 0) {
+ ForEach(visible) { person in
+ VStack(spacing: 3) {
+ AvatarView(
+ member: person.member,
+ image: entry.avatars[person.member.id],
+ size: 34
+ )
+ Text(person.member.name)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ Text("\(person.todayCount) today")
+ .font(.system(size: 9, weight: person.todayCount > 0 ? .bold : .regular))
+ .foregroundColor(person.todayCount > 0 ? Palette.accent : .secondary)
+ Text("\(person.weekCount) this week")
+ .font(.system(size: 9))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+
+ // Large: one row per member.
+ private var largeView: some View {
+ let visible = Array(people.prefix(9))
+ return VStack(alignment: .leading, spacing: 4) {
+ WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated)
+ Spacer(minLength: 2)
+ ForEach(visible) { person in
+ HStack(spacing: 9) {
+ AvatarView(
+ member: person.member,
+ image: entry.avatars[person.member.id],
+ size: 26
+ )
+ Text(person.member.name)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ Spacer(minLength: 6)
+ Text("\(person.todayCount) today · \(person.weekCount) this week")
+ .font(.system(size: 11))
+ .foregroundColor(person.todayCount > 0 ? Palette.accent : .secondary)
+ }
+ .frame(minHeight: 28)
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+}
+
+struct PeopleWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: "DonetickPeopleWidget", provider: PeopleProvider()) { entry in
+ PeopleWidgetView(entry: entry).widgetShell()
+ }
+ .configurationDisplayName("People")
+ .description("Everyone in your circle with their tasks for today and the week ahead.")
+ .supportedFamilies([.systemMedium, .systemLarge])
+ }
+}
+
+// MARK: - Bundle
+
+@main
+struct DonetickWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ TodayWidget()
+ WeekWidget()
+ PeopleWidget()
+ }
+}
diff --git a/ios/App/DonetickWidget/Info.plist b/ios/App/DonetickWidget/Info.plist
index 843fa22..55c75a4 100644
--- a/ios/App/DonetickWidget/Info.plist
+++ b/ios/App/DonetickWidget/Info.plist
@@ -5,7 +5,7 @@
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
- DoneTick Widget
+ Donetick
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js
index e4c00ce..fd8d195 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -62,7 +62,11 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
const handleUrlOpen = (url, isColdStart = false) => {
console.log('[NFC] handleUrlOpen:', url)
- if (url.startsWith('donetick://chores/')) {
+ if (url.startsWith('donetick://chores/add')) {
+ // Widget "+" button: land on the chore list with the quick-add modal open
+ // (MyChores watches for the add_task param and consumes it).
+ routerNavigate('/chores?add_task=1')
+ } else if (url.startsWith('donetick://chores/')) {
handleNFCChoreDeepLink(url, isColdStart)
} else if (url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(url)
diff --git a/src/contexts/QueryContext.jsx b/src/contexts/QueryContext.jsx
index 3d775a9..fb2469b 100644
--- a/src/contexts/QueryContext.jsx
+++ b/src/contexts/QueryContext.jsx
@@ -1,16 +1,23 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import { initWidgetSync } from '../service/WidgetService'
const QueryContext = ({ children }) => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 300000, // 5 minutes
- gcTime: 600000, // 10 minutes
- refetchOnWindowFocus: false,
- retry: 1,
- },
- },
- })
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 300000, // 5 minutes
+ gcTime: 600000, // 10 minutes
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+ }),
+ )
+
+ useEffect(() => initWidgetSync(queryClient), [queryClient])
return (
{children}
diff --git a/src/service/WidgetService.js b/src/service/WidgetService.js
new file mode 100644
index 0000000..a4c45b1
--- /dev/null
+++ b/src/service/WidgetService.js
@@ -0,0 +1,145 @@
+import { Capacitor, registerPlugin } from '@capacitor/core'
+import { apiClient } from '../utils/ApiClient'
+
+// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
+// android/.../widget/WidgetBridgePlugin.java. It persists the snapshot in
+// storage the home-screen widgets can read (App Group defaults on iOS,
+// SharedPreferences on Android) and asks the OS to redraw them.
+const WidgetBridge = registerPlugin('WidgetBridge')
+
+const WINDOW_DAYS = 7
+const MAX_TASKS = 100
+const MAX_MEMBERS = 12
+const PUSH_DEBOUNCE_MS = 1500
+
+const isNative = () => Capacitor.isNativePlatform()
+
+// The snapshot carries every circle member's actionable tasks (due inside the
+// 7-day window, overdue included, or awaiting approval — status 3). Each task
+// records its assignee so the widgets can filter down to "mine" (the default)
+// or show everyone, per the user's widget configuration; the People widget
+// derives per-member counts from the same list. The Today widget re-derives
+// its subset natively from dueDate so one snapshot feeds all widgets.
+export const buildWidgetTasks = chores => {
+ const endOfWindow = new Date()
+ endOfWindow.setHours(23, 59, 59, 999)
+ endOfWindow.setDate(endOfWindow.getDate() + WINDOW_DAYS)
+ const cutoff = endOfWindow.getTime()
+
+ return (chores || [])
+ .map(chore => {
+ if (!chore || chore.id == null) return null
+ const approval = chore.status === 3
+ const dueDate = chore.nextDueDate
+ ? new Date(chore.nextDueDate).getTime()
+ : null
+ const inWindow = dueDate !== null && dueDate <= cutoff
+ if (!approval && !inWindow) return null
+ return {
+ id: chore.id,
+ name: chore.name || '',
+ dueDate,
+ priority: chore.priority || 0,
+ approval,
+ assignedTo: chore.assignedTo == null ? null : String(chore.assignedTo),
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => {
+ if (a.approval !== b.approval) return a.approval ? -1 : 1
+ if (a.dueDate === null) return b.dueDate === null ? 0 : 1
+ if (b.dueDate === null) return -1
+ return a.dueDate - b.dueDate
+ })
+ .slice(0, MAX_TASKS)
+}
+
+// Circle members, trimmed to what the widgets render: avatar + short name.
+export const buildWidgetMembers = members => {
+ return (members || [])
+ .filter(member => member && member.userId != null)
+ .slice(0, MAX_MEMBERS)
+ .map(member => ({
+ id: String(member.userId),
+ name: member.displayName || member.username || '',
+ image: member.image || null,
+ }))
+}
+
+const pushSnapshot = async queryClient => {
+ const choresData = queryClient.getQueryData(['chores', false])
+ const chores = choresData?.res
+ if (!Array.isArray(chores)) return
+
+ const profileQuery = queryClient
+ .getQueryCache()
+ .findAll({ queryKey: ['userProfile'] })
+ .find(q => q.state.data?.id != null)
+ const userId = profileQuery?.state.data?.id
+ if (userId == null) return
+
+ const token = apiClient.getToken()
+ if (!token) return
+
+ const members = queryClient.getQueryData(['allCircleMembers'])?.res
+
+ await WidgetBridge.update({
+ data: JSON.stringify({
+ version: 2,
+ lastUpdated: Date.now(),
+ tasks: buildWidgetTasks(chores),
+ members: buildWidgetMembers(members),
+ }),
+ config: JSON.stringify({
+ serverUrl: apiClient.getApiURL(),
+ token,
+ userId,
+ }),
+ })
+}
+
+/**
+ * Watch the react-query cache and mirror every chores update into the
+ * home-screen widgets. Covers both fresh fetches and local mutations
+ * (complete/skip/approve write through the same cache key).
+ */
+export const initWidgetSync = queryClient => {
+ if (!isNative()) return () => {}
+
+ let timer = null
+ const schedule = () => {
+ clearTimeout(timer)
+ timer = setTimeout(() => {
+ pushSnapshot(queryClient).catch(err =>
+ console.error('Widget sync failed', err),
+ )
+ }, PUSH_DEBOUNCE_MS)
+ }
+
+ const unsubscribe = queryClient.getQueryCache().subscribe(event => {
+ const key = event?.query?.queryKey
+ if (
+ event?.type === 'updated' &&
+ (key?.[0] === 'chores' ||
+ key?.[0] === 'userProfile' ||
+ key?.[0] === 'allCircleMembers')
+ ) {
+ schedule()
+ }
+ })
+
+ return () => {
+ clearTimeout(timer)
+ unsubscribe()
+ }
+}
+
+/** Wipe widget storage so no task data lingers after logout. */
+export const clearWidgetData = async () => {
+ if (!isNative()) return
+ try {
+ await WidgetBridge.clear()
+ } catch (err) {
+ console.error('Failed to clear widget data', err)
+ }
+}
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index e5cb1d1..8a9ff70 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -145,6 +145,13 @@ class ApiClient {
} catch (e) {
console.error('Error clearing image cache on logout', e)
}
+ try {
+ // Dynamic import sidesteps the ApiClient <-> WidgetService module cycle
+ const { clearWidgetData } = await import('../service/WidgetService')
+ await clearWidgetData()
+ } catch (e) {
+ console.error('Error clearing widget data on logout', e)
+ }
try {
await logout()
} catch (e) {
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index 84a0424..bc9b546 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -77,7 +77,7 @@ const MyChores = () => {
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const Navigate = useNavigate()
- const [searchParams] = useSearchParams()
+ const [searchParams, setSearchParams] = useSearchParams()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: projects = [], isLoading: projectsLoading } = useProjects()
const {
@@ -439,6 +439,18 @@ const MyChores = () => {
setSelectedProjectWithCache,
])
+ // Widget "+" deep link (donetick://chores/add → /chores?add_task=1):
+ // open the quick-add modal once and strip the param so back/refresh
+ // doesn't re-trigger it.
+ useEffect(() => {
+ if (searchParams.get('add_task') === '1') {
+ setAddTaskModalOpen(true)
+ const params = new URLSearchParams(searchParams)
+ params.delete('add_task')
+ setSearchParams(params, { replace: true })
+ }
+ }, [searchParams, setSearchParams])
+
// Read and apply filters from URL parameters
useEffect(() => {
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters