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:
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal file
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal 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);
|
||||
}
|
||||
}
|
||||
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"sessions":{}}
|
||||
@@ -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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal file
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/widget_background.xml
Normal file
5
android/app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal file
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal 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>
|
||||
6
android/app/src/main/res/drawable/widget_ring.xml
Normal file
6
android/app/src/main/res/drawable/widget_ring.xml
Normal 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>
|
||||
43
android/app/src/main/res/layout/widget_config.xml
Normal file
43
android/app/src/main/res/layout/widget_config.xml
Normal 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>
|
||||
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal file
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal 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" />
|
||||
37
android/app/src/main/res/layout/widget_row_person.xml
Normal file
37
android/app/src/main/res/layout/widget_row_person.xml
Normal 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>
|
||||
46
android/app/src/main/res/layout/widget_row_task.xml
Normal file
46
android/app/src/main/res/layout/widget_row_task.xml
Normal 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>
|
||||
93
android/app/src/main/res/layout/widget_shell.xml
Normal file
93
android/app/src/main/res/layout/widget_shell.xml
Normal 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>
|
||||
12
android/app/src/main/res/values-night/colors.xml
Normal file
12
android/app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
13
android/app/src/main/res/values/colors.xml
Normal file
13
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
13
android/app/src/main/res/xml/widget_people_info.xml
Normal file
13
android/app/src/main/res/xml/widget_people_info.xml
Normal 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" />
|
||||
15
android/app/src/main/res/xml/widget_today_info.xml
Normal file
15
android/app/src/main/res/xml/widget_today_info.xml
Normal 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" />
|
||||
15
android/app/src/main/res/xml/widget_week_info.xml
Normal file
15
android/app/src/main/res/xml/widget_week_info.xml
Normal 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" />
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 */;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
ios/App/App/MainViewController.swift
Normal file
9
ios/App/App/MainViewController.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
46
ios/App/App/WidgetBridgePlugin.swift
Normal file
46
ios/App/App/WidgetBridgePlugin.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal file
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal 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>
|
||||
1007
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
1007
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
src/service/WidgetService.js
Normal file
145
src/service/WidgetService.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user