diff --git a/taskview-packages/capacitor-widget-bridge/.gitignore b/taskview-packages/capacitor-widget-bridge/.gitignore
new file mode 100644
index 0000000..259d2b7
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/.gitignore
@@ -0,0 +1,6 @@
+android/build/
+android/.gradle/
+android/local.properties
+.swiftpm/
+ios/.build/
+DerivedData/
diff --git a/taskview-packages/capacitor-widget-bridge/Package.swift b/taskview-packages/capacitor-widget-bridge/Package.swift
new file mode 100644
index 0000000..1b1d17b
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/Package.swift
@@ -0,0 +1,24 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "CapacitorWidgetBridge",
+ platforms: [.iOS(.v15)],
+ products: [
+ .library(
+ name: "CapacitorWidgetBridge",
+ targets: ["WidgetBridgePlugin"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
+ ],
+ targets: [
+ .target(
+ name: "WidgetBridgePlugin",
+ dependencies: [
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
+ .product(name: "Cordova", package: "capacitor-swift-pm")
+ ],
+ path: "ios/Sources/WidgetBridgePlugin")
+ ]
+)
diff --git a/taskview-packages/capacitor-widget-bridge/android/build.gradle b/taskview-packages/capacitor-widget-bridge/android/build.gradle
new file mode 100644
index 0000000..aa19f4b
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/android/build.gradle
@@ -0,0 +1,51 @@
+ext {
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.13.0'
+ }
+}
+
+apply plugin: 'com.android.library'
+
+android {
+ namespace = "tech.taskview.plugins.widgetbridge"
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
+ defaultConfig {
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
+ versionCode 1
+ versionName "1.0"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ lintOptions {
+ abortOnError = false
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_21
+ targetCompatibility JavaVersion.VERSION_21
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation project(':capacitor-android')
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+}
diff --git a/taskview-packages/capacitor-widget-bridge/android/src/main/AndroidManifest.xml b/taskview-packages/capacitor-widget-bridge/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b2d3ea1
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/taskview-packages/capacitor-widget-bridge/android/src/main/java/tech/taskview/plugins/widgetbridge/WidgetBridgePlugin.java b/taskview-packages/capacitor-widget-bridge/android/src/main/java/tech/taskview/plugins/widgetbridge/WidgetBridgePlugin.java
new file mode 100644
index 0000000..72e4580
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/android/src/main/java/tech/taskview/plugins/widgetbridge/WidgetBridgePlugin.java
@@ -0,0 +1,48 @@
+package tech.taskview.plugins.widgetbridge;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+@CapacitorPlugin(name = "WidgetBridge")
+public class WidgetBridgePlugin extends Plugin {
+
+ public static final String PREFS_NAME = "taskview_widget";
+ public static final String SNAPSHOT_KEY = "widgetSnapshot";
+ public static final String ACTION_WIDGET_UPDATE = "tech.taskview.widget.UPDATE";
+
+ @PluginMethod
+ public void setSnapshot(PluginCall call) {
+ String snapshot = call.getString("snapshot");
+ if (snapshot == null) {
+ call.reject("snapshot is required");
+ return;
+ }
+ prefs().edit().putString(SNAPSHOT_KEY, snapshot).apply();
+ notifyWidgets();
+ call.resolve();
+ }
+
+ @PluginMethod
+ public void clearSnapshot(PluginCall call) {
+ prefs().edit().remove(SNAPSHOT_KEY).apply();
+ notifyWidgets();
+ call.resolve();
+ }
+
+ private SharedPreferences prefs() {
+ return getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ private void notifyWidgets() {
+ Context context = getContext();
+ Intent intent = new Intent(ACTION_WIDGET_UPDATE);
+ intent.setPackage(context.getPackageName());
+ context.sendBroadcast(intent);
+ }
+}
diff --git a/taskview-packages/capacitor-widget-bridge/ios/Sources/WidgetBridgePlugin/WidgetBridgePlugin.swift b/taskview-packages/capacitor-widget-bridge/ios/Sources/WidgetBridgePlugin/WidgetBridgePlugin.swift
new file mode 100644
index 0000000..5f7c60b
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/ios/Sources/WidgetBridgePlugin/WidgetBridgePlugin.swift
@@ -0,0 +1,44 @@
+import Foundation
+import Capacitor
+import WidgetKit
+
+@objc(WidgetBridgePlugin)
+public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
+ public let identifier = "WidgetBridgePlugin"
+ public let jsName = "WidgetBridge"
+ public let pluginMethods: [CAPPluginMethod] = [
+ CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "clearSnapshot", returnType: CAPPluginReturnPromise)
+ ]
+
+ static let snapshotKey = "widgetSnapshot"
+
+ private var sharedDefaults: UserDefaults? {
+ guard let appGroup = getConfig().getString("appGroup") else { return nil }
+ return UserDefaults(suiteName: appGroup)
+ }
+
+ @objc func setSnapshot(_ call: CAPPluginCall) {
+ guard let snapshot = call.getString("snapshot") else {
+ call.reject("snapshot is required")
+ return
+ }
+ guard let defaults = sharedDefaults else {
+ call.reject("WidgetBridge appGroup is not configured in capacitor.config")
+ return
+ }
+ defaults.set(snapshot, forKey: Self.snapshotKey)
+ WidgetCenter.shared.reloadAllTimelines()
+ call.resolve()
+ }
+
+ @objc func clearSnapshot(_ call: CAPPluginCall) {
+ guard let defaults = sharedDefaults else {
+ call.reject("WidgetBridge appGroup is not configured in capacitor.config")
+ return
+ }
+ defaults.removeObject(forKey: Self.snapshotKey)
+ WidgetCenter.shared.reloadAllTimelines()
+ call.resolve()
+ }
+}
diff --git a/taskview-packages/capacitor-widget-bridge/package.json b/taskview-packages/capacitor-widget-bridge/package.json
new file mode 100644
index 0000000..fcb2e26
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "capacitor-widget-bridge",
+ "private": false,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Capacitor bridge that shares a data snapshot with native home-screen widgets (iOS WidgetKit / Android App Widgets)",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "ios",
+ "android",
+ "Package.swift"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "type-check": "tsc --noEmit"
+ },
+ "capacitor": {
+ "ios": {
+ "src": "ios"
+ },
+ "android": {
+ "src": "android"
+ }
+ },
+ "peerDependencies": {
+ "@capacitor/core": "^8.0.0"
+ },
+ "devDependencies": {
+ "@capacitor/core": "^8.1.0",
+ "typescript": "~5.8.3"
+ }
+}
diff --git a/taskview-packages/capacitor-widget-bridge/src/definitions.ts b/taskview-packages/capacitor-widget-bridge/src/definitions.ts
new file mode 100644
index 0000000..8d23965
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/src/definitions.ts
@@ -0,0 +1,32 @@
+export type WidgetSnapshotTask = {
+ id: number
+ title: string
+ priority: 1 | 2 | 3
+ overdue: boolean
+ endTime: string | null
+ endDate: string | null
+ path: string
+}
+
+export type WidgetSnapshotMode = 'today' | 'upcoming'
+
+export type WidgetSnapshot = {
+ v: 3
+ generatedAt: string
+ locale: string
+ orgSlug: string | null
+ mode: WidgetSnapshotMode
+ todayCount: number
+ overdueCount: number
+ upcomingCount: number
+ tasks: WidgetSnapshotTask[]
+}
+
+export type SetSnapshotOptions = {
+ snapshot: string
+}
+
+export type WidgetBridgePlugin = {
+ setSnapshot(options: SetSnapshotOptions): Promise
+ clearSnapshot(): Promise
+}
diff --git a/taskview-packages/capacitor-widget-bridge/src/index.ts b/taskview-packages/capacitor-widget-bridge/src/index.ts
new file mode 100644
index 0000000..084f3db
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/src/index.ts
@@ -0,0 +1,12 @@
+import { registerPlugin } from '@capacitor/core'
+import type { WidgetBridgePlugin } from './definitions'
+
+export const WidgetBridge = registerPlugin('WidgetBridge')
+
+export type {
+ WidgetBridgePlugin,
+ WidgetSnapshot,
+ WidgetSnapshotMode,
+ WidgetSnapshotTask,
+ SetSnapshotOptions,
+} from './definitions'
diff --git a/taskview-packages/capacitor-widget-bridge/tsconfig.json b/taskview-packages/capacitor-widget-bridge/tsconfig.json
new file mode 100644
index 0000000..fc34b70
--- /dev/null
+++ b/taskview-packages/capacitor-widget-bridge/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "lib": [
+ "ES2020"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "declaration": true,
+ "declarationMap": true,
+ "outDir": "./dist",
+ "noEmit": false,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/web/android/app/build.gradle b/web/android/app/build.gradle
index db1f52b..5949f8e 100644
--- a/web/android/app/build.gradle
+++ b/web/android/app/build.gradle
@@ -7,8 +7,8 @@ android {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 14801
- versionName "1.48.1"
+ versionCode 14902
+ versionName "1.49.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/web/android/app/capacitor.build.gradle b/web/android/app/capacitor.build.gradle
index 81423f1..739bad6 100644
--- a/web/android/app/capacitor.build.gradle
+++ b/web/android/app/capacitor.build.gradle
@@ -17,6 +17,7 @@ dependencies {
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
implementation project(':capgo-capacitor-updater')
+ implementation project(':capacitor-widget-bridge')
}
diff --git a/web/android/app/src/main/AndroidManifest.xml b/web/android/app/src/main/AndroidManifest.xml
index 2602d18..cb904cd 100644
--- a/web/android/app/src/main/AndroidManifest.xml
+++ b/web/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,24 @@
+
+
+
+
+
+
+
+
+
+
tasks = new ArrayList<>();
+ private boolean upcoming = false;
+
+ public TodayWidgetFactory(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void onCreate() {
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ tasks.clear();
+ upcoming = false;
+ String snapshot = context
+ .getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
+ if (snapshot == null) return;
+ try {
+ JSONObject parsed = new JSONObject(snapshot);
+ upcoming = "upcoming".equals(parsed.optString("mode"));
+ JSONArray items = parsed.optJSONArray("tasks");
+ if (items == null) return;
+ for (int i = 0; i < items.length(); i++) {
+ JSONObject task = items.optJSONObject(i);
+ if (task != null) tasks.add(task);
+ }
+ } catch (JSONException ignored) {
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ tasks.clear();
+ }
+
+ @Override
+ public int getCount() {
+ return tasks.size();
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ JSONObject task = tasks.get(position);
+ RemoteViews row = new RemoteViews(context.getPackageName(), R.layout.widget_today_item);
+
+ row.setTextViewText(R.id.widget_item_title, task.optString("title"));
+ row.setImageViewResource(R.id.widget_item_checkbox, priorityCheckbox(task.optInt("priority", 1)));
+
+ boolean overdue = !upcoming && task.optBoolean("overdue", false);
+ String meta = upcoming
+ ? formatEndDate(task)
+ : (overdue ? context.getString(R.string.widget_overdue) : formatEndTime(task));
+ if (meta == null || meta.isEmpty()) {
+ row.setViewVisibility(R.id.widget_item_meta, View.GONE);
+ } else {
+ row.setViewVisibility(R.id.widget_item_meta, View.VISIBLE);
+ row.setTextViewText(R.id.widget_item_meta, meta);
+ row.setTextColor(
+ R.id.widget_item_meta,
+ context.getColor(overdue ? R.color.widget_overdue : R.color.widget_text_secondary));
+ }
+
+ Intent fillIn = new Intent();
+ String path = task.optString("path", "");
+ if (!path.isEmpty()) {
+ fillIn.setData(Uri.parse("taskview://open?path=" + Uri.encode(path)));
+ }
+ row.setOnClickFillInIntent(R.id.widget_item_root, fillIn);
+
+ return row;
+ }
+
+ private int priorityCheckbox(int priority) {
+ switch (priority) {
+ case 3:
+ return R.drawable.widget_checkbox_high;
+ case 2:
+ return R.drawable.widget_checkbox_medium;
+ default:
+ return R.drawable.widget_checkbox_low;
+ }
+ }
+
+ private String formatEndTime(JSONObject task) {
+ if (task.isNull("endTime")) return null;
+ String endTime = task.optString("endTime", "");
+ return endTime.length() >= 5 ? endTime.substring(0, 5) : endTime;
+ }
+
+ private String formatEndDate(JSONObject task) {
+ if (task.isNull("endDate")) return null;
+ String endDate = task.optString("endDate", "");
+ String[] parts = endDate.split("-");
+ return parts.length == 3 ? parts[2] + "." + parts[1] : endDate;
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return tasks.get(position).optLong("id", position);
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+}
diff --git a/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetProvider.java b/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetProvider.java
new file mode 100644
index 0000000..805f62c
--- /dev/null
+++ b/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetProvider.java
@@ -0,0 +1,90 @@
+package com.handscream.taskview.app.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.widget.RemoteViews;
+
+import com.handscream.taskview.app.MainActivity;
+import com.handscream.taskview.app.R;
+
+import org.json.JSONObject;
+
+import tech.taskview.plugins.widgetbridge.WidgetBridgePlugin;
+
+public class TodayWidgetProvider extends AppWidgetProvider {
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ for (int appWidgetId : appWidgetIds) {
+ manager.updateAppWidget(appWidgetId, buildViews(context, appWidgetId));
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ if (WidgetBridgePlugin.ACTION_WIDGET_UPDATE.equals(intent.getAction())) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, TodayWidgetProvider.class));
+ if (ids.length == 0) return;
+ manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_today_list);
+ onUpdate(context, manager, ids);
+ }
+ }
+
+ private RemoteViews buildViews(Context context, int appWidgetId) {
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_today);
+
+ JSONObject snapshot = readSnapshot(context);
+ boolean upcoming = snapshot != null && "upcoming".equals(snapshot.optString("mode"));
+ int count = snapshot == null
+ ? 0
+ : (upcoming ? snapshot.optInt("upcomingCount", 0) : snapshot.optInt("todayCount", 0));
+ views.setTextViewText(
+ R.id.widget_today_title,
+ context.getString(upcoming ? R.string.widget_upcoming_title : R.string.widget_today_title));
+ views.setTextViewText(R.id.widget_today_count, String.valueOf(count));
+
+ Intent adapter = new Intent(context, TodayWidgetService.class);
+ adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
+ views.setRemoteAdapter(R.id.widget_today_list, adapter);
+ views.setEmptyView(R.id.widget_today_list, R.id.widget_today_empty);
+
+ PendingIntent openApp = PendingIntent.getActivity(
+ context,
+ 0,
+ new Intent(context, MainActivity.class),
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ views.setOnClickPendingIntent(R.id.widget_today_header, openApp);
+ views.setOnClickPendingIntent(R.id.widget_today_empty, openApp);
+
+ Intent template = new Intent(context, MainActivity.class);
+ template.setAction(Intent.ACTION_VIEW);
+ PendingIntent templateIntent = PendingIntent.getActivity(
+ context,
+ 1,
+ template,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
+ views.setPendingIntentTemplate(R.id.widget_today_list, templateIntent);
+
+ return views;
+ }
+
+ private JSONObject readSnapshot(Context context) {
+ String snapshot = context
+ .getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
+ if (snapshot == null) return null;
+ try {
+ return new JSONObject(snapshot);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetService.java b/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetService.java
new file mode 100644
index 0000000..7528861
--- /dev/null
+++ b/web/android/app/src/main/java/com/handscream/taskview/app/widget/TodayWidgetService.java
@@ -0,0 +1,12 @@
+package com.handscream.taskview.app.widget;
+
+import android.content.Intent;
+import android.widget.RemoteViewsService;
+
+public class TodayWidgetService extends RemoteViewsService {
+
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new TodayWidgetFactory(getApplicationContext());
+ }
+}
diff --git a/web/android/app/src/main/res/drawable/widget_badge_bg.xml b/web/android/app/src/main/res/drawable/widget_badge_bg.xml
new file mode 100644
index 0000000..df7869c
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_badge_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/drawable/widget_bg.xml b/web/android/app/src/main/res/drawable/widget_bg.xml
new file mode 100644
index 0000000..e5b273b
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/drawable/widget_checkbox_high.xml b/web/android/app/src/main/res/drawable/widget_checkbox_high.xml
new file mode 100644
index 0000000..5a78202
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_checkbox_high.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/drawable/widget_checkbox_low.xml b/web/android/app/src/main/res/drawable/widget_checkbox_low.xml
new file mode 100644
index 0000000..f63c1b3
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_checkbox_low.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/drawable/widget_checkbox_medium.xml b/web/android/app/src/main/res/drawable/widget_checkbox_medium.xml
new file mode 100644
index 0000000..5a5ad68
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_checkbox_medium.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/drawable/widget_header_bg.xml b/web/android/app/src/main/res/drawable/widget_header_bg.xml
new file mode 100644
index 0000000..8f224c9
--- /dev/null
+++ b/web/android/app/src/main/res/drawable/widget_header_bg.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/web/android/app/src/main/res/layout/widget_today.xml b/web/android/app/src/main/res/layout/widget_today.xml
new file mode 100644
index 0000000..ed7260c
--- /dev/null
+++ b/web/android/app/src/main/res/layout/widget_today.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/android/app/src/main/res/layout/widget_today_item.xml b/web/android/app/src/main/res/layout/widget_today_item.xml
new file mode 100644
index 0000000..e18e6c0
--- /dev/null
+++ b/web/android/app/src/main/res/layout/widget_today_item.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/android/app/src/main/res/values-night/widget_colors.xml b/web/android/app/src/main/res/values-night/widget_colors.xml
new file mode 100644
index 0000000..aa13d73
--- /dev/null
+++ b/web/android/app/src/main/res/values-night/widget_colors.xml
@@ -0,0 +1,8 @@
+
+
+ #18181B
+ #27272A
+ #3F3F46
+ #FAFAFA
+ #A1A1AA
+
diff --git a/web/android/app/src/main/res/values-ru/strings_widget.xml b/web/android/app/src/main/res/values-ru/strings_widget.xml
new file mode 100644
index 0000000..c2a385a
--- /dev/null
+++ b/web/android/app/src/main/res/values-ru/strings_widget.xml
@@ -0,0 +1,8 @@
+
+
+ Сегодня
+ Ближайшие
+ Нет задач на сегодня
+ Просрочено
+ Ваши задачи на сегодня
+
diff --git a/web/android/app/src/main/res/values/strings_widget.xml b/web/android/app/src/main/res/values/strings_widget.xml
new file mode 100644
index 0000000..7b79d13
--- /dev/null
+++ b/web/android/app/src/main/res/values/strings_widget.xml
@@ -0,0 +1,8 @@
+
+
+ Today
+ Upcoming
+ No tasks for today
+ Overdue
+ Your tasks for today at a glance
+
diff --git a/web/android/app/src/main/res/values/widget_colors.xml b/web/android/app/src/main/res/values/widget_colors.xml
new file mode 100644
index 0000000..18cb493
--- /dev/null
+++ b/web/android/app/src/main/res/values/widget_colors.xml
@@ -0,0 +1,13 @@
+
+
+ #FFFFFF
+ #F4F4F5
+ #E4E4E7
+ #18181B
+ #71717A
+ #16A34A
+ #FF1744
+ #38D681
+ #FF9100
+ #FF1744
+
diff --git a/web/android/app/src/main/res/xml/widget_today_info.xml b/web/android/app/src/main/res/xml/widget_today_info.xml
new file mode 100644
index 0000000..9d71a24
--- /dev/null
+++ b/web/android/app/src/main/res/xml/widget_today_info.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/web/android/capacitor.settings.gradle b/web/android/capacitor.settings.gradle
index 2d705a9..94cdeda 100644
--- a/web/android/capacitor.settings.gradle
+++ b/web/android/capacitor.settings.gradle
@@ -1,27 +1,30 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
-project(':capacitor-android').projectDir = new File('../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
+project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-firebase-messaging'
-project(':capacitor-firebase-messaging').projectDir = new File('../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging/android')
+project(':capacitor-firebase-messaging').projectDir = new File('../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging/android')
include ':capacitor-app'
-project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
+project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
include ':capacitor-browser'
-project(':capacitor-browser').projectDir = new File('../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
+project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
include ':capacitor-device'
-project(':capacitor-device').projectDir = new File('../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
+project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
include ':capacitor-preferences'
-project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
+project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
-project(':capacitor-push-notifications').projectDir = new File('../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
+project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
include ':capacitor-splash-screen'
-project(':capacitor-splash-screen').projectDir = new File('../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
+project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
include ':capgo-capacitor-updater'
-project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
+project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
+
+include ':capacitor-widget-bridge'
+project(':capacitor-widget-bridge').projectDir = new File('../node_modules/capacitor-widget-bridge/android')
diff --git a/web/capacitor.config.ts b/web/capacitor.config.ts
index 0f094af..626eee9 100644
--- a/web/capacitor.config.ts
+++ b/web/capacitor.config.ts
@@ -25,6 +25,9 @@ const config: CapacitorConfig = {
CapacitorHttp: {
enabled: true,
},
+ WidgetBridge: {
+ appGroup: 'group.com.handscream.taskview.app',
+ },
},
}
diff --git a/web/ios/App/App.xcodeproj/project.pbxproj b/web/ios/App/App.xcodeproj/project.pbxproj
index 02a0c63..bbb2320 100644
--- a/web/ios/App/App.xcodeproj/project.pbxproj
+++ b/web/ios/App/App.xcodeproj/project.pbxproj
@@ -8,6 +8,9 @@
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
+ 30163AB74EEE183517D215DC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41EECE9262690D5E2EF23CE9 /* Assets.xcassets */; };
+ 30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */; };
+ 336711805E8F2928D054F35E /* TodayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -15,11 +18,43 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
+ 7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */; };
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAA632B12F6BB598007C705D /* GoogleService-Info.plist */; };
+ ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */; };
+ AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
+ AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ 8471300B26377F4336A38D36 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 504EC2FC1FED79650016851F /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 13D58B519FF779A139CB09EB;
+ remoteInfo = TaskViewWidget;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
+ 029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; };
+ 41EECE9262690D5E2EF23CE9 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayWidget.swift; sourceTree = ""; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
@@ -28,9 +63,15 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; };
+ 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TaskViewWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotModel.swift; sourceTree = ""; };
+ AB10F00D00000000000000A1 /* OpenTaskIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OpenTaskIntent.swift; sourceTree = ""; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
+ 9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskViewWidgetBundle.swift; sourceTree = ""; };
AAA632B02F69F429007C705D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; };
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
+ EB7495A9A40AB2365CC0624E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TaskViewWidget.entitlements; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -42,9 +83,25 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8331741D08C05C5C85275E50 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 4961C8EBB434EDD15B55395B /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 7E3EA782F4406B2B6D7A0121 /* iOS */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
@@ -52,6 +109,8 @@
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
+ 4961C8EBB434EDD15B55395B /* Frameworks */,
+ 79D12E01B1FBD77844324865 /* TaskViewWidget */,
);
sourceTree = "";
};
@@ -59,6 +118,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
+ 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */,
);
name = Products;
sourceTree = "";
@@ -79,9 +139,49 @@
path = App;
sourceTree = "";
};
+ 79D12E01B1FBD77844324865 /* TaskViewWidget */ = {
+ isa = PBXGroup;
+ children = (
+ 9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */,
+ 4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */,
+ 873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */,
+ AB10F00D00000000000000A1 /* OpenTaskIntent.swift */,
+ EB7495A9A40AB2365CC0624E /* Info.plist */,
+ F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */,
+ 41EECE9262690D5E2EF23CE9 /* Assets.xcassets */,
+ );
+ name = TaskViewWidget;
+ path = TaskViewWidget;
+ sourceTree = "";
+ };
+ 7E3EA782F4406B2B6D7A0121 /* iOS */ = {
+ isa = PBXGroup;
+ children = (
+ 029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */,
+ );
+ name = iOS;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
+ 13D58B519FF779A139CB09EB /* TaskViewWidget */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */;
+ buildPhases = (
+ 2B48B6699C53A2E975CEE972 /* Sources */,
+ 8331741D08C05C5C85275E50 /* Frameworks */,
+ 8F380AD759A2116160808CCC /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = TaskViewWidget;
+ productName = TaskViewWidget;
+ productReference = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
@@ -89,10 +189,12 @@
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
+ E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
+ 70738FEC670F89727813A33B /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -135,6 +237,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
+ 13D58B519FF779A139CB09EB /* TaskViewWidget */,
);
};
/* End PBXProject section */
@@ -154,19 +257,48 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8F380AD759A2116160808CCC /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 30163AB74EEE183517D215DC /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 2B48B6699C53A2E975CEE972 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */,
+ 336711805E8F2928D054F35E /* TodayWidget.swift in Sources */,
+ ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */,
+ AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
+ AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ 70738FEC670F89727813A33B /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ name = TaskViewWidget;
+ target = 13D58B519FF779A139CB09EB /* TaskViewWidget */;
+ targetProxy = 8471300B26377F4336A38D36 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -304,7 +436,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1.48.1;
+ CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -312,7 +444,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.48.1;
+ MARKETING_VERSION = 1.49.2;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-weak_framework",
+ AppIntents,
+ );
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -329,7 +466,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1.48.1;
+ CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -337,7 +474,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.48.1;
+ MARKETING_VERSION = 1.49.2;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-weak_framework",
+ AppIntents,
+ );
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -346,6 +488,63 @@
};
name = Release;
};
+ 578B436A7C18D66B2BDFCF0B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1.49.2;
+ DEVELOPMENT_TEAM = H2W2SG48JT;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TaskViewWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.49.2;
+ PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ AC8696991AE5C61127E97D40 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1.49.2;
+ DEVELOPMENT_TEAM = H2W2SG48JT;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TaskViewWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.49.2;
+ PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -367,6 +566,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AC8696991AE5C61127E97D40 /* Release */,
+ 578B436A7C18D66B2BDFCF0B /* Debug */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
diff --git a/web/ios/App/App/App.entitlements b/web/ios/App/App/App.entitlements
index 903def2..837078c 100644
--- a/web/ios/App/App/App.entitlements
+++ b/web/ios/App/App/App.entitlements
@@ -4,5 +4,9 @@
aps-environment
development
+ com.apple.security.application-groups
+
+ group.com.handscream.taskview.app
+
diff --git a/web/ios/App/CapApp-SPM/Package.swift b/web/ios/App/CapApp-SPM/Package.swift
index da91ba5..c0c83da 100644
--- a/web/ios/App/CapApp-SPM/Package.swift
+++ b/web/ios/App/CapApp-SPM/Package.swift
@@ -12,14 +12,15 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"),
- .package(name: "CapacitorFirebaseMessaging", path: "../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging"),
- .package(name: "CapacitorApp", path: "../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
- .package(name: "CapacitorBrowser", path: "../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
- .package(name: "CapacitorDevice", path: "../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
- .package(name: "CapacitorPreferences", path: "../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
- .package(name: "CapacitorPushNotifications", path: "../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
- .package(name: "CapacitorSplashScreen", path: "../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
- .package(name: "CapgoCapacitorUpdater", path: "../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater")
+ .package(name: "CapacitorFirebaseMessaging", path: "../../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging"),
+ .package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
+ .package(name: "CapacitorBrowser", path: "../../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
+ .package(name: "CapacitorDevice", path: "../../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
+ .package(name: "CapacitorPreferences", path: "../../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
+ .package(name: "CapacitorPushNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
+ .package(name: "CapacitorSplashScreen", path: "../../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
+ .package(name: "CapgoCapacitorUpdater", path: "../../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater"),
+ .package(name: "CapacitorWidgetBridge", path: "../../../node_modules/capacitor-widget-bridge")
],
targets: [
.target(
@@ -34,7 +35,8 @@ let package = Package(
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
- .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
+ .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"),
+ .product(name: "CapacitorWidgetBridge", package: "CapacitorWidgetBridge")
]
)
]
diff --git a/web/ios/App/TaskViewWidget/Assets.xcassets/Contents.json b/web/ios/App/TaskViewWidget/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/Contents.json b/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/Contents.json
new file mode 100644
index 0000000..7c58c47
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/logo.png b/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/logo.png
new file mode 100644
index 0000000..0df96db
Binary files /dev/null and b/web/ios/App/TaskViewWidget/Assets.xcassets/WidgetLogo.imageset/logo.png differ
diff --git a/web/ios/App/TaskViewWidget/OpenTaskIntent.swift b/web/ios/App/TaskViewWidget/OpenTaskIntent.swift
new file mode 100644
index 0000000..7953421
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/OpenTaskIntent.swift
@@ -0,0 +1,39 @@
+import Foundation
+import AppIntents
+#if !WIDGET_EXTENSION
+import UIKit
+#endif
+
+@available(iOS 18.0, *)
+struct OpenTaskIntent: AppIntent {
+ static let title: LocalizedStringResource = "Open Task"
+ static let isDiscoverable = false
+ static let openAppWhenRun = true
+
+ @Parameter(title: "URL")
+ var urlString: String
+
+ init() {
+ urlString = "taskview://open"
+ }
+
+ init(urlString: String) {
+ self.urlString = urlString
+ }
+
+ private var resolvedURL: URL {
+ URL(string: urlString) ?? URL(string: "taskview://open")!
+ }
+
+ #if WIDGET_EXTENSION
+ func perform() async throws -> some IntentResult & OpensIntent {
+ .result(opensIntent: OpenURLIntent(resolvedURL))
+ }
+ #else
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ await UIApplication.shared.open(resolvedURL)
+ return .result()
+ }
+ #endif
+}
diff --git a/web/ios/App/TaskViewWidget/TaskViewWidget.entitlements b/web/ios/App/TaskViewWidget/TaskViewWidget.entitlements
new file mode 100644
index 0000000..bd2204d
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/TaskViewWidget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.handscream.taskview.app
+
+
+
diff --git a/web/ios/App/TaskViewWidget/TaskViewWidgetBundle.swift b/web/ios/App/TaskViewWidget/TaskViewWidgetBundle.swift
new file mode 100644
index 0000000..d56c64a
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/TaskViewWidgetBundle.swift
@@ -0,0 +1,9 @@
+import WidgetKit
+import SwiftUI
+
+@main
+struct TaskViewWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ TodayWidget()
+ }
+}
diff --git a/web/ios/App/TaskViewWidget/TodayWidget.swift b/web/ios/App/TaskViewWidget/TodayWidget.swift
new file mode 100644
index 0000000..e7b1cfa
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/TodayWidget.swift
@@ -0,0 +1,251 @@
+import WidgetKit
+import SwiftUI
+
+struct TodayEntry: TimelineEntry {
+ let date: Date
+ let snapshot: WidgetSnapshot?
+}
+
+struct TodayProvider: TimelineProvider {
+ func placeholder(in context: Context) -> TodayEntry {
+ TodayEntry(date: .now, snapshot: nil)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (TodayEntry) -> Void) {
+ completion(TodayEntry(date: .now, snapshot: WidgetSnapshot.load()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = TodayEntry(date: .now, snapshot: WidgetSnapshot.load())
+ let refresh = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
+ completion(Timeline(entries: [entry], policy: .after(refresh)))
+ }
+}
+
+struct TodayWidget: Widget {
+ let kind = "TaskViewTodayWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: TodayProvider()) { entry in
+ TodayWidgetView(entry: entry)
+ .containerBackground(for: .widget) {
+ Color(uiColor: .systemBackground)
+ }
+ }
+ .configurationDisplayName("TaskView")
+ .description("Today's tasks / Задачи на сегодня")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ .contentMarginsDisabled()
+ }
+}
+
+struct TodayWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+
+ let entry: TodayEntry
+
+ private var strings: WidgetStrings {
+ WidgetStrings.forLocale(entry.snapshot?.locale)
+ }
+
+ private var maxSlots: Int {
+ switch family {
+ case .systemLarge: return 8
+ case .systemSmall: return 4
+ default: return 3
+ }
+ }
+
+ var body: some View {
+ TaskListTodayView(
+ snapshot: entry.snapshot,
+ strings: strings,
+ maxSlots: maxSlots,
+ compact: family == .systemSmall
+ )
+ }
+}
+
+struct TaskListTodayView: View {
+ let snapshot: WidgetSnapshot?
+ let strings: WidgetStrings
+ let maxSlots: Int
+ let compact: Bool
+
+ private var horizontalPadding: CGFloat { compact ? 12 : 16 }
+ private var rowVerticalPadding: CGFloat { compact ? 6 : 10 }
+ private var dividerInset: CGFloat { compact ? 38 : 48 }
+
+ private var isUpcoming: Bool {
+ snapshot?.isUpcoming ?? false
+ }
+
+ private var visibleTasks: [WidgetSnapshotTask] {
+ guard let snapshot else { return [] }
+ let count = snapshot.activeCount > maxSlots ? maxSlots - 1 : maxSlots
+ return Array(snapshot.tasks.prefix(count))
+ }
+
+ private var hiddenCount: Int {
+ guard let snapshot else { return 0 }
+ return max(0, snapshot.activeCount - visibleTasks.count)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: compact ? 6 : 8) {
+ WidgetLogoView(size: compact ? 16 : 20)
+
+ Text(isUpcoming ? strings.upcoming : strings.today)
+ .font(compact ? .footnote.weight(.semibold) : .headline)
+
+ Spacer()
+
+ Text("\(snapshot?.activeCount ?? 0)")
+ .font(.caption2.weight(.bold))
+ .contentTransition(.numericText(countsDown: true))
+ .foregroundStyle(.white)
+ .padding(.horizontal, compact ? 6 : 8)
+ .padding(.vertical, compact ? 2 : 3)
+ .background(WidgetPalette.accent, in: Capsule())
+ }
+ .padding(.horizontal, horizontalPadding)
+ .padding(.top, 12)
+ .padding(.bottom, compact ? 8 : 12)
+ .background(WidgetPalette.headerBackground)
+
+ if !visibleTasks.isEmpty {
+ VStack(spacing: 0) {
+ ForEach(Array(visibleTasks.enumerated()), id: \.element.id) { index, task in
+ VStack(spacing: 0) {
+ if index > 0 {
+ Divider()
+ .padding(.leading, dividerInset)
+ }
+
+ TaskRowView(task: task, strings: strings, compact: compact, showDate: isUpcoming)
+ .padding(.horizontal, horizontalPadding)
+ .padding(.vertical, rowVerticalPadding)
+ }
+ .transition(.opacity.combined(with: .move(edge: .trailing)))
+ }
+
+ if hiddenCount > 0 {
+ Divider()
+ .padding(.leading, dividerInset)
+
+ Text(strings.more(hiddenCount))
+ .font(compact ? .caption2 : .footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, dividerInset)
+ .padding(.trailing, horizontalPadding)
+ .padding(.vertical, rowVerticalPadding)
+ }
+ }
+ Spacer(minLength: 0)
+ } else {
+ Spacer()
+ Text(snapshot == nil ? strings.openApp : strings.empty)
+ .font(compact ? .caption : .footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, horizontalPadding)
+ Spacer()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .widgetURL(compact ? URL(string: "taskview://open") : nil)
+ }
+}
+
+struct TaskRowView: View {
+ let task: WidgetSnapshotTask
+ let strings: WidgetStrings
+ let compact: Bool
+ let showDate: Bool
+
+ private var destination: URL {
+ task.deepLinkURL ?? URL(string: "taskview://open")!
+ }
+
+ var body: some View {
+ if compact {
+ if #available(iOS 18.0, *) {
+ Button(intent: OpenTaskIntent(urlString: destination.absoluteString)) {
+ row
+ }
+ .buttonStyle(.plain)
+ } else {
+ row
+ }
+ } else {
+ Link(destination: destination) {
+ row
+ }
+ }
+ }
+
+ private var row: some View {
+ HStack(spacing: compact ? 4 : 6) {
+ Circle()
+ .strokeBorder(WidgetPalette.priority(task.priority), lineWidth: compact ? 1.2 : 1.5)
+ .frame(width: compact ? 14 : 18, height: compact ? 14 : 18)
+ .padding(4)
+
+ content
+ }
+ }
+
+ private var content: some View {
+ HStack(spacing: compact ? 8 : 10) {
+ Text(task.title)
+ .font(compact ? .caption : .subheadline)
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+
+ Spacer(minLength: 4)
+
+ if showDate {
+ if let endDate = task.shortEndDate {
+ Text(endDate)
+ .font(compact ? .caption2 : .caption)
+ .foregroundStyle(.secondary)
+ }
+ } else if task.overdue {
+ Text(compact ? "!" : strings.overdue)
+ .font(compact ? .caption.weight(.bold) : .caption.weight(.medium))
+ .foregroundStyle(WidgetPalette.overdue)
+ } else if let endTime = task.shortEndTime {
+ Text(endTime)
+ .font(compact ? .caption2 : .caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+}
+
+struct WidgetLogoView: View {
+ let size: CGFloat
+
+ var body: some View {
+ Image("WidgetLogo")
+ .resizable()
+ .frame(width: size, height: size)
+ .clipShape(RoundedRectangle(cornerRadius: size * 0.28))
+ }
+}
+
+enum WidgetPalette {
+ static let accent = Color(red: 0.086, green: 0.639, blue: 0.290)
+ static let overdue = Color(red: 1.0, green: 0.090, blue: 0.267)
+ static let headerBackground = Color(uiColor: .secondarySystemBackground).opacity(0.7)
+
+ static func priority(_ priority: Int) -> Color {
+ switch priority {
+ case 3: return Color(red: 1.0, green: 0.090, blue: 0.267)
+ case 2: return Color(red: 1.0, green: 0.569, blue: 0.0)
+ default: return Color(red: 0.220, green: 0.839, blue: 0.506)
+ }
+ }
+}
diff --git a/web/ios/App/TaskViewWidget/WidgetSnapshotModel.swift b/web/ios/App/TaskViewWidget/WidgetSnapshotModel.swift
new file mode 100644
index 0000000..ca80087
--- /dev/null
+++ b/web/ios/App/TaskViewWidget/WidgetSnapshotModel.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+struct WidgetSnapshotTask: Decodable, Identifiable {
+ let id: Int
+ let title: String
+ let priority: Int
+ let overdue: Bool
+ let endTime: String?
+ let endDate: String?
+ let path: String
+
+ var deepLinkURL: URL? {
+ guard let encoded = path.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { return nil }
+ return URL(string: "taskview://open?path=\(encoded)")
+ }
+
+ var shortEndTime: String? {
+ guard let endTime, endTime.count >= 5 else { return endTime }
+ return String(endTime.prefix(5))
+ }
+
+ var shortEndDate: String? {
+ guard let endDate else { return nil }
+ let parts = endDate.split(separator: "-")
+ guard parts.count == 3 else { return endDate }
+ return "\(parts[2]).\(parts[1])"
+ }
+}
+
+struct WidgetSnapshot: Decodable {
+ let v: Int
+ let generatedAt: String
+ let locale: String
+ let orgSlug: String?
+ let mode: String?
+ let todayCount: Int
+ let overdueCount: Int
+ let upcomingCount: Int?
+ let tasks: [WidgetSnapshotTask]
+
+ var isUpcoming: Bool {
+ mode == "upcoming"
+ }
+
+ var activeCount: Int {
+ isUpcoming ? (upcomingCount ?? tasks.count) : todayCount
+ }
+
+ static let appGroup = "group.com.handscream.taskview.app"
+ static let snapshotKey = "widgetSnapshot"
+
+ static func load() -> WidgetSnapshot? {
+ guard let defaults = UserDefaults(suiteName: appGroup),
+ let raw = defaults.string(forKey: snapshotKey),
+ let data = raw.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
+ }
+}
+
+struct WidgetStrings {
+ let today: String
+ let upcoming: String
+ let empty: String
+ let overdue: String
+ let openApp: String
+ let moreFormat: String
+
+ static let ru = WidgetStrings(
+ today: "Сегодня",
+ upcoming: "Ближайшие",
+ empty: "Нет задач на сегодня",
+ overdue: "Просрочено",
+ openApp: "Откройте TaskView",
+ moreFormat: "и ещё %d"
+ )
+
+ static let en = WidgetStrings(
+ today: "Today",
+ upcoming: "Upcoming",
+ empty: "No tasks for today",
+ overdue: "Overdue",
+ openApp: "Open TaskView",
+ moreFormat: "+%d more"
+ )
+
+ func more(_ count: Int) -> String {
+ String(format: moreFormat, count)
+ }
+
+ static func forLocale(_ locale: String?) -> WidgetStrings {
+ let resolved = locale ?? Locale.preferredLanguages.first ?? "ru"
+ return resolved.hasPrefix("ru") ? ru : en
+ }
+}
diff --git a/web/package.json b/web/package.json
index 0dd6bca..26eb52f 100644
--- a/web/package.json
+++ b/web/package.json
@@ -2,10 +2,10 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
- "version": "1.49.1",
+ "version": "1.49.2",
"scripts": {
"dev": "vite",
- "build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
+ "build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
"build": "pnpm run build:packages && pnpm run typecheck && vite build",
"build-only": "vite build",
"preview": "vite preview",
@@ -58,6 +58,7 @@
"@vueuse/core": "^14.1.0",
"arktype": "2.1.20",
"axios": "1.13.5",
+ "capacitor-widget-bridge": "workspace:^",
"centrifuge": "^5.5.3",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
diff --git a/web/src/composables/useLogout.ts b/web/src/composables/useLogout.ts
index b7cce0e..d5778b7 100644
--- a/web/src/composables/useLogout.ts
+++ b/web/src/composables/useLogout.ts
@@ -1,3 +1,5 @@
+import { Capacitor } from '@capacitor/core'
+import { WidgetBridge } from 'capacitor-widget-bridge'
import $api from '@/helpers/axios'
import { $ls, $tvApi } from '@/plugins/axios'
import { usePushNotifications } from '@/composables/usePushNotifications'
@@ -18,6 +20,11 @@ export async function useLogout() {
if (result) {
reset()
+ if (Capacitor.isNativePlatform()) {
+ await WidgetBridge.clearSnapshot().catch((err) => {
+ console.error('[Logout] Failed to clear widget snapshot:', err)
+ })
+ }
await $ls.invalidateTokens()
return true
}
diff --git a/web/src/composables/useUpdater.ts b/web/src/composables/useUpdater.ts
index 56875cf..80d7987 100644
--- a/web/src/composables/useUpdater.ts
+++ b/web/src/composables/useUpdater.ts
@@ -22,8 +22,6 @@ function isNewerVersion(server: string, current: string): boolean {
}
export async function useUpdater(canUpdate: boolean = false) {
- // console.log(canUpdate);
- // return;
try {
const BRANCH_MODE: 'prod' | 'dev' = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
@@ -42,9 +40,11 @@ export async function useUpdater(canUpdate: boolean = false) {
return
}
- console.log('currentVersion', currentVersion, 'updateInfo', last)
+ const effectiveVersion = currentVersion || APP_VERSION
- if (!currentVersion || isNewerVersion(last.version, currentVersion) || (version && version?.version !== last.version)) {
+ console.log('currentVersion', effectiveVersion, 'updateInfo', last)
+
+ if (isNewerVersion(last.version, effectiveVersion) || (version && version?.version !== last.version)) {
console.log('[Update] Downloading new version:', last.version)
version = await CapacitorUpdater.download({
diff --git a/web/src/composables/useWidgetDeepLink.ts b/web/src/composables/useWidgetDeepLink.ts
new file mode 100644
index 0000000..0d81c62
--- /dev/null
+++ b/web/src/composables/useWidgetDeepLink.ts
@@ -0,0 +1,67 @@
+import { onScopeDispose, ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { Capacitor } from '@capacitor/core'
+import { App } from '@capacitor/app'
+import { useOrganizationStore } from '@/stores/organization.store'
+import { useGoalsStore } from '@/stores/goals.store'
+
+const OPEN_PREFIX = 'taskview://open'
+
+let launchUrlHandled = false
+
+export function useWidgetDeepLink() {
+ if (!Capacitor.isNativePlatform()) return
+
+ const router = useRouter()
+ const orgStore = useOrganizationStore()
+ const goalsStore = useGoalsStore()
+
+ const pendingPath = ref(null)
+
+ const extractPath = (url: string): string | null => {
+ if (!url.startsWith(OPEN_PREFIX)) return null
+ const match = url.match(/[?&]path=([^&]+)/)
+ return match ? decodeURIComponent(match[1]) : null
+ }
+
+ const navigate = (url: string) => {
+ const path = extractPath(url)
+ console.log('[WidgetDeepLink] url:', url, '-> path:', path)
+ if (path) pendingPath.value = path
+ }
+
+ watch(
+ () => [pendingPath.value, orgStore.initialized, goalsStore.initialized] as const,
+ ([path, orgReady, goalsReady]) => {
+ if (!path) return
+ console.log('[WidgetDeepLink] pending:', path, 'orgReady:', orgReady, 'goalsReady:', goalsReady)
+ if (!orgReady || !goalsReady) return
+ pendingPath.value = null
+ // If a task is already open, replace the history entry instead of pushing —
+ // otherwise consecutive widget taps stack task routes and closing one
+ // reopens the previous task.
+ const hasOpenTask = Boolean(router.currentRoute.value.params.taskId)
+ if (hasOpenTask) {
+ router.replace(path)
+ } else {
+ router.push(path)
+ }
+ },
+ { immediate: true },
+ )
+
+ let remove: (() => void) | undefined
+ App.addListener('appUrlOpen', ({ url }) => navigate(url)).then((handle) => {
+ remove = () => handle.remove()
+ })
+
+ if (!launchUrlHandled) {
+ launchUrlHandled = true
+ App.getLaunchUrl().then((result) => {
+ console.log('[WidgetDeepLink] launchUrl:', result?.url ?? 'none')
+ if (result?.url) navigate(result.url)
+ })
+ }
+
+ onScopeDispose(() => remove?.())
+}
diff --git a/web/src/composables/useWidgetSnapshot.ts b/web/src/composables/useWidgetSnapshot.ts
new file mode 100644
index 0000000..6233bd8
--- /dev/null
+++ b/web/src/composables/useWidgetSnapshot.ts
@@ -0,0 +1,74 @@
+import { watch } from 'vue'
+import { useDebounceFn, useDateFormat } from '@vueuse/core'
+import { Capacitor } from '@capacitor/core'
+import { useI18n } from 'vue-i18n'
+import { ALL_TASKS_LIST_ID } from 'taskview-api'
+import { WidgetBridge, type WidgetSnapshot, type WidgetSnapshotTask } from 'capacitor-widget-bridge'
+import { useBaseScreenStore } from '@/stores/base-screen.store'
+import { useOrganizationStore } from '@/stores/organization.store'
+import { useRefreshOnResume } from '@/composables/useRefreshOnResume'
+import type { TaskItem } from '@/types/tasks.types'
+
+const MAX_WIDGET_TASKS = 10
+
+export function useWidgetSnapshot() {
+ if (!Capacitor.isNativePlatform()) return
+
+ const baseScreenStore = useBaseScreenStore()
+ const orgStore = useOrganizationStore()
+ const { locale } = useI18n()
+
+ const todayYmd = () => useDateFormat(new Date(), 'YYYY-MM-DD').value
+
+ const isOverdue = (task: TaskItem) => {
+ if (!task.endDate) return false
+ return useDateFormat(new Date(task.endDate), 'YYYY-MM-DD').value < todayYmd()
+ }
+
+ const toSnapshotTask = (task: TaskItem): WidgetSnapshotTask => ({
+ id: task.id,
+ title: task.description,
+ priority: task.priorityId,
+ overdue: isOverdue(task),
+ endTime: task.endTime,
+ endDate: task.endDate ? useDateFormat(new Date(task.endDate), 'YYYY-MM-DD').value : null,
+ path: `/${orgStore.currentOrgSlug}/${task.goalId}/${task.goalListId ?? ALL_TASKS_LIST_ID}/${task.id}`,
+ })
+
+ const buildSnapshot = (): WidgetSnapshot => {
+ const openToday = baseScreenStore.tasksToday.filter((task) => !task.complete)
+ const openUpcoming = baseScreenStore.tasksUpcoming
+ .filter((task) => !task.complete)
+ .sort((a, b) => new Date(a.endDate ?? 0).getTime() - new Date(b.endDate ?? 0).getTime())
+ const mode = openToday.length === 0 && openUpcoming.length > 0 ? 'upcoming' : 'today'
+ const shownTasks = mode === 'today' ? openToday : openUpcoming
+ return {
+ v: 3,
+ generatedAt: new Date().toISOString(),
+ locale: locale.value,
+ orgSlug: orgStore.currentOrgSlug || null,
+ mode,
+ todayCount: openToday.length,
+ overdueCount: openToday.filter(isOverdue).length,
+ upcomingCount: openUpcoming.length,
+ tasks: shownTasks.slice(0, MAX_WIDGET_TASKS).map(toSnapshotTask),
+ }
+ }
+
+ const writeSnapshot = useDebounceFn(async () => {
+ if (!baseScreenStore.wasCalled) return
+ await WidgetBridge.setSnapshot({ snapshot: JSON.stringify(buildSnapshot()) }).catch((err) => {
+ console.error('[WidgetSnapshot] Failed to write snapshot:', err)
+ })
+ }, 500)
+
+ watch(
+ () => [baseScreenStore.tasksToday, baseScreenStore.tasksUpcoming, orgStore.currentOrgSlug, locale.value] as const,
+ () => writeSnapshot(),
+ { deep: true },
+ )
+
+ useRefreshOnResume(() => {
+ if (baseScreenStore.wasCalled) baseScreenStore.fetchAllState()
+ })
+}
diff --git a/web/src/layouts/UserLayout.vue b/web/src/layouts/UserLayout.vue
index 3d608bf..d1ee855 100644
--- a/web/src/layouts/UserLayout.vue
+++ b/web/src/layouts/UserLayout.vue
@@ -34,6 +34,8 @@ import DashboardSidebarSecond from '@/components/sidebars/DashboardSidebarSecond
import SearchAll from '@/components/features/main/screen-main/parts/SearchAll.vue'
import { useCentrifugo } from '@/composables/useCentrifugo'
import { usePushNotifications } from '@/composables/usePushNotifications'
+import { useWidgetSnapshot } from '@/composables/useWidgetSnapshot'
+import { useWidgetDeepLink } from '@/composables/useWidgetDeepLink'
import { useGoalsStore } from '@/stores/goals.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useTimeTrackingStore } from '@/stores/time-tracking.store'
@@ -49,6 +51,9 @@ const orgStore = useOrganizationStore()
const timeTrackingStore = useTimeTrackingStore()
const uiPrefsStore = useUiPreferencesStore()
+useWidgetSnapshot()
+useWidgetDeepLink()
+
watch(
() => timeTrackingStore.lastError,
(err) => {