diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4846b6f..5e9dc43 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + { + WidgetStore.setIncludeOthers(this, appWidgetId, includeOthers.isChecked()); + WidgetUi.refreshAll(this); + setResult(RESULT_OK, resultIntent()); + finish(); + }); + } + + private Intent resultIntent() { + return new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + } +} diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java b/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java new file mode 100644 index 0000000..110762e --- /dev/null +++ b/android/app/src/main/java/com/donetick/app/widget/WidgetListService.java @@ -0,0 +1,291 @@ +package com.donetick.app.widget; + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import androidx.core.content.ContextCompat; + +import com.donetick.app.R; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Feeds task rows, day-group headers, and people rows to the widget ListView. */ +public class WidgetListService extends RemoteViewsService { + @Override + public RemoteViewsFactory onGetViewFactory(Intent intent) { + String mode = intent.getStringExtra(WidgetUi.EXTRA_MODE); + int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID); + return new WidgetListFactory(getApplicationContext(), + mode != null ? mode : WidgetUi.MODE_TODAY, appWidgetId); + } + + static class Row { + final String header; // non-null for day-group headers + final WidgetStore.Task task; // non-null for task rows + final WidgetStore.Member person; // non-null for people rows + int todayCount; + int weekCount; + + Row(String header, WidgetStore.Task task, WidgetStore.Member person) { + this.header = header; + this.task = task; + this.person = person; + } + } + + static class WidgetListFactory implements RemoteViewsFactory { + private final Context context; + private final String mode; + private final int appWidgetId; + private List rows = new ArrayList<>(); + private final Map avatars = new HashMap<>(); + private String myUserId; + private boolean includeOthers; + + WidgetListFactory(Context context, String mode, int appWidgetId) { + this.context = context; + this.mode = mode; + this.appWidgetId = appWidgetId; + } + + @Override + public void onCreate() {} + + @Override + public void onDataSetChanged() { + // Runs on a binder thread, so synchronous network work is allowed + // here. This is the background-refresh path: the 30-minute + // updatePeriodMillis cycle lands here via notifyAppWidgetViewDataChanged. + boolean refreshed = WidgetStore.refreshFromServerIfStale(context); + + myUserId = WidgetStore.userId(context); + includeOthers = WidgetStore.includeOthers(context, appWidgetId); + List allTasks = WidgetStore.loadTasks(context); + List members = WidgetStore.loadMembers(context); + + if (WidgetUi.MODE_PEOPLE.equals(mode)) { + rows = buildPeopleRows(allTasks, members); + loadAvatars(members); + } else { + List tasks = + WidgetStore.visibleTasks(context, allTasks, includeOthers); + rows = WidgetUi.MODE_TODAY.equals(mode) + ? buildTodayRows(tasks) + : buildWeekRows(tasks); + if (includeOthers) loadAvatars(members); + } + + if (refreshed) { + // Counts and "Updated …" line live outside the list. + WidgetUi.updateHeaders(context); + } + } + + /** Resolve avatars for everyone up front — getViewAt must not block. */ + private void loadAvatars(List members) { + avatars.clear(); + for (WidgetStore.Member member : members) { + avatars.put(member.id, AvatarCache.get(context, member)); + } + } + + private List buildTodayRows(List tasks) { + List result = new ArrayList<>(); + for (WidgetStore.Task task : WidgetStore.todaySubset(tasks)) { + result.add(new Row(null, task, null)); + } + return result; + } + + private List buildWeekRows(List tasks) { + List result = new ArrayList<>(); + long startOfToday = WidgetStore.endOfDay(-1) + 1; + SimpleDateFormat dayFormat = new SimpleDateFormat("EEE, MMM d", Locale.getDefault()); + + List approvals = new ArrayList<>(); + List scheduled = new ArrayList<>(); + for (WidgetStore.Task task : tasks) { + if (task.approval) approvals.add(task); + else if (task.dueDate != null) scheduled.add(task); + } + + if (!approvals.isEmpty()) { + result.add(new Row(context.getString(R.string.widget_group_approval), null, null)); + for (WidgetStore.Task task : approvals) result.add(new Row(null, task, null)); + } + + String currentGroup = null; + for (WidgetStore.Task task : scheduled) { + String group; + if (task.dueDate < startOfToday) { + group = context.getString(R.string.widget_group_overdue); + } else if (task.dueDate <= WidgetStore.endOfDay(0)) { + group = context.getString(R.string.widget_group_today); + } else if (task.dueDate <= WidgetStore.endOfDay(1)) { + group = context.getString(R.string.widget_group_tomorrow); + } else { + group = dayFormat.format(new Date(task.dueDate)); + } + if (!group.equals(currentGroup)) { + result.add(new Row(group, null, null)); + currentGroup = group; + } + result.add(new Row(null, task, null)); + } + return result; + } + + /** One row per member with today/week workloads, busiest first. */ + private List buildPeopleRows(List tasks, + List members) { + List result = new ArrayList<>(); + List todayTasks = WidgetStore.todaySubset(tasks); + for (WidgetStore.Member member : members) { + Row row = new Row(null, null, member); + for (WidgetStore.Task task : tasks) { + if (member.id.equals(task.assignedTo)) row.weekCount++; + } + for (WidgetStore.Task task : todayTasks) { + if (member.id.equals(task.assignedTo)) row.todayCount++; + } + result.add(row); + } + java.util.Collections.sort(result, (a, b) -> { + if (a.todayCount != b.todayCount) return b.todayCount - a.todayCount; + if (a.weekCount != b.weekCount) return b.weekCount - a.weekCount; + return a.person.name.compareToIgnoreCase(b.person.name); + }); + return result; + } + + @Override + public RemoteViews getViewAt(int position) { + if (position < 0 || position >= rows.size()) return null; + Row row = rows.get(position); + + if (row.header != null) { + RemoteViews views = new RemoteViews(context.getPackageName(), + R.layout.widget_row_day_header); + views.setTextViewText(R.id.row_day, row.header); + return views; + } + + if (row.person != null) { + return buildPersonRow(row); + } + + WidgetStore.Task task = row.task; + RemoteViews views = new RemoteViews(context.getPackageName(), + R.layout.widget_row_task); + views.setTextViewText(R.id.row_title, task.name); + + boolean overdue = !task.approval && task.dueDate != null + && task.dueDate < System.currentTimeMillis(); + int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary); + int warning = ContextCompat.getColor(context, R.color.widget_warning); + int danger = ContextCompat.getColor(context, R.color.widget_overdue); + + String meta; + int metaColor; + if (task.approval) { + meta = context.getString(R.string.widget_meta_approval); + metaColor = warning; + } else if (overdue) { + meta = context.getString(R.string.widget_meta_overdue); + metaColor = danger; + } else { + meta = android.text.format.DateFormat.getTimeFormat(context) + .format(new Date(task.dueDate)); + metaColor = secondary; + } + views.setTextViewText(R.id.row_meta, meta); + views.setTextColor(R.id.row_meta, metaColor); + + int ringColor; + if (task.approval) ringColor = warning; + else if (overdue || task.priority == 1) ringColor = danger; + else if (task.priority == 2) ringColor = warning; + else ringColor = ContextCompat.getColor(context, R.color.widget_ring_neutral); + views.setInt(R.id.row_ring, "setColorFilter", ringColor); + + // In "everyone" mode, show who a task belongs to (own tasks stay clean). + Bitmap avatar = includeOthers && task.assignedTo != null + && !task.assignedTo.equals(myUserId) + ? avatars.get(task.assignedTo) + : null; + if (avatar != null) { + views.setImageViewBitmap(R.id.row_avatar, avatar); + views.setViewVisibility(R.id.row_avatar, View.VISIBLE); + } else { + views.setViewVisibility(R.id.row_avatar, View.GONE); + } + + Intent fillIn = new Intent(); + fillIn.setData(Uri.parse("donetick://chores/" + task.id)); + views.setOnClickFillInIntent(R.id.row_root, fillIn); + return views; + } + + private RemoteViews buildPersonRow(Row row) { + RemoteViews views = new RemoteViews(context.getPackageName(), + R.layout.widget_row_person); + Bitmap avatar = avatars.get(row.person.id); + if (avatar == null) avatar = AvatarCache.initials(row.person); + views.setImageViewBitmap(R.id.person_avatar, avatar); + views.setTextViewText(R.id.person_name, row.person.name); + views.setTextViewText(R.id.person_counts, context.getString( + R.string.widget_person_counts, row.todayCount, row.weekCount)); + + // Highlight the today-count when someone has work due today. + int accent = ContextCompat.getColor(context, R.color.widget_accent); + int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary); + views.setTextColor(R.id.person_counts, row.todayCount > 0 ? accent : secondary); + + Intent fillIn = new Intent(); + fillIn.setData(Uri.parse("donetick://chores")); + views.setOnClickFillInIntent(R.id.person_root, fillIn); + return views; + } + + @Override + public RemoteViews getLoadingView() { + return null; + } + + @Override + public int getViewTypeCount() { + return 3; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public int getCount() { + return rows.size(); + } + + @Override + public void onDestroy() {} + } +} diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java b/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java new file mode 100644 index 0000000..14a4124 --- /dev/null +++ b/android/app/src/main/java/com/donetick/app/widget/WidgetStore.java @@ -0,0 +1,346 @@ +package com.donetick.app.widget; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.List; + +/** + * Shared storage + refresh logic for the home-screen widgets. + * + * The web app pushes a pre-filtered snapshot through WidgetBridgePlugin + * whenever its chores cache changes. When the app has not run for a while, + * {@link #refreshFromServerIfStale} re-fetches the list directly so the + * widget stays current in the background (driven by the 30-minute + * updatePeriodMillis cycle). + * + * Snapshot JSON: {version, lastUpdated, + * tasks:[{id, name, dueDate, priority, approval, assignedTo}], + * members:[{id, name, image}]} + * Config JSON: {serverUrl, token, userId} + * + * Since v2 the snapshot holds every member's tasks; widgets narrow it down to + * the current user unless the per-widget "include others" option is on. + */ +public final class WidgetStore { + private static final String TAG = "DonetickWidget"; + private static final String PREFS = "donetick_widget"; + private static final String KEY_DATA = "widget_tasks"; + private static final String KEY_CONFIG = "widget_config"; + private static final String KEY_INCLUDE_OTHERS_PREFIX = "include_others_"; + + // Same filtering window as src/service/WidgetService.js + private static final int WINDOW_DAYS = 7; + private static final int MAX_TASKS = 100; + // Don't hit the network if the app (or a previous refresh) updated the + // snapshot recently; also guards against notify->refresh loops. + private static final long STALE_MS = 10 * 60 * 1000; + + private static final Object REFRESH_LOCK = new Object(); + + private WidgetStore() {} + + public static class Task { + public String id; + public String name; + public Long dueDate; // epoch millis, null when unscheduled + public int priority; + public boolean approval; + public String assignedTo; // member userId, null when unassigned + } + + public static class Member { + public String id; + public String name; + public String image; // avatar URL, may be null + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + public static void saveData(Context context, String json) { + prefs(context).edit().putString(KEY_DATA, json).apply(); + } + + public static void saveConfig(Context context, String json) { + prefs(context).edit().putString(KEY_CONFIG, json).apply(); + } + + public static void clear(Context context) { + prefs(context).edit().clear().apply(); + } + + public static boolean hasConfig(Context context) { + return prefs(context).getString(KEY_CONFIG, null) != null; + } + + /** The signed-in user's id from the pushed config, or null. */ + public static String userId(Context context) { + try { + String raw = prefs(context).getString(KEY_CONFIG, null); + if (raw == null) return null; + Object id = new JSONObject(raw).opt("userId"); + return id == null ? null : String.valueOf(id); + } catch (Exception e) { + return null; + } + } + + /** Per-widget "include tasks assigned to others" option (default off). */ + public static boolean includeOthers(Context context, int appWidgetId) { + return prefs(context).getBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, false); + } + + public static void setIncludeOthers(Context context, int appWidgetId, boolean value) { + prefs(context).edit() + .putBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, value) + .apply(); + } + + public static void removeWidgetOptions(Context context, int appWidgetId) { + prefs(context).edit() + .remove(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId) + .apply(); + } + + public static long lastUpdated(Context context) { + try { + String raw = prefs(context).getString(KEY_DATA, null); + if (raw == null) return 0; + return new JSONObject(raw).optLong("lastUpdated", 0); + } catch (Exception e) { + return 0; + } + } + + public static List loadTasks(Context context) { + List tasks = new ArrayList<>(); + try { + String raw = prefs(context).getString(KEY_DATA, null); + if (raw == null) return tasks; + JSONArray arr = new JSONObject(raw).optJSONArray("tasks"); + if (arr == null) return tasks; + for (int i = 0; i < arr.length(); i++) { + JSONObject obj = arr.optJSONObject(i); + if (obj == null) continue; + Task task = new Task(); + task.id = String.valueOf(obj.opt("id")); + task.name = obj.optString("name", ""); + task.dueDate = obj.isNull("dueDate") ? null : obj.optLong("dueDate"); + task.priority = obj.optInt("priority", 0); + task.approval = obj.optBoolean("approval", false); + // v1 snapshots carried only the user's own tasks and had no + // assignedTo — treat those rows as "mine". + task.assignedTo = obj.has("assignedTo") + ? (obj.isNull("assignedTo") ? null : String.valueOf(obj.opt("assignedTo"))) + : userId(context); + tasks.add(task); + } + } catch (Exception e) { + Log.e(TAG, "Failed to parse widget snapshot", e); + } + return tasks; + } + + public static List loadMembers(Context context) { + List members = new ArrayList<>(); + try { + String raw = prefs(context).getString(KEY_DATA, null); + if (raw == null) return members; + JSONArray arr = new JSONObject(raw).optJSONArray("members"); + if (arr == null) return members; + for (int i = 0; i < arr.length(); i++) { + JSONObject obj = arr.optJSONObject(i); + if (obj == null) continue; + Member member = new Member(); + member.id = String.valueOf(obj.opt("id")); + member.name = obj.optString("name", ""); + member.image = obj.isNull("image") ? null : obj.optString("image", null); + members.add(member); + } + } catch (Exception e) { + Log.e(TAG, "Failed to parse widget members", e); + } + return members; + } + + /** + * Tasks a today/week widget should render: everything when includeOthers, + * otherwise the user's own tasks plus approvals (which wait on them). + */ + public static List visibleTasks(Context context, List tasks, boolean includeOthers) { + if (includeOthers) return tasks; + String me = userId(context); + List mine = new ArrayList<>(); + for (Task task : tasks) { + if (task.approval || (me != null && me.equals(task.assignedTo))) { + mine.add(task); + } + } + return mine; + } + + /** Tasks the Today widget shows: awaiting approval, overdue, or due today. */ + public static List todaySubset(List tasks) { + long endOfToday = endOfDay(0); + List subset = new ArrayList<>(); + for (Task task : tasks) { + if (task.approval || (task.dueDate != null && task.dueDate <= endOfToday)) { + subset.add(task); + } + } + return subset; + } + + public static long endOfDay(int daysFromNow) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_YEAR, daysFromNow); + cal.set(Calendar.HOUR_OF_DAY, 23); + cal.set(Calendar.MINUTE, 59); + cal.set(Calendar.SECOND, 59); + cal.set(Calendar.MILLISECOND, 999); + return cal.getTimeInMillis(); + } + + /** + * Fetch /chores/ from the configured server and rebuild the snapshot. + * Returns true when a network refresh actually happened and succeeded. + * Safe to call from RemoteViewsFactory.onDataSetChanged (binder thread). + */ + public static boolean refreshFromServerIfStale(Context context) { + synchronized (REFRESH_LOCK) { + long age = System.currentTimeMillis() - lastUpdated(context); + if (age < STALE_MS) return false; + + String rawConfig = prefs(context).getString(KEY_CONFIG, null); + if (rawConfig == null) return false; + + HttpURLConnection connection = null; + try { + JSONObject config = new JSONObject(rawConfig); + String serverUrl = config.optString("serverUrl", ""); + String token = config.optString("token", ""); + if (serverUrl.isEmpty() || token.isEmpty()) return false; + + URL url = new URL(serverUrl + "/chores/"); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(15000); + connection.setRequestProperty("Authorization", "Bearer " + token); + connection.setRequestProperty("Accept", "application/json"); + + if (connection.getResponseCode() != 200) { + // Expired token or server trouble — keep the last snapshot, + // the UI surfaces staleness via the "Updated …" line. + Log.w(TAG, "Widget refresh got HTTP " + connection.getResponseCode()); + return false; + } + + JSONArray chores = new JSONObject(readAll(connection.getInputStream())) + .optJSONArray("res"); + if (chores == null) return false; + + // The chores endpoint has no member profiles, so carry the + // member list over from the previous snapshot (it changes + // rarely and the app re-pushes it on every open). + JSONArray members = null; + String previous = prefs(context).getString(KEY_DATA, null); + if (previous != null) { + members = new JSONObject(previous).optJSONArray("members"); + } + + JSONObject snapshot = new JSONObject(); + snapshot.put("version", 2); + snapshot.put("lastUpdated", System.currentTimeMillis()); + snapshot.put("tasks", filterChores(chores)); + snapshot.put("members", members == null ? new JSONArray() : members); + saveData(context, snapshot.toString()); + return true; + } catch (Exception e) { + Log.w(TAG, "Widget background refresh failed", e); + return false; + } finally { + if (connection != null) connection.disconnect(); + } + } + } + + /** Mirror of buildWidgetTasks in src/service/WidgetService.js. */ + private static JSONArray filterChores(JSONArray chores) throws Exception { + long cutoff = endOfDay(WINDOW_DAYS); + List selected = new ArrayList<>(); + + for (int i = 0; i < chores.length(); i++) { + JSONObject chore = chores.optJSONObject(i); + if (chore == null || chore.opt("id") == null) continue; + + boolean approval = chore.optInt("status", 0) == 3; + Long dueDate = parseDate(chore.optString("nextDueDate", null)); + boolean inWindow = dueDate != null && dueDate <= cutoff; + if (!approval && !inWindow) continue; + + JSONObject task = new JSONObject(); + task.put("id", chore.opt("id")); + task.put("name", chore.optString("name", "")); + task.put("dueDate", dueDate == null ? JSONObject.NULL : dueDate); + task.put("priority", chore.optInt("priority", 0)); + task.put("approval", approval); + task.put("assignedTo", chore.isNull("assignedTo") + ? JSONObject.NULL + : String.valueOf(chore.opt("assignedTo"))); + selected.add(task); + } + + Collections.sort(selected, (a, b) -> { + boolean aApproval = a.optBoolean("approval"); + boolean bApproval = b.optBoolean("approval"); + if (aApproval != bApproval) return aApproval ? -1 : 1; + long aDue = a.isNull("dueDate") ? Long.MAX_VALUE : a.optLong("dueDate"); + long bDue = b.isNull("dueDate") ? Long.MAX_VALUE : b.optLong("dueDate"); + return Long.compare(aDue, bDue); + }); + + JSONArray result = new JSONArray(); + for (int i = 0; i < selected.size() && i < MAX_TASKS; i++) { + result.put(selected.get(i)); + } + return result; + } + + private static Long parseDate(String value) { + if (value == null || value.isEmpty() || "null".equals(value)) return null; + try { + return OffsetDateTime.parse(value).toInstant().toEpochMilli(); + } catch (Exception e) { + return null; + } + } + + private static String readAll(InputStream stream) throws Exception { + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + return builder.toString(); + } +} diff --git a/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java b/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java new file mode 100644 index 0000000..612abea --- /dev/null +++ b/android/app/src/main/java/com/donetick/app/widget/WidgetUi.java @@ -0,0 +1,141 @@ +package com.donetick.app.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.view.View; +import android.widget.RemoteViews; + +import com.donetick.app.MainActivity; +import com.donetick.app.R; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** Builds the shell RemoteViews shared by the widgets and pushes updates. */ +public final class WidgetUi { + public static final String MODE_TODAY = "today"; + public static final String MODE_WEEK = "week"; + public static final String MODE_PEOPLE = "people"; + public static final String EXTRA_MODE = "com.donetick.app.widget.MODE"; + + private WidgetUi() {} + + /** Full refresh: redraw headers and reload list content (used by the JS bridge). */ + public static void refreshAll(Context context) { + updateHeaders(context); + AppWidgetManager manager = AppWidgetManager.getInstance(context); + manager.notifyAppWidgetViewDataChanged( + widgetIds(context, manager, TodayWidgetProvider.class), R.id.widget_list); + manager.notifyAppWidgetViewDataChanged( + widgetIds(context, manager, WeekWidgetProvider.class), R.id.widget_list); + manager.notifyAppWidgetViewDataChanged( + widgetIds(context, manager, PeopleWidgetProvider.class), R.id.widget_list); + } + + /** Redraw title/count/subtitle only — safe to call from the list factory. */ + public static void updateHeaders(Context context) { + AppWidgetManager manager = AppWidgetManager.getInstance(context); + for (int id : widgetIds(context, manager, TodayWidgetProvider.class)) { + manager.updateAppWidget(id, build(context, MODE_TODAY, id)); + } + for (int id : widgetIds(context, manager, WeekWidgetProvider.class)) { + manager.updateAppWidget(id, build(context, MODE_WEEK, id)); + } + for (int id : widgetIds(context, manager, PeopleWidgetProvider.class)) { + manager.updateAppWidget(id, build(context, MODE_PEOPLE, id)); + } + } + + private static int[] widgetIds(Context context, AppWidgetManager manager, Class provider) { + return manager.getAppWidgetIds(new ComponentName(context, provider)); + } + + public static RemoteViews build(Context context, String mode, int appWidgetId) { + boolean today = MODE_TODAY.equals(mode); + boolean people = MODE_PEOPLE.equals(mode); + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_shell); + + int titleRes = today ? R.string.widget_today_title + : people ? R.string.widget_people_title + : R.string.widget_week_title; + views.setTextViewText(R.id.widget_title, context.getString(titleRes)); + + if (people) { + views.setViewVisibility(R.id.widget_count, View.GONE); + } else { + boolean includeOthers = WidgetStore.includeOthers(context, appWidgetId); + List tasks = WidgetStore.visibleTasks( + context, WidgetStore.loadTasks(context), includeOthers); + int count = (today ? WidgetStore.todaySubset(tasks) : tasks).size(); + views.setTextViewText(R.id.widget_count, String.valueOf(count)); + views.setViewVisibility(R.id.widget_count, count > 0 ? View.VISIBLE : View.GONE); + } + + views.setTextViewText(R.id.widget_subtitle, subtitle(context)); + + if (!WidgetStore.hasConfig(context)) { + views.setTextViewText(R.id.widget_empty, + context.getString(R.string.widget_signed_out)); + } else { + int emptyRes = today ? R.string.widget_empty_today + : people ? R.string.widget_empty_people + : R.string.widget_empty_week; + views.setTextViewText(R.id.widget_empty, context.getString(emptyRes)); + } + + // The Today widget gets a quick-add button that deep links straight + // into the in-app AddTaskModal. + if (today) { + Intent addTask = new Intent(context, MainActivity.class); + addTask.setAction(Intent.ACTION_VIEW); + addTask.setData(Uri.parse("donetick://chores/add")); + views.setOnClickPendingIntent(R.id.widget_add, PendingIntent.getActivity( + context, 2, addTask, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + views.setViewVisibility(R.id.widget_add, View.VISIBLE); + } else { + views.setViewVisibility(R.id.widget_add, View.GONE); + } + + // List content comes from WidgetListService; mode travels in the intent + // and the unique data URI keeps the adapters from being collapsed. + Intent adapterIntent = new Intent(context, WidgetListService.class); + adapterIntent.putExtra(EXTRA_MODE, mode); + adapterIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + adapterIntent.setData(Uri.parse("donetickwidget://" + mode + "/" + appWidgetId)); + views.setRemoteAdapter(R.id.widget_list, adapterIntent); + views.setEmptyView(R.id.widget_list, R.id.widget_empty); + + // Row taps deep link into the chore view; the row's fill-in intent + // supplies the donetick://chores/ data URI. + Intent rowTemplate = new Intent(context, MainActivity.class); + rowTemplate.setAction(Intent.ACTION_VIEW); + views.setPendingIntentTemplate(R.id.widget_list, PendingIntent.getActivity( + context, 1, rowTemplate, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE)); + + // Anywhere else on the widget simply opens the app. + Intent openApp = new Intent(context, MainActivity.class); + views.setOnClickPendingIntent(R.id.widget_container, PendingIntent.getActivity( + context, 0, openApp, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + + return views; + } + + private static String subtitle(Context context) { + String date = new SimpleDateFormat("EEE, MMM d", Locale.getDefault()) + .format(new Date()); + long lastUpdated = WidgetStore.lastUpdated(context); + if (lastUpdated <= 0) return date; + String time = android.text.format.DateFormat.getTimeFormat(context) + .format(new Date(lastUpdated)); + return date + " · " + context.getString(R.string.widget_updated_at, time); + } +} diff --git a/android/app/src/main/res/.impeccable/hook.cache.json b/android/app/src/main/res/.impeccable/hook.cache.json new file mode 100644 index 0000000..ebc7c5d --- /dev/null +++ b/android/app/src/main/res/.impeccable/hook.cache.json @@ -0,0 +1 @@ +{"version":1,"sessions":{}} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-v31/widget_background.xml b/android/app/src/main/res/drawable-v31/widget_background.xml new file mode 100644 index 0000000..b4e2a53 --- /dev/null +++ b/android/app/src/main/res/drawable-v31/widget_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_add.xml b/android/app/src/main/res/drawable/ic_widget_add.xml new file mode 100644 index 0000000..5bd0032 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_add.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..adc0fad --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_count_bg.xml b/android/app/src/main/res/drawable/widget_count_bg.xml new file mode 100644 index 0000000..ebd82cc --- /dev/null +++ b/android/app/src/main/res/drawable/widget_count_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_ring.xml b/android/app/src/main/res/drawable/widget_ring.xml new file mode 100644 index 0000000..87d344a --- /dev/null +++ b/android/app/src/main/res/drawable/widget_ring.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/layout/widget_config.xml b/android/app/src/main/res/layout/widget_config.xml new file mode 100644 index 0000000..329bbf8 --- /dev/null +++ b/android/app/src/main/res/layout/widget_config.xml @@ -0,0 +1,43 @@ + + + + + + + + + +