Merge pull request #158 from donetick/add-widget-ios-android

Add home-screen widgets for iOS and Android (Today, Next 7 Days, People)
This commit is contained in:
Mohamad Tarbin
2026-07-18 17:14:41 -04:00
committed by GitHub
42 changed files with 2898 additions and 14 deletions

View File

@@ -37,6 +37,53 @@
</intent-filter>
</activity>
<!-- Home-screen widgets -->
<receiver
android:name=".widget.TodayWidgetProvider"
android:exported="false"
android:label="@string/widget_today_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_today_info" />
</receiver>
<receiver
android:name=".widget.WeekWidgetProvider"
android:exported="false"
android:label="@string/widget_week_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_week_info" />
</receiver>
<receiver
android:name=".widget.PeopleWidgetProvider"
android:exported="false"
android:label="@string/widget_people_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_people_info" />
</receiver>
<service
android:name=".widget.WidgetListService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<activity
android:name=".widget.WidgetConfigActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -9,11 +9,20 @@ import com.getcapacitor.PluginHandle;
import com.getcapacitor.Plugin;
import android.content.Intent;
import android.nfc.NfcAdapter;
import android.os.Bundle;
import android.util.Log;
import com.donetick.app.widget.WidgetBridgePlugin;
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(WidgetBridgePlugin.class);
super.onCreate(savedInstanceState);
}
// Capacitor only forwards ACTION_VIEW deep links, so normalize Donetick NFC intents before dispatch.
@Override
protected void onNewIntent(Intent intent) {

View File

@@ -0,0 +1,131 @@
package com.donetick.app.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Locale;
/**
* Member avatars for the widgets: downloads and circle-crops profile photos
* (cached on disk for a day) and falls back to a colored initials disc.
* Downloads must only happen from a background/binder thread —
* RemoteViewsFactory.onDataSetChanged qualifies.
*/
final class AvatarCache {
private static final String TAG = "DonetickWidget";
private static final String DIR = "widget_avatars";
private static final long MAX_AGE_MS = 24 * 60 * 60 * 1000;
private static final int SIZE_PX = 96;
// Joy-ish palette for initials discs; picked by hashing the member id so
// each person keeps a stable color.
private static final int[] PALETTE = {
0xFF0B6BCB, 0xFF147D57, 0xFF9C4DD3, 0xFFC2410C,
0xFF0E7490, 0xFFB02A5B, 0xFF5B21B6, 0xFF937800,
};
private AvatarCache() {}
/** Photo avatar or null; never throws, never touches the network on failure loops. */
static Bitmap photo(Context context, WidgetStore.Member member) {
if (member == null || member.image == null || !member.image.startsWith("http")) {
return null;
}
try {
File dir = new File(context.getCacheDir(), DIR);
if (!dir.exists()) dir.mkdirs();
File file = new File(dir, member.id + ".png");
if (file.exists()
&& System.currentTimeMillis() - file.lastModified() < MAX_AGE_MS) {
return BitmapFactory.decodeFile(file.getAbsolutePath());
}
Bitmap downloaded = download(member.image);
if (downloaded == null) {
// Keep serving a stale copy rather than nothing.
return file.exists() ? BitmapFactory.decodeFile(file.getAbsolutePath()) : null;
}
Bitmap circled = circleCrop(downloaded);
try (FileOutputStream out = new FileOutputStream(file)) {
circled.compress(Bitmap.CompressFormat.PNG, 100, out);
}
return circled;
} catch (Exception e) {
Log.w(TAG, "Avatar load failed for member " + member.id, e);
return null;
}
}
/** Colored disc with the member's first initial — the no-photo fallback. */
static Bitmap initials(WidgetStore.Member member) {
String name = member != null && member.name != null ? member.name.trim() : "";
String letter = name.isEmpty()
? "?"
: new String(Character.toChars(name.codePointAt(0))).toUpperCase(Locale.getDefault());
int color = PALETTE[Math.abs((member != null ? member.id : "?").hashCode()) % PALETTE.length];
Bitmap bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
text.setColor(Color.WHITE);
text.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
text.setTextSize(SIZE_PX * 0.42f);
text.setTextAlign(Paint.Align.CENTER);
Rect bounds = new Rect();
text.getTextBounds(letter, 0, letter.length(), bounds);
canvas.drawText(letter, SIZE_PX / 2f, SIZE_PX / 2f + bounds.height() / 2f, text);
return bitmap;
}
/** Best avatar for a member: photo when available, initials otherwise. */
static Bitmap get(Context context, WidgetStore.Member member) {
Bitmap photo = photo(context, member);
return photo != null ? photo : initials(member);
}
private static Bitmap download(String imageUrl) {
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(imageUrl).openConnection();
connection.setConnectTimeout(8000);
connection.setReadTimeout(10000);
if (connection.getResponseCode() != 200) return null;
try (InputStream stream = connection.getInputStream()) {
Bitmap raw = BitmapFactory.decodeStream(stream);
if (raw == null) return null;
return Bitmap.createScaledBitmap(raw, SIZE_PX, SIZE_PX, true);
}
} catch (Exception e) {
Log.w(TAG, "Avatar download failed", e);
return null;
} finally {
if (connection != null) connection.disconnect();
}
}
private static Bitmap circleCrop(Bitmap source) {
Bitmap output = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
return output;
}
}

View File

@@ -0,0 +1,20 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "People" home-screen widget: every circle member with their task load. */
public class PeopleWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_PEOPLE, id));
}
// Reload rows; the factory refreshes from the server when stale, which
// is what keeps the widget current while the app stays closed.
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
}

View File

@@ -0,0 +1,27 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "Today" home-screen widget: tasks due today plus anything awaiting approval. */
public class TodayWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_TODAY, id));
}
// Reload rows; the factory refreshes from the server when stale, which
// is what keeps the widget current while the app stays closed.
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
for (int id : appWidgetIds) {
WidgetStore.removeWidgetOptions(context, id);
}
}
}

View File

@@ -0,0 +1,25 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "Next 7 Days" home-screen widget: upcoming tasks grouped by day. */
public class WeekWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_WEEK, id));
}
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
for (int id : appWidgetIds) {
WidgetStore.removeWidgetOptions(context, id);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.donetick.app.widget;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* JS bridge for the home-screen widgets (see src/service/WidgetService.js).
* Persists the task snapshot + API config and redraws the widgets.
*/
@CapacitorPlugin(name = "WidgetBridge")
public class WidgetBridgePlugin extends Plugin {
@PluginMethod
public void update(PluginCall call) {
String data = call.getString("data");
String config = call.getString("config");
if (data != null) WidgetStore.saveData(getContext(), data);
if (config != null) WidgetStore.saveConfig(getContext(), config);
WidgetUi.refreshAll(getContext());
call.resolve();
}
@PluginMethod
public void clear(PluginCall call) {
WidgetStore.clear(getContext());
WidgetUi.refreshAll(getContext());
call.resolve();
}
}

View File

@@ -0,0 +1,48 @@
package com.donetick.app.widget;
import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Switch;
import com.donetick.app.R;
/**
* Placement / long-press configuration for the Today and Next 7 Days widgets.
* One option for now: include tasks assigned to other circle members
* (stored per appWidgetId so mixed setups work side by side).
*/
public class WidgetConfigActivity extends Activity {
private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
appWidgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
// Cancelled result until Save, so backing out never adds a half-configured widget.
setResult(RESULT_CANCELED, resultIntent());
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish();
return;
}
setContentView(R.layout.widget_config);
Switch includeOthers = findViewById(R.id.config_include_others);
includeOthers.setChecked(WidgetStore.includeOthers(this, appWidgetId));
findViewById(R.id.config_save).setOnClickListener(v -> {
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);
}
}

View File

@@ -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<Row> rows = new ArrayList<>();
private final Map<String, Bitmap> 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<WidgetStore.Task> allTasks = WidgetStore.loadTasks(context);
List<WidgetStore.Member> members = WidgetStore.loadMembers(context);
if (WidgetUi.MODE_PEOPLE.equals(mode)) {
rows = buildPeopleRows(allTasks, members);
loadAvatars(members);
} else {
List<WidgetStore.Task> 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<WidgetStore.Member> members) {
avatars.clear();
for (WidgetStore.Member member : members) {
avatars.put(member.id, AvatarCache.get(context, member));
}
}
private List<Row> buildTodayRows(List<WidgetStore.Task> tasks) {
List<Row> result = new ArrayList<>();
for (WidgetStore.Task task : WidgetStore.todaySubset(tasks)) {
result.add(new Row(null, task, null));
}
return result;
}
private List<Row> buildWeekRows(List<WidgetStore.Task> tasks) {
List<Row> result = new ArrayList<>();
long startOfToday = WidgetStore.endOfDay(-1) + 1;
SimpleDateFormat dayFormat = new SimpleDateFormat("EEE, MMM d", Locale.getDefault());
List<WidgetStore.Task> approvals = new ArrayList<>();
List<WidgetStore.Task> 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<Row> buildPeopleRows(List<WidgetStore.Task> tasks,
List<WidgetStore.Member> members) {
List<Row> result = new ArrayList<>();
List<WidgetStore.Task> 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() {}
}
}

View File

@@ -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<Task> loadTasks(Context context) {
List<Task> 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<Member> loadMembers(Context context) {
List<Member> 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<Task> visibleTasks(Context context, List<Task> tasks, boolean includeOthers) {
if (includeOthers) return tasks;
String me = userId(context);
List<Task> 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<Task> todaySubset(List<Task> tasks) {
long endOfToday = endOfDay(0);
List<Task> 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<JSONObject> 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();
}
}

View File

@@ -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<WidgetStore.Task> 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/<id> 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);
}
}

View File

@@ -0,0 +1 @@
{"version":1,"sessions":{}}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_bg" />
<!-- Match the launcher's system-wide widget corner radius on Android 12+ -->
<corners android:radius="@android:dimen/system_app_widget_background_radius" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_accent">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_bg" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_accent_soft" />
<corners android:radius="10dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#FFFFFF" />
<size android:width="18dp" android:height="18dp" />
</shape>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_bg"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_config_title"
android:textColor="@color/widget_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<Switch
android:id="@+id/config_include_others"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:minHeight="48dp"
android:text="@string/widget_config_include_others"
android:textColor="@color/widget_text_primary"
android:textSize="16sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/widget_config_include_others_hint"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp" />
<Button
android:id="@+id/config_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/widget_accent"
android:text="@string/widget_config_save"
android:textColor="#FFFFFF" />
</LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/row_day"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:letterSpacing="0.08"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:textAllCaps="true"
android:textColor="@color/widget_text_secondary"
android:textSize="10sp"
android:textStyle="bold" />

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/person_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="44dp"
android:orientation="horizontal"
android:paddingTop="5dp"
android:paddingBottom="5dp">
<ImageView
android:id="@+id/person_avatar"
android:layout_width="28dp"
android:layout_height="28dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/person_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/person_counts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/row_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:id="@+id/row_ring"
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring" />
<TextView
android:id="@+id/row_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/row_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
<!-- Assignee avatar, shown only in "everyone" mode for others' tasks. -->
<ImageView
android:id="@+id/row_avatar"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="14dp"
android:paddingBottom="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/widget_count"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toStartOf="@+id/widget_add"
android:layout_centerVertical="true"
android:background="@drawable/widget_count_bg"
android:minWidth="22dp"
android:gravity="center"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<!-- Quick-add, shown on the Today widget only (deep links to the
in-app AddTaskModal). Gone by default so the other widgets keep
the count pill flush with the edge. -->
<ImageView
android:id="@+id/widget_add"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="6dp"
android:background="@drawable/widget_count_bg"
android:contentDescription="@string/widget_add_task"
android:padding="5dp"
android:src="@drawable/ic_widget_add"
android:visibility="gone" />
</RelativeLayout>
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
<ListView
android:id="@+id/widget_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:divider="@null"
android:dividerHeight="0dp"
android:listSelector="@android:color/transparent"
android:scrollbars="none" />
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:lineSpacingExtra="3dp"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_bg">#181C20</color>
<color name="widget_text_primary">#F0F4F8</color>
<color name="widget_text_secondary">#9FA6AD</color>
<color name="widget_accent">#4B9BE8</color>
<color name="widget_accent_soft">#12395F</color>
<color name="widget_overdue">#F09898</color>
<color name="widget_warning">#F3C896</color>
<color name="widget_ring_neutral">#555E68</color>
<color name="widget_divider">#252B31</color>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Home-screen widget palette (values-night overrides for dark mode) -->
<color name="widget_bg">#FFFFFF</color>
<color name="widget_text_primary">#171A1C</color>
<color name="widget_text_secondary">#6B7681</color>
<color name="widget_accent">#0B6BCB</color>
<color name="widget_accent_soft">#E3EFFB</color>
<color name="widget_overdue">#C41C1C</color>
<color name="widget_warning">#B26A00</color>
<color name="widget_ring_neutral">#B8C0C9</color>
<color name="widget_divider">#EDF1F5</color>
</resources>

View File

@@ -4,4 +4,32 @@
<string name="title_activity_main">Donetick</string>
<string name="package_name">com.donetick.app</string>
<string name="custom_url_scheme">com.donetick.app</string>
<!-- Home-screen widgets -->
<string name="widget_today_label">Today</string>
<string name="widget_week_label">Next 7 Days</string>
<string name="widget_today_description">Your tasks due today, plus anything waiting on you.</string>
<string name="widget_week_description">Your tasks for the next 7 days, grouped by day.</string>
<string name="widget_today_title">Today</string>
<string name="widget_week_title">Next 7 days</string>
<string name="widget_empty_today">All caught up!\nNothing due today</string>
<string name="widget_empty_week">All caught up!\nNothing due this week</string>
<string name="widget_signed_out">Open Donetick and sign in\nto see your tasks</string>
<string name="widget_meta_overdue">Overdue</string>
<string name="widget_meta_approval">Approve</string>
<string name="widget_group_approval">Needs approval</string>
<string name="widget_group_overdue">Overdue</string>
<string name="widget_group_today">Today</string>
<string name="widget_group_tomorrow">Tomorrow</string>
<string name="widget_updated_at">Updated %s</string>
<string name="widget_add_task">Add task</string>
<string name="widget_people_label">People</string>
<string name="widget_people_description">Everyone in your circle with their tasks for today and the week ahead.</string>
<string name="widget_people_title">People</string>
<string name="widget_empty_people">No circle members yet</string>
<string name="widget_person_counts">%1$d today · %2$d this week</string>
<string name="widget_config_title">Widget options</string>
<string name="widget_config_include_others">Show everyone\'s tasks</string>
<string name="widget_config_include_others_hint">Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.</string>
<string name="widget_config_save">Save</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_people_description"
android:initialLayout="@layout/widget_shell"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_today_description"
android:initialLayout="@layout/widget_shell"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:configure="com.donetick.app.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_week_description"
android:initialLayout="@layout/widget_shell"
android:minWidth="250dp"
android:minHeight="180dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="3"
android:updatePeriodMillis="1800000"
android:configure="com.donetick.app.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />

View File

@@ -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)",
},
},
)

View File

@@ -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 = "<group>"; };
D1115B4A2C659D51004C6043 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = "<group>"; };
D0AC000000000000000000A2 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
D0AC000000000000000000A3 /* DonetickWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonetickWidget.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D0AC000000000000000000A6 /* DonetickWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DonetickWidget.entitlements; sourceTree = "<group>"; };
/* 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 = "<group>";
};
D0AC000000000000000000C1 /* DonetickWidget */ = {
isa = PBXGroup;
children = (
D0AC000000000000000000A3 /* DonetickWidget.swift */,
D0AC000000000000000000A5 /* Info.plist */,
D0AC000000000000000000A6 /* DonetickWidget.entitlements */,
);
path = DonetickWidget;
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -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 */;

View File

@@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.donetick.app</string>
</array>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>

View File

@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="MainViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.donetick.app</string>
</array>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DoneTick Widget</string>
<string>Donetick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View File

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

View File

@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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