Add support for iOS and android widgets
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" />
|
||||
Reference in New Issue
Block a user