Compare commits
42 Commits
v1.2.16
...
backup-in-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c832e6af6c | ||
|
|
fc7f46618f | ||
|
|
b2949b2dd9 | ||
|
|
9478f7d0f5 | ||
|
|
2e0ff5a10c | ||
|
|
85826d8068 | ||
|
|
9b0af72d74 | ||
|
|
eb088a259a | ||
|
|
dcd157274a | ||
|
|
19ed0ba3ab | ||
|
|
b8fe2833b6 | ||
|
|
c39bbe5984 | ||
|
|
9950d8ff6c | ||
|
|
99b467488f | ||
|
|
8dfaa4d8c6 | ||
|
|
000e4d0d61 | ||
|
|
0132408eec | ||
|
|
b4d46caee4 | ||
|
|
b2d4d90c9c | ||
|
|
f92063dfe9 | ||
|
|
ce07e467f3 | ||
|
|
f6adc99187 | ||
|
|
950645cc96 | ||
|
|
cb71b6074d | ||
|
|
bb87dc2e14 | ||
|
|
ce49e4afc6 | ||
|
|
ea89f90edb | ||
|
|
8cba22024a | ||
|
|
9044193aa3 | ||
|
|
b145bb422b | ||
|
|
5a4f67d70a | ||
|
|
09c3dbbebc | ||
|
|
ad1f85ffcc | ||
|
|
ec8eb39599 | ||
|
|
adca4c3af9 | ||
|
|
f5da57d7f7 | ||
|
|
a133b271d0 | ||
|
|
e0257445ef | ||
|
|
65c774d0d0 | ||
|
|
2e5f0a3725 | ||
|
|
ebae9b78b1 | ||
|
|
b73586f81b |
@@ -1,36 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:tailwindcss/recommended',
|
||||
],
|
||||
ignorePatterns: [
|
||||
'dist',
|
||||
'.eslintrc.cjs',
|
||||
'tailwind.config.js',
|
||||
'postcss.config.js',
|
||||
],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: [
|
||||
'react-refresh',
|
||||
'simple-import-sort',
|
||||
'sort-destructure-keys',
|
||||
'sort-keys-fix',
|
||||
'prettier',
|
||||
|
||||
'tailwindcss',
|
||||
],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
}
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -2,7 +2,6 @@ name: Build validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -19,3 +18,4 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
- run: npm run build
|
||||
- run: npm run lint:ci
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -43,4 +43,8 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/.env
|
||||
fastlane/*.log
|
||||
vendor/bundle
|
||||
vendor/bundle
|
||||
|
||||
# Claude Code skills cache
|
||||
.agents/
|
||||
skills-lock.json
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
LICENSE.md
|
||||
dist/
|
||||
13
.prettierrc
13
.prettierrc
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"jsxBracketSameLine": false,
|
||||
"printWidth": 80,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.9)
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
@@ -230,4 +230,4 @@ DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
1.17.2
|
||||
2.4.10
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 37
|
||||
versionName "1.2.16"
|
||||
versionCode 55
|
||||
versionName "1.2.34"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -9,10 +9,13 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-in-app-review')
|
||||
implementation project(':capacitor-community-speech-recognition')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-local-llm')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
@@ -37,6 +38,64 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_today_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_today_info" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.WeekWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_week_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_week_info" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.PeopleWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_people_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_people_info" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.QuickCaptureWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_quick_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_quick_capture_info" />
|
||||
</receiver>
|
||||
<service
|
||||
android:name=".widget.WidgetListService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
<activity
|
||||
android:name=".widget.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -66,7 +125,22 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Remove advertising ID / Privacy Sandbox ad permissions if transitive SDKs inject them. -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_CUSTOM_AUDIENCE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- Voice task capture -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Android 11+ package visibility for the system speech recognition service -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -9,11 +9,20 @@ import com.getcapacitor.PluginHandle;
|
||||
import com.getcapacitor.Plugin;
|
||||
import android.content.Intent;
|
||||
import android.nfc.NfcAdapter;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import com.donetick.app.widget.WidgetBridgePlugin;
|
||||
|
||||
|
||||
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(WidgetBridgePlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
// Capacitor only forwards ACTION_VIEW deep links, so normalize Donetick NFC intents before dispatch.
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.BitmapShader;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.Shader;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Member avatars for the widgets: downloads and circle-crops profile photos
|
||||
* (cached on disk for a day) and falls back to a colored initials disc.
|
||||
* Downloads must only happen from a background/binder thread —
|
||||
* RemoteViewsFactory.onDataSetChanged qualifies.
|
||||
*/
|
||||
final class AvatarCache {
|
||||
private static final String TAG = "DonetickWidget";
|
||||
private static final String DIR = "widget_avatars";
|
||||
private static final long MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
private static final int SIZE_PX = 96;
|
||||
|
||||
// Joy-ish palette for initials discs; picked by hashing the member id so
|
||||
// each person keeps a stable color.
|
||||
private static final int[] PALETTE = {
|
||||
0xFF0B6BCB, 0xFF147D57, 0xFF9C4DD3, 0xFFC2410C,
|
||||
0xFF0E7490, 0xFFB02A5B, 0xFF5B21B6, 0xFF937800,
|
||||
};
|
||||
|
||||
private AvatarCache() {}
|
||||
|
||||
/** Photo avatar or null; never throws, never touches the network on failure loops. */
|
||||
static Bitmap photo(Context context, WidgetStore.Member member) {
|
||||
if (member == null || member.image == null || !member.image.startsWith("http")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
File dir = new File(context.getCacheDir(), DIR);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
File file = new File(dir, member.id + ".png");
|
||||
if (file.exists()
|
||||
&& System.currentTimeMillis() - file.lastModified() < MAX_AGE_MS) {
|
||||
return BitmapFactory.decodeFile(file.getAbsolutePath());
|
||||
}
|
||||
Bitmap downloaded = download(member.image);
|
||||
if (downloaded == null) {
|
||||
// Keep serving a stale copy rather than nothing.
|
||||
return file.exists() ? BitmapFactory.decodeFile(file.getAbsolutePath()) : null;
|
||||
}
|
||||
Bitmap circled = circleCrop(downloaded);
|
||||
try (FileOutputStream out = new FileOutputStream(file)) {
|
||||
circled.compress(Bitmap.CompressFormat.PNG, 100, out);
|
||||
}
|
||||
return circled;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Avatar load failed for member " + member.id, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Colored disc with the member's first initial — the no-photo fallback. */
|
||||
static Bitmap initials(WidgetStore.Member member) {
|
||||
String name = member != null && member.name != null ? member.name.trim() : "";
|
||||
String letter = name.isEmpty()
|
||||
? "?"
|
||||
: new String(Character.toChars(name.codePointAt(0))).toUpperCase(Locale.getDefault());
|
||||
int color = PALETTE[Math.abs((member != null ? member.id : "?").hashCode()) % PALETTE.length];
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
paint.setColor(color);
|
||||
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
|
||||
|
||||
Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
text.setColor(Color.WHITE);
|
||||
text.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
|
||||
text.setTextSize(SIZE_PX * 0.42f);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
Rect bounds = new Rect();
|
||||
text.getTextBounds(letter, 0, letter.length(), bounds);
|
||||
canvas.drawText(letter, SIZE_PX / 2f, SIZE_PX / 2f + bounds.height() / 2f, text);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
/** Best avatar for a member: photo when available, initials otherwise. */
|
||||
static Bitmap get(Context context, WidgetStore.Member member) {
|
||||
Bitmap photo = photo(context, member);
|
||||
return photo != null ? photo : initials(member);
|
||||
}
|
||||
|
||||
private static Bitmap download(String imageUrl) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = (HttpURLConnection) new URL(imageUrl).openConnection();
|
||||
connection.setConnectTimeout(8000);
|
||||
connection.setReadTimeout(10000);
|
||||
if (connection.getResponseCode() != 200) return null;
|
||||
try (InputStream stream = connection.getInputStream()) {
|
||||
Bitmap raw = BitmapFactory.decodeStream(stream);
|
||||
if (raw == null) return null;
|
||||
return Bitmap.createScaledBitmap(raw, SIZE_PX, SIZE_PX, true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Avatar download failed", e);
|
||||
return null;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap circleCrop(Bitmap source) {
|
||||
Bitmap output = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(output);
|
||||
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
|
||||
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
/** "People" home-screen widget: every circle member with their task load. */
|
||||
public class PeopleWidgetProvider extends AppWidgetProvider {
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_PEOPLE, id));
|
||||
}
|
||||
// Reload rows; the factory refreshes from the server when stale, which
|
||||
// is what keeps the widget current while the app stays closed.
|
||||
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
/**
|
||||
* "Quick Capture" home-screen widget: three shortcuts straight into the
|
||||
* add-task flow (type, scan, speak). Purely a launcher — it shows no task
|
||||
* data, so it never needs a refresh cycle.
|
||||
*/
|
||||
public class QuickCaptureWidgetProvider extends AppWidgetProvider {
|
||||
// Handled in src/CapacitorListener.js → /chores?add_task=1[&mode=…]
|
||||
private static final String URI_TYPE = "donetick://chores/add";
|
||||
private static final String URI_SCAN = "donetick://chores/add?mode=scan";
|
||||
private static final String URI_VOICE = "donetick://chores/add?mode=voice";
|
||||
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
manager.updateAppWidget(id, build(context));
|
||||
}
|
||||
}
|
||||
|
||||
private static RemoteViews build(Context context) {
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_quick_capture);
|
||||
views.setOnClickPendingIntent(R.id.quick_type, deepLink(context, 10, URI_TYPE));
|
||||
views.setOnClickPendingIntent(R.id.quick_scan, deepLink(context, 11, URI_SCAN));
|
||||
views.setOnClickPendingIntent(R.id.quick_voice, deepLink(context, 12, URI_VOICE));
|
||||
return views;
|
||||
}
|
||||
|
||||
private static PendingIntent deepLink(Context context, int requestCode, String uri) {
|
||||
Intent intent = new Intent(context, com.donetick.app.MainActivity.class);
|
||||
intent.setAction(Intent.ACTION_VIEW);
|
||||
// Distinct data per tile so the three PendingIntents aren't collapsed.
|
||||
intent.setData(Uri.parse(uri));
|
||||
return PendingIntent.getActivity(context, requestCode, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
/** "Today" home-screen widget: tasks due today plus anything awaiting approval. */
|
||||
public class TodayWidgetProvider extends AppWidgetProvider {
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_TODAY, id));
|
||||
}
|
||||
// Reload rows; the factory refreshes from the server when stale, which
|
||||
// is what keeps the widget current while the app stays closed.
|
||||
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeleted(Context context, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
WidgetStore.removeWidgetOptions(context, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
/** "Next 7 Days" home-screen widget: upcoming tasks grouped by day. */
|
||||
public class WeekWidgetProvider extends AppWidgetProvider {
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_WEEK, id));
|
||||
}
|
||||
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeleted(Context context, int[] appWidgetIds) {
|
||||
for (int id : appWidgetIds) {
|
||||
WidgetStore.removeWidgetOptions(context, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/**
|
||||
* JS bridge for the home-screen widgets (see src/service/WidgetService.js).
|
||||
* Persists the task snapshot + API config and redraws the widgets.
|
||||
*/
|
||||
@CapacitorPlugin(name = "WidgetBridge")
|
||||
public class WidgetBridgePlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void update(PluginCall call) {
|
||||
String data = call.getString("data");
|
||||
String config = call.getString("config");
|
||||
if (data != null) WidgetStore.saveData(getContext(), data);
|
||||
if (config != null) WidgetStore.saveConfig(getContext(), config);
|
||||
WidgetUi.refreshAll(getContext());
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void clear(PluginCall call) {
|
||||
WidgetStore.clear(getContext());
|
||||
WidgetUi.refreshAll(getContext());
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Switch;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
/**
|
||||
* Placement / long-press configuration for the Today and Next 7 Days widgets.
|
||||
* One option for now: include tasks assigned to other circle members
|
||||
* (stored per appWidgetId so mixed setups work side by side).
|
||||
*/
|
||||
public class WidgetConfigActivity extends Activity {
|
||||
private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
appWidgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID);
|
||||
// Cancelled result until Save, so backing out never adds a half-configured widget.
|
||||
setResult(RESULT_CANCELED, resultIntent());
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
setContentView(R.layout.widget_config);
|
||||
|
||||
Switch includeOthers = findViewById(R.id.config_include_others);
|
||||
includeOthers.setChecked(WidgetStore.includeOthers(this, appWidgetId));
|
||||
|
||||
findViewById(R.id.config_save).setOnClickListener(v -> {
|
||||
WidgetStore.setIncludeOthers(this, appWidgetId, includeOthers.isChecked());
|
||||
WidgetUi.refreshAll(this);
|
||||
setResult(RESULT_OK, resultIntent());
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
private Intent resultIntent() {
|
||||
return new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
import android.widget.RemoteViewsService;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.donetick.app.R;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Feeds task rows, day-group headers, and people rows to the widget ListView. */
|
||||
public class WidgetListService extends RemoteViewsService {
|
||||
@Override
|
||||
public RemoteViewsFactory onGetViewFactory(Intent intent) {
|
||||
String mode = intent.getStringExtra(WidgetUi.EXTRA_MODE);
|
||||
int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID);
|
||||
return new WidgetListFactory(getApplicationContext(),
|
||||
mode != null ? mode : WidgetUi.MODE_TODAY, appWidgetId);
|
||||
}
|
||||
|
||||
static class Row {
|
||||
final String header; // non-null for day-group headers
|
||||
final WidgetStore.Task task; // non-null for task rows
|
||||
final WidgetStore.Member person; // non-null for people rows
|
||||
int todayCount;
|
||||
int weekCount;
|
||||
|
||||
Row(String header, WidgetStore.Task task, WidgetStore.Member person) {
|
||||
this.header = header;
|
||||
this.task = task;
|
||||
this.person = person;
|
||||
}
|
||||
}
|
||||
|
||||
static class WidgetListFactory implements RemoteViewsFactory {
|
||||
private final Context context;
|
||||
private final String mode;
|
||||
private final int appWidgetId;
|
||||
private List<Row> rows = new ArrayList<>();
|
||||
private final Map<String, Bitmap> avatars = new HashMap<>();
|
||||
private String myUserId;
|
||||
private boolean includeOthers;
|
||||
|
||||
WidgetListFactory(Context context, String mode, int appWidgetId) {
|
||||
this.context = context;
|
||||
this.mode = mode;
|
||||
this.appWidgetId = appWidgetId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {}
|
||||
|
||||
@Override
|
||||
public void onDataSetChanged() {
|
||||
// Runs on a binder thread, so synchronous network work is allowed
|
||||
// here. This is the background-refresh path: the 30-minute
|
||||
// updatePeriodMillis cycle lands here via notifyAppWidgetViewDataChanged.
|
||||
boolean refreshed = WidgetStore.refreshFromServerIfStale(context);
|
||||
|
||||
myUserId = WidgetStore.userId(context);
|
||||
includeOthers = WidgetStore.includeOthers(context, appWidgetId);
|
||||
List<WidgetStore.Task> allTasks = WidgetStore.loadTasks(context);
|
||||
List<WidgetStore.Member> members = WidgetStore.loadMembers(context);
|
||||
|
||||
if (WidgetUi.MODE_PEOPLE.equals(mode)) {
|
||||
rows = buildPeopleRows(allTasks, members);
|
||||
loadAvatars(members);
|
||||
} else {
|
||||
List<WidgetStore.Task> tasks =
|
||||
WidgetStore.visibleTasks(context, allTasks, includeOthers);
|
||||
rows = WidgetUi.MODE_TODAY.equals(mode)
|
||||
? buildTodayRows(tasks)
|
||||
: buildWeekRows(tasks);
|
||||
if (includeOthers) loadAvatars(members);
|
||||
}
|
||||
|
||||
if (refreshed) {
|
||||
// Counts and "Updated …" line live outside the list.
|
||||
WidgetUi.updateHeaders(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve avatars for everyone up front — getViewAt must not block. */
|
||||
private void loadAvatars(List<WidgetStore.Member> members) {
|
||||
avatars.clear();
|
||||
for (WidgetStore.Member member : members) {
|
||||
avatars.put(member.id, AvatarCache.get(context, member));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Row> buildTodayRows(List<WidgetStore.Task> tasks) {
|
||||
List<Row> result = new ArrayList<>();
|
||||
for (WidgetStore.Task task : WidgetStore.todaySubset(tasks)) {
|
||||
result.add(new Row(null, task, null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Row> buildWeekRows(List<WidgetStore.Task> tasks) {
|
||||
List<Row> result = new ArrayList<>();
|
||||
long startOfToday = WidgetStore.endOfDay(-1) + 1;
|
||||
SimpleDateFormat dayFormat = new SimpleDateFormat("EEE, MMM d", Locale.getDefault());
|
||||
|
||||
List<WidgetStore.Task> approvals = new ArrayList<>();
|
||||
List<WidgetStore.Task> scheduled = new ArrayList<>();
|
||||
for (WidgetStore.Task task : tasks) {
|
||||
if (task.approval) approvals.add(task);
|
||||
else if (task.dueDate != null) scheduled.add(task);
|
||||
}
|
||||
|
||||
if (!approvals.isEmpty()) {
|
||||
result.add(new Row(context.getString(R.string.widget_group_approval), null, null));
|
||||
for (WidgetStore.Task task : approvals) result.add(new Row(null, task, null));
|
||||
}
|
||||
|
||||
String currentGroup = null;
|
||||
for (WidgetStore.Task task : scheduled) {
|
||||
String group;
|
||||
if (task.dueDate < startOfToday) {
|
||||
group = context.getString(R.string.widget_group_overdue);
|
||||
} else if (task.dueDate <= WidgetStore.endOfDay(0)) {
|
||||
group = context.getString(R.string.widget_group_today);
|
||||
} else if (task.dueDate <= WidgetStore.endOfDay(1)) {
|
||||
group = context.getString(R.string.widget_group_tomorrow);
|
||||
} else {
|
||||
group = dayFormat.format(new Date(task.dueDate));
|
||||
}
|
||||
if (!group.equals(currentGroup)) {
|
||||
result.add(new Row(group, null, null));
|
||||
currentGroup = group;
|
||||
}
|
||||
result.add(new Row(null, task, null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** One row per member with today/week workloads, busiest first. */
|
||||
private List<Row> buildPeopleRows(List<WidgetStore.Task> tasks,
|
||||
List<WidgetStore.Member> members) {
|
||||
List<Row> result = new ArrayList<>();
|
||||
List<WidgetStore.Task> todayTasks = WidgetStore.todaySubset(tasks);
|
||||
for (WidgetStore.Member member : members) {
|
||||
Row row = new Row(null, null, member);
|
||||
for (WidgetStore.Task task : tasks) {
|
||||
if (member.id.equals(task.assignedTo)) row.weekCount++;
|
||||
}
|
||||
for (WidgetStore.Task task : todayTasks) {
|
||||
if (member.id.equals(task.assignedTo)) row.todayCount++;
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
java.util.Collections.sort(result, (a, b) -> {
|
||||
if (a.todayCount != b.todayCount) return b.todayCount - a.todayCount;
|
||||
if (a.weekCount != b.weekCount) return b.weekCount - a.weekCount;
|
||||
return a.person.name.compareToIgnoreCase(b.person.name);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteViews getViewAt(int position) {
|
||||
if (position < 0 || position >= rows.size()) return null;
|
||||
Row row = rows.get(position);
|
||||
|
||||
if (row.header != null) {
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(),
|
||||
R.layout.widget_row_day_header);
|
||||
views.setTextViewText(R.id.row_day, row.header);
|
||||
return views;
|
||||
}
|
||||
|
||||
if (row.person != null) {
|
||||
return buildPersonRow(row);
|
||||
}
|
||||
|
||||
WidgetStore.Task task = row.task;
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(),
|
||||
R.layout.widget_row_task);
|
||||
views.setTextViewText(R.id.row_title, task.name);
|
||||
|
||||
boolean overdue = !task.approval && task.dueDate != null
|
||||
&& task.dueDate < System.currentTimeMillis();
|
||||
int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
|
||||
int warning = ContextCompat.getColor(context, R.color.widget_warning);
|
||||
int danger = ContextCompat.getColor(context, R.color.widget_overdue);
|
||||
|
||||
String meta;
|
||||
int metaColor;
|
||||
if (task.approval) {
|
||||
meta = context.getString(R.string.widget_meta_approval);
|
||||
metaColor = warning;
|
||||
} else if (overdue) {
|
||||
meta = context.getString(R.string.widget_meta_overdue);
|
||||
metaColor = danger;
|
||||
} else {
|
||||
meta = android.text.format.DateFormat.getTimeFormat(context)
|
||||
.format(new Date(task.dueDate));
|
||||
metaColor = secondary;
|
||||
}
|
||||
views.setTextViewText(R.id.row_meta, meta);
|
||||
views.setTextColor(R.id.row_meta, metaColor);
|
||||
|
||||
int ringColor;
|
||||
if (task.approval) ringColor = warning;
|
||||
else if (overdue || task.priority == 1) ringColor = danger;
|
||||
else if (task.priority == 2) ringColor = warning;
|
||||
else ringColor = ContextCompat.getColor(context, R.color.widget_ring_neutral);
|
||||
views.setInt(R.id.row_ring, "setColorFilter", ringColor);
|
||||
|
||||
// In "everyone" mode, show who a task belongs to (own tasks stay clean).
|
||||
Bitmap avatar = includeOthers && task.assignedTo != null
|
||||
&& !task.assignedTo.equals(myUserId)
|
||||
? avatars.get(task.assignedTo)
|
||||
: null;
|
||||
if (avatar != null) {
|
||||
views.setImageViewBitmap(R.id.row_avatar, avatar);
|
||||
views.setViewVisibility(R.id.row_avatar, View.VISIBLE);
|
||||
} else {
|
||||
views.setViewVisibility(R.id.row_avatar, View.GONE);
|
||||
}
|
||||
|
||||
Intent fillIn = new Intent();
|
||||
fillIn.setData(Uri.parse("donetick://chores/" + task.id));
|
||||
views.setOnClickFillInIntent(R.id.row_root, fillIn);
|
||||
return views;
|
||||
}
|
||||
|
||||
private RemoteViews buildPersonRow(Row row) {
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(),
|
||||
R.layout.widget_row_person);
|
||||
Bitmap avatar = avatars.get(row.person.id);
|
||||
if (avatar == null) avatar = AvatarCache.initials(row.person);
|
||||
views.setImageViewBitmap(R.id.person_avatar, avatar);
|
||||
views.setTextViewText(R.id.person_name, row.person.name);
|
||||
views.setTextViewText(R.id.person_counts, context.getString(
|
||||
R.string.widget_person_counts, row.todayCount, row.weekCount));
|
||||
|
||||
// Highlight the today-count when someone has work due today.
|
||||
int accent = ContextCompat.getColor(context, R.color.widget_accent);
|
||||
int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
|
||||
views.setTextColor(R.id.person_counts, row.todayCount > 0 ? accent : secondary);
|
||||
|
||||
Intent fillIn = new Intent();
|
||||
fillIn.setData(Uri.parse("donetick://chores"));
|
||||
views.setOnClickFillInIntent(R.id.person_root, fillIn);
|
||||
return views;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteViews getLoadingView() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getViewTypeCount() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasStableIds() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared storage + refresh logic for the home-screen widgets.
|
||||
*
|
||||
* The web app pushes a pre-filtered snapshot through WidgetBridgePlugin
|
||||
* whenever its chores cache changes. When the app has not run for a while,
|
||||
* {@link #refreshFromServerIfStale} re-fetches the list directly so the
|
||||
* widget stays current in the background (driven by the 30-minute
|
||||
* updatePeriodMillis cycle).
|
||||
*
|
||||
* Snapshot JSON: {version, lastUpdated,
|
||||
* tasks:[{id, name, dueDate, priority, approval, assignedTo}],
|
||||
* members:[{id, name, image}]}
|
||||
* Config JSON: {serverUrl, token, userId}
|
||||
*
|
||||
* Since v2 the snapshot holds every member's tasks; widgets narrow it down to
|
||||
* the current user unless the per-widget "include others" option is on.
|
||||
*/
|
||||
public final class WidgetStore {
|
||||
private static final String TAG = "DonetickWidget";
|
||||
private static final String PREFS = "donetick_widget";
|
||||
private static final String KEY_DATA = "widget_tasks";
|
||||
private static final String KEY_CONFIG = "widget_config";
|
||||
private static final String KEY_INCLUDE_OTHERS_PREFIX = "include_others_";
|
||||
|
||||
// Same filtering window as src/service/WidgetService.js
|
||||
private static final int WINDOW_DAYS = 7;
|
||||
private static final int MAX_TASKS = 100;
|
||||
// Don't hit the network if the app (or a previous refresh) updated the
|
||||
// snapshot recently; also guards against notify->refresh loops.
|
||||
private static final long STALE_MS = 10 * 60 * 1000;
|
||||
|
||||
private static final Object REFRESH_LOCK = new Object();
|
||||
|
||||
private WidgetStore() {}
|
||||
|
||||
public static class Task {
|
||||
public String id;
|
||||
public String name;
|
||||
public Long dueDate; // epoch millis, null when unscheduled
|
||||
public int priority;
|
||||
public boolean approval;
|
||||
public String assignedTo; // member userId, null when unassigned
|
||||
}
|
||||
|
||||
public static class Member {
|
||||
public String id;
|
||||
public String name;
|
||||
public String image; // avatar URL, may be null
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context context) {
|
||||
return context.getApplicationContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public static void saveData(Context context, String json) {
|
||||
prefs(context).edit().putString(KEY_DATA, json).apply();
|
||||
}
|
||||
|
||||
public static void saveConfig(Context context, String json) {
|
||||
prefs(context).edit().putString(KEY_CONFIG, json).apply();
|
||||
}
|
||||
|
||||
public static void clear(Context context) {
|
||||
prefs(context).edit().clear().apply();
|
||||
}
|
||||
|
||||
public static boolean hasConfig(Context context) {
|
||||
return prefs(context).getString(KEY_CONFIG, null) != null;
|
||||
}
|
||||
|
||||
/** The signed-in user's id from the pushed config, or null. */
|
||||
public static String userId(Context context) {
|
||||
try {
|
||||
String raw = prefs(context).getString(KEY_CONFIG, null);
|
||||
if (raw == null) return null;
|
||||
Object id = new JSONObject(raw).opt("userId");
|
||||
return id == null ? null : String.valueOf(id);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-widget "include tasks assigned to others" option (default off). */
|
||||
public static boolean includeOthers(Context context, int appWidgetId) {
|
||||
return prefs(context).getBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, false);
|
||||
}
|
||||
|
||||
public static void setIncludeOthers(Context context, int appWidgetId, boolean value) {
|
||||
prefs(context).edit()
|
||||
.putBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, value)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public static void removeWidgetOptions(Context context, int appWidgetId) {
|
||||
prefs(context).edit()
|
||||
.remove(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public static long lastUpdated(Context context) {
|
||||
try {
|
||||
String raw = prefs(context).getString(KEY_DATA, null);
|
||||
if (raw == null) return 0;
|
||||
return new JSONObject(raw).optLong("lastUpdated", 0);
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Task> loadTasks(Context context) {
|
||||
List<Task> tasks = new ArrayList<>();
|
||||
try {
|
||||
String raw = prefs(context).getString(KEY_DATA, null);
|
||||
if (raw == null) return tasks;
|
||||
JSONArray arr = new JSONObject(raw).optJSONArray("tasks");
|
||||
if (arr == null) return tasks;
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject obj = arr.optJSONObject(i);
|
||||
if (obj == null) continue;
|
||||
Task task = new Task();
|
||||
task.id = String.valueOf(obj.opt("id"));
|
||||
task.name = obj.optString("name", "");
|
||||
task.dueDate = obj.isNull("dueDate") ? null : obj.optLong("dueDate");
|
||||
task.priority = obj.optInt("priority", 0);
|
||||
task.approval = obj.optBoolean("approval", false);
|
||||
// v1 snapshots carried only the user's own tasks and had no
|
||||
// assignedTo — treat those rows as "mine".
|
||||
task.assignedTo = obj.has("assignedTo")
|
||||
? (obj.isNull("assignedTo") ? null : String.valueOf(obj.opt("assignedTo")))
|
||||
: userId(context);
|
||||
tasks.add(task);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to parse widget snapshot", e);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public static List<Member> loadMembers(Context context) {
|
||||
List<Member> members = new ArrayList<>();
|
||||
try {
|
||||
String raw = prefs(context).getString(KEY_DATA, null);
|
||||
if (raw == null) return members;
|
||||
JSONArray arr = new JSONObject(raw).optJSONArray("members");
|
||||
if (arr == null) return members;
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject obj = arr.optJSONObject(i);
|
||||
if (obj == null) continue;
|
||||
Member member = new Member();
|
||||
member.id = String.valueOf(obj.opt("id"));
|
||||
member.name = obj.optString("name", "");
|
||||
member.image = obj.isNull("image") ? null : obj.optString("image", null);
|
||||
members.add(member);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to parse widget members", e);
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks a today/week widget should render: everything when includeOthers,
|
||||
* otherwise the user's own tasks plus approvals (which wait on them).
|
||||
*/
|
||||
public static List<Task> visibleTasks(Context context, List<Task> tasks, boolean includeOthers) {
|
||||
if (includeOthers) return tasks;
|
||||
String me = userId(context);
|
||||
List<Task> mine = new ArrayList<>();
|
||||
for (Task task : tasks) {
|
||||
if (task.approval || (me != null && me.equals(task.assignedTo))) {
|
||||
mine.add(task);
|
||||
}
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
|
||||
/** Tasks the Today widget shows: awaiting approval, overdue, or due today. */
|
||||
public static List<Task> todaySubset(List<Task> tasks) {
|
||||
long endOfToday = endOfDay(0);
|
||||
List<Task> subset = new ArrayList<>();
|
||||
for (Task task : tasks) {
|
||||
if (task.approval || (task.dueDate != null && task.dueDate <= endOfToday)) {
|
||||
subset.add(task);
|
||||
}
|
||||
}
|
||||
return subset;
|
||||
}
|
||||
|
||||
public static long endOfDay(int daysFromNow) {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.DAY_OF_YEAR, daysFromNow);
|
||||
cal.set(Calendar.HOUR_OF_DAY, 23);
|
||||
cal.set(Calendar.MINUTE, 59);
|
||||
cal.set(Calendar.SECOND, 59);
|
||||
cal.set(Calendar.MILLISECOND, 999);
|
||||
return cal.getTimeInMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch /chores/ from the configured server and rebuild the snapshot.
|
||||
* Returns true when a network refresh actually happened and succeeded.
|
||||
* Safe to call from RemoteViewsFactory.onDataSetChanged (binder thread).
|
||||
*/
|
||||
public static boolean refreshFromServerIfStale(Context context) {
|
||||
synchronized (REFRESH_LOCK) {
|
||||
long age = System.currentTimeMillis() - lastUpdated(context);
|
||||
if (age < STALE_MS) return false;
|
||||
|
||||
String rawConfig = prefs(context).getString(KEY_CONFIG, null);
|
||||
if (rawConfig == null) return false;
|
||||
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
JSONObject config = new JSONObject(rawConfig);
|
||||
String serverUrl = config.optString("serverUrl", "");
|
||||
String token = config.optString("token", "");
|
||||
if (serverUrl.isEmpty() || token.isEmpty()) return false;
|
||||
|
||||
URL url = new URL(serverUrl + "/chores/");
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(10000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
|
||||
if (connection.getResponseCode() != 200) {
|
||||
// Expired token or server trouble — keep the last snapshot,
|
||||
// the UI surfaces staleness via the "Updated …" line.
|
||||
Log.w(TAG, "Widget refresh got HTTP " + connection.getResponseCode());
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONArray chores = new JSONObject(readAll(connection.getInputStream()))
|
||||
.optJSONArray("res");
|
||||
if (chores == null) return false;
|
||||
|
||||
// The chores endpoint has no member profiles, so carry the
|
||||
// member list over from the previous snapshot (it changes
|
||||
// rarely and the app re-pushes it on every open).
|
||||
JSONArray members = null;
|
||||
String previous = prefs(context).getString(KEY_DATA, null);
|
||||
if (previous != null) {
|
||||
members = new JSONObject(previous).optJSONArray("members");
|
||||
}
|
||||
|
||||
JSONObject snapshot = new JSONObject();
|
||||
snapshot.put("version", 2);
|
||||
snapshot.put("lastUpdated", System.currentTimeMillis());
|
||||
snapshot.put("tasks", filterChores(chores));
|
||||
snapshot.put("members", members == null ? new JSONArray() : members);
|
||||
saveData(context, snapshot.toString());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Widget background refresh failed", e);
|
||||
return false;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror of buildWidgetTasks in src/service/WidgetService.js. */
|
||||
private static JSONArray filterChores(JSONArray chores) throws Exception {
|
||||
long cutoff = endOfDay(WINDOW_DAYS);
|
||||
List<JSONObject> selected = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < chores.length(); i++) {
|
||||
JSONObject chore = chores.optJSONObject(i);
|
||||
if (chore == null || chore.opt("id") == null) continue;
|
||||
|
||||
boolean approval = chore.optInt("status", 0) == 3;
|
||||
Long dueDate = parseDate(chore.optString("nextDueDate", null));
|
||||
boolean inWindow = dueDate != null && dueDate <= cutoff;
|
||||
if (!approval && !inWindow) continue;
|
||||
|
||||
JSONObject task = new JSONObject();
|
||||
task.put("id", chore.opt("id"));
|
||||
task.put("name", chore.optString("name", ""));
|
||||
task.put("dueDate", dueDate == null ? JSONObject.NULL : dueDate);
|
||||
task.put("priority", chore.optInt("priority", 0));
|
||||
task.put("approval", approval);
|
||||
task.put("assignedTo", chore.isNull("assignedTo")
|
||||
? JSONObject.NULL
|
||||
: String.valueOf(chore.opt("assignedTo")));
|
||||
selected.add(task);
|
||||
}
|
||||
|
||||
Collections.sort(selected, (a, b) -> {
|
||||
boolean aApproval = a.optBoolean("approval");
|
||||
boolean bApproval = b.optBoolean("approval");
|
||||
if (aApproval != bApproval) return aApproval ? -1 : 1;
|
||||
long aDue = a.isNull("dueDate") ? Long.MAX_VALUE : a.optLong("dueDate");
|
||||
long bDue = b.isNull("dueDate") ? Long.MAX_VALUE : b.optLong("dueDate");
|
||||
return Long.compare(aDue, bDue);
|
||||
});
|
||||
|
||||
JSONArray result = new JSONArray();
|
||||
for (int i = 0; i < selected.size() && i < MAX_TASKS; i++) {
|
||||
result.put(selected.get(i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Long parseDate(String value) {
|
||||
if (value == null || value.isEmpty() || "null".equals(value)) return null;
|
||||
try {
|
||||
return OffsetDateTime.parse(value).toInstant().toEpochMilli();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String readAll(InputStream stream) throws Exception {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal file
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal file
@@ -0,0 +1,141 @@
|
||||
package com.donetick.app.widget;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.donetick.app.MainActivity;
|
||||
import com.donetick.app.R;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Builds the shell RemoteViews shared by the widgets and pushes updates. */
|
||||
public final class WidgetUi {
|
||||
public static final String MODE_TODAY = "today";
|
||||
public static final String MODE_WEEK = "week";
|
||||
public static final String MODE_PEOPLE = "people";
|
||||
public static final String EXTRA_MODE = "com.donetick.app.widget.MODE";
|
||||
|
||||
private WidgetUi() {}
|
||||
|
||||
/** Full refresh: redraw headers and reload list content (used by the JS bridge). */
|
||||
public static void refreshAll(Context context) {
|
||||
updateHeaders(context);
|
||||
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
||||
manager.notifyAppWidgetViewDataChanged(
|
||||
widgetIds(context, manager, TodayWidgetProvider.class), R.id.widget_list);
|
||||
manager.notifyAppWidgetViewDataChanged(
|
||||
widgetIds(context, manager, WeekWidgetProvider.class), R.id.widget_list);
|
||||
manager.notifyAppWidgetViewDataChanged(
|
||||
widgetIds(context, manager, PeopleWidgetProvider.class), R.id.widget_list);
|
||||
}
|
||||
|
||||
/** Redraw title/count/subtitle only — safe to call from the list factory. */
|
||||
public static void updateHeaders(Context context) {
|
||||
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
||||
for (int id : widgetIds(context, manager, TodayWidgetProvider.class)) {
|
||||
manager.updateAppWidget(id, build(context, MODE_TODAY, id));
|
||||
}
|
||||
for (int id : widgetIds(context, manager, WeekWidgetProvider.class)) {
|
||||
manager.updateAppWidget(id, build(context, MODE_WEEK, id));
|
||||
}
|
||||
for (int id : widgetIds(context, manager, PeopleWidgetProvider.class)) {
|
||||
manager.updateAppWidget(id, build(context, MODE_PEOPLE, id));
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] widgetIds(Context context, AppWidgetManager manager, Class<?> provider) {
|
||||
return manager.getAppWidgetIds(new ComponentName(context, provider));
|
||||
}
|
||||
|
||||
public static RemoteViews build(Context context, String mode, int appWidgetId) {
|
||||
boolean today = MODE_TODAY.equals(mode);
|
||||
boolean people = MODE_PEOPLE.equals(mode);
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_shell);
|
||||
|
||||
int titleRes = today ? R.string.widget_today_title
|
||||
: people ? R.string.widget_people_title
|
||||
: R.string.widget_week_title;
|
||||
views.setTextViewText(R.id.widget_title, context.getString(titleRes));
|
||||
|
||||
if (people) {
|
||||
views.setViewVisibility(R.id.widget_count, View.GONE);
|
||||
} else {
|
||||
boolean includeOthers = WidgetStore.includeOthers(context, appWidgetId);
|
||||
List<WidgetStore.Task> tasks = WidgetStore.visibleTasks(
|
||||
context, WidgetStore.loadTasks(context), includeOthers);
|
||||
int count = (today ? WidgetStore.todaySubset(tasks) : tasks).size();
|
||||
views.setTextViewText(R.id.widget_count, String.valueOf(count));
|
||||
views.setViewVisibility(R.id.widget_count, count > 0 ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
views.setTextViewText(R.id.widget_subtitle, subtitle(context));
|
||||
|
||||
if (!WidgetStore.hasConfig(context)) {
|
||||
views.setTextViewText(R.id.widget_empty,
|
||||
context.getString(R.string.widget_signed_out));
|
||||
} else {
|
||||
int emptyRes = today ? R.string.widget_empty_today
|
||||
: people ? R.string.widget_empty_people
|
||||
: R.string.widget_empty_week;
|
||||
views.setTextViewText(R.id.widget_empty, context.getString(emptyRes));
|
||||
}
|
||||
|
||||
// The Today widget gets a quick-add button that deep links straight
|
||||
// into the in-app AddTaskModal.
|
||||
if (today) {
|
||||
Intent addTask = new Intent(context, MainActivity.class);
|
||||
addTask.setAction(Intent.ACTION_VIEW);
|
||||
addTask.setData(Uri.parse("donetick://chores/add"));
|
||||
views.setOnClickPendingIntent(R.id.widget_add, PendingIntent.getActivity(
|
||||
context, 2, addTask,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
||||
views.setViewVisibility(R.id.widget_add, View.VISIBLE);
|
||||
} else {
|
||||
views.setViewVisibility(R.id.widget_add, View.GONE);
|
||||
}
|
||||
|
||||
// List content comes from WidgetListService; mode travels in the intent
|
||||
// and the unique data URI keeps the adapters from being collapsed.
|
||||
Intent adapterIntent = new Intent(context, WidgetListService.class);
|
||||
adapterIntent.putExtra(EXTRA_MODE, mode);
|
||||
adapterIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
adapterIntent.setData(Uri.parse("donetickwidget://" + mode + "/" + appWidgetId));
|
||||
views.setRemoteAdapter(R.id.widget_list, adapterIntent);
|
||||
views.setEmptyView(R.id.widget_list, R.id.widget_empty);
|
||||
|
||||
// Row taps deep link into the chore view; the row's fill-in intent
|
||||
// supplies the donetick://chores/<id> data URI.
|
||||
Intent rowTemplate = new Intent(context, MainActivity.class);
|
||||
rowTemplate.setAction(Intent.ACTION_VIEW);
|
||||
views.setPendingIntentTemplate(R.id.widget_list, PendingIntent.getActivity(
|
||||
context, 1, rowTemplate,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE));
|
||||
|
||||
// Anywhere else on the widget simply opens the app.
|
||||
Intent openApp = new Intent(context, MainActivity.class);
|
||||
views.setOnClickPendingIntent(R.id.widget_container, PendingIntent.getActivity(
|
||||
context, 0, openApp,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
private static String subtitle(Context context) {
|
||||
String date = new SimpleDateFormat("EEE, MMM d", Locale.getDefault())
|
||||
.format(new Date());
|
||||
long lastUpdated = WidgetStore.lastUpdated(context);
|
||||
if (lastUpdated <= 0) return date;
|
||||
String time = android.text.format.DateFormat.getTimeFormat(context)
|
||||
.format(new Date(lastUpdated));
|
||||
return date + " · " + context.getString(R.string.widget_updated_at, time);
|
||||
}
|
||||
}
|
||||
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"sessions":{}}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_quick.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_quick.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_today.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_today.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_week.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_week.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/widget_bg" />
|
||||
<!-- Match the launcher's system-wide widget corner radius on Android 12+ -->
|
||||
<corners android:radius="@android:dimen/system_app_widget_background_radius" />
|
||||
</shape>
|
||||
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal file
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/widget_accent">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||
</vector>
|
||||
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal file
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/widget_accent">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
|
||||
</vector>
|
||||
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal file
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/widget_accent">
|
||||
<!-- Framing corners plus a scan line: reads as "scan" at widget size. -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,4h5v2H6v3H4V4zM15,4h5v5h-2V6h-3V4zM4,15h2v3h3v2H4V15zM18,15h2v5h-5v-2h3V15z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,11h18v2H3V11z" />
|
||||
</vector>
|
||||
5
android/app/src/main/res/drawable/widget_background.xml
Normal file
5
android/app/src/main/res/drawable/widget_background.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/widget_bg" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal file
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/widget_accent_soft" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Initials disc stand-in for the picker previews; placed widgets get the
|
||||
real cached avatars from AvatarCache. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@color/widget_accent_soft" />
|
||||
<size android:width="28dp" android:height="28dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/widget_ring.xml
Normal file
6
android/app/src/main/res/drawable/widget_ring.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:width="2dp" android:color="#FFFFFF" />
|
||||
<size android:width="18dp" android:height="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal file
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Tappable tile on the Quick Capture widget. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/widget_accent_soft" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
43
android/app/src/main/res/layout/widget_config.xml
Normal file
43
android/app/src/main/res/layout/widget_config.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/widget_bg"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_config_title"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/config_include_others"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:minHeight="48dp"
|
||||
android:text="@string/widget_config_include_others"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/widget_config_include_others_hint"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/config_save"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:backgroundTint="@color/widget_accent"
|
||||
android:text="@string/widget_config_save"
|
||||
android:textColor="#FFFFFF" />
|
||||
</LinearLayout>
|
||||
182
android/app/src/main/res/layout/widget_preview_people.xml
Normal file
182
android/app/src/main/res/layout/widget_preview_people.xml
Normal file
@@ -0,0 +1,182 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Widget-picker preview for the People widget. See widget_preview_today.xml
|
||||
for why the real shell can't be previewed directly. Avatars are initials
|
||||
discs here; placed widgets get the real photos from AvatarCache.
|
||||
Keep in visual sync with widget_shell.xml + widget_row_person.xml. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@+id/preview_count"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_people_title"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:gravity="center"
|
||||
android:minWidth="22dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/widget_preview_count_people"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="1dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_updated"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:background="@drawable/widget_preview_avatar"
|
||||
android:gravity="center"
|
||||
android:text="@string/widget_preview_initials_1"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_person_1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_counts_1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:background="@drawable/widget_preview_avatar"
|
||||
android:gravity="center"
|
||||
android:text="@string/widget_preview_initials_2"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_person_2"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_counts_2"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:background="@drawable/widget_preview_avatar"
|
||||
android:gravity="center"
|
||||
android:text="@string/widget_preview_initials_3"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_person_3"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_counts_3"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
182
android/app/src/main/res/layout/widget_preview_today.xml
Normal file
182
android/app/src/main/res/layout/widget_preview_today.xml
Normal file
@@ -0,0 +1,182 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Widget-picker preview for the Today widget. Mirrors widget_shell.xml, but
|
||||
with literal sample rows: the real shell fills its ListView from a
|
||||
RemoteViewsFactory, which the picker never binds, so it would render blank.
|
||||
Keep in visual sync with widget_shell.xml + widget_row_task.xml. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@+id/preview_count"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_today_title"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@+id/preview_add"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:gravity="center"
|
||||
android:minWidth="22dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/widget_preview_count_today"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview_add"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="6dp"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:contentDescription="@string/widget_add_task"
|
||||
android:padding="5dp"
|
||||
android:src="@drawable/ic_widget_add" />
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="1dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_updated"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_overdue" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_meta_overdue"
|
||||
android:textColor="@color/widget_overdue"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_ring_neutral" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_2"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_meta_1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_ring_neutral" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_3"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_meta_2"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
193
android/app/src/main/res/layout/widget_preview_week.xml
Normal file
193
android/app/src/main/res/layout/widget_preview_week.xml
Normal file
@@ -0,0 +1,193 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Widget-picker preview for the Next 7 Days widget. See widget_preview_today.xml
|
||||
for why the real shell can't be previewed directly. Keep in visual sync with
|
||||
widget_shell.xml + widget_row_day_header.xml + widget_row_task.xml. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@+id/preview_count"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_week_title"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:gravity="center"
|
||||
android:minWidth="22dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/widget_preview_count_week"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="1dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_updated"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:letterSpacing="0.08"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/widget_group_today"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_ring_neutral" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_meta_1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_ring_neutral" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_2"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_meta_2"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.08"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@string/widget_group_tomorrow"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring"
|
||||
android:tint="@color/widget_ring_neutral" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_preview_task_3"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_meta_1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal file
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal file
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Quick Capture widget: three one-tap ways into the add-task flow. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/quick_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/quick_type"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/widget_tile_bg"
|
||||
android:contentDescription="@string/widget_quick_type"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/widget_quick_type"
|
||||
android:src="@drawable/ic_widget_add" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_quick_type"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/quick_scan"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/widget_tile_bg"
|
||||
android:contentDescription="@string/widget_quick_scan"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/widget_quick_scan"
|
||||
android:src="@drawable/ic_widget_scan" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_quick_scan"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/quick_voice"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/widget_tile_bg"
|
||||
android:contentDescription="@string/widget_quick_voice"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/widget_quick_voice"
|
||||
android:src="@drawable/ic_widget_mic" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/widget_quick_voice"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal file
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/row_day"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.08"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
37
android/app/src/main/res/layout/widget_row_person.xml
Normal file
37
android/app/src/main/res/layout/widget_row_person.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/person_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="44dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/person_avatar"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/person_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/person_counts"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
46
android/app/src/main/res/layout/widget_row_task.xml
Normal file
46
android/app/src/main/res/layout/widget_row_task.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/row_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/row_ring"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_ring" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/row_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/row_meta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<!-- Assignee avatar, shown only in "everyone" mode for others' tasks. -->
|
||||
<ImageView
|
||||
android:id="@+id/row_avatar"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
93
android/app/src/main/res/layout/widget_shell.xml
Normal file
93
android/app/src/main/res/layout/widget_shell.xml
Normal file
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@id/widget_count"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toStartOf="@+id/widget_add"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:minWidth="22dp"
|
||||
android:gravity="center"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Quick-add, shown on the Today widget only (deep links to the
|
||||
in-app AddTaskModal). Gone by default so the other widgets keep
|
||||
the count pill flush with the edge. -->
|
||||
<ImageView
|
||||
android:id="@+id/widget_add"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="6dp"
|
||||
android:background="@drawable/widget_count_bg"
|
||||
android:contentDescription="@string/widget_add_task"
|
||||
android:padding="5dp"
|
||||
android:src="@drawable/ic_widget_add"
|
||||
android:visibility="gone" />
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="1dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/widget_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="6dp"
|
||||
android:divider="@null"
|
||||
android:dividerHeight="0dp"
|
||||
android:listSelector="@android:color/transparent"
|
||||
android:scrollbars="none" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
12
android/app/src/main/res/values-night/colors.xml
Normal file
12
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_bg">#181C20</color>
|
||||
<color name="widget_text_primary">#F0F4F8</color>
|
||||
<color name="widget_text_secondary">#9FA6AD</color>
|
||||
<color name="widget_accent">#4B9BE8</color>
|
||||
<color name="widget_accent_soft">#12395F</color>
|
||||
<color name="widget_overdue">#F09898</color>
|
||||
<color name="widget_warning">#F3C896</color>
|
||||
<color name="widget_ring_neutral">#555E68</color>
|
||||
<color name="widget_divider">#252B31</color>
|
||||
</resources>
|
||||
13
android/app/src/main/res/values/colors.xml
Normal file
13
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Home-screen widget palette (values-night overrides for dark mode) -->
|
||||
<color name="widget_bg">#FFFFFF</color>
|
||||
<color name="widget_text_primary">#171A1C</color>
|
||||
<color name="widget_text_secondary">#6B7681</color>
|
||||
<color name="widget_accent">#0B6BCB</color>
|
||||
<color name="widget_accent_soft">#E3EFFB</color>
|
||||
<color name="widget_overdue">#C41C1C</color>
|
||||
<color name="widget_warning">#B26A00</color>
|
||||
<color name="widget_ring_neutral">#B8C0C9</color>
|
||||
<color name="widget_divider">#EDF1F5</color>
|
||||
</resources>
|
||||
@@ -4,4 +4,58 @@
|
||||
<string name="title_activity_main">Donetick</string>
|
||||
<string name="package_name">com.donetick.app</string>
|
||||
<string name="custom_url_scheme">com.donetick.app</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_today_label">Today</string>
|
||||
<string name="widget_week_label">Next 7 Days</string>
|
||||
<string name="widget_today_description">Your tasks due today, plus anything waiting on you.</string>
|
||||
<string name="widget_week_description">Your tasks for the next 7 days, grouped by day.</string>
|
||||
<string name="widget_today_title">Today</string>
|
||||
<string name="widget_week_title">Next 7 days</string>
|
||||
<string name="widget_empty_today">All caught up!\nNothing due today</string>
|
||||
<string name="widget_empty_week">All caught up!\nNothing due this week</string>
|
||||
<string name="widget_signed_out">Open Donetick and sign in\nto see your tasks</string>
|
||||
<string name="widget_meta_overdue">Overdue</string>
|
||||
<string name="widget_meta_approval">Approve</string>
|
||||
<string name="widget_group_approval">Needs approval</string>
|
||||
<string name="widget_group_overdue">Overdue</string>
|
||||
<string name="widget_group_today">Today</string>
|
||||
<string name="widget_group_tomorrow">Tomorrow</string>
|
||||
<string name="widget_updated_at">Updated %s</string>
|
||||
<string name="widget_add_task">Add task</string>
|
||||
<string name="widget_people_label">People</string>
|
||||
<string name="widget_people_description">Everyone in your circle with their tasks for today and the week ahead.</string>
|
||||
<string name="widget_people_title">People</string>
|
||||
<string name="widget_empty_people">No circle members yet</string>
|
||||
<string name="widget_person_counts">%1$d today · %2$d this week</string>
|
||||
<string name="widget_quick_label">Quick Capture</string>
|
||||
<string name="widget_quick_description">Capture a task in one tap — type it, scan it, or say it.</string>
|
||||
<string name="widget_quick_type">Type</string>
|
||||
<string name="widget_quick_scan">Scan</string>
|
||||
<string name="widget_quick_voice">Speak</string>
|
||||
<string name="widget_config_title">Widget options</string>
|
||||
<string name="widget_config_include_others">Show everyone\'s tasks</string>
|
||||
<string name="widget_config_include_others_hint">Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.</string>
|
||||
<string name="widget_config_save">Save</string>
|
||||
|
||||
<!-- Sample data for the widget-picker previews (never shown on a placed
|
||||
widget). Kept generic so it reads well in any locale. -->
|
||||
<string name="widget_preview_updated">Updated just now</string>
|
||||
<string name="widget_preview_task_1">Take out the bins</string>
|
||||
<string name="widget_preview_task_2">Water the plants</string>
|
||||
<string name="widget_preview_task_3">Book the vet</string>
|
||||
<string name="widget_preview_meta_1">6:00 PM</string>
|
||||
<string name="widget_preview_meta_2">8:30 PM</string>
|
||||
<string name="widget_preview_count_today">3</string>
|
||||
<string name="widget_preview_count_week">7</string>
|
||||
<string name="widget_preview_count_people">4</string>
|
||||
<string name="widget_preview_person_1">Alex</string>
|
||||
<string name="widget_preview_person_2">Sam</string>
|
||||
<string name="widget_preview_person_3">Jordan</string>
|
||||
<string name="widget_preview_initials_1">A</string>
|
||||
<string name="widget_preview_initials_2">S</string>
|
||||
<string name="widget_preview_initials_3">J</string>
|
||||
<string name="widget_preview_counts_1">2 today · 5 this week</string>
|
||||
<string name="widget_preview_counts_2">1 today · 3 this week</string>
|
||||
<string name="widget_preview_counts_3">0 today · 2 this week</string>
|
||||
</resources>
|
||||
|
||||
15
android/app/src/main/res/xml/network_security_config.xml
Normal file
15
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!--
|
||||
Trust both the system CA store and user-installed CA certificates.
|
||||
This lets self-hosted Donetick instances that use custom or self-signed
|
||||
certificates work once the user installs their CA on the device, without
|
||||
having to modify/repackage the APK. See donetick/donetick#727.
|
||||
-->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
15
android/app/src/main/res/xml/widget_people_info.xml
Normal file
15
android/app/src/main/res/xml/widget_people_info.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_people_description"
|
||||
android:initialLayout="@layout/widget_shell"
|
||||
android:previewLayout="@layout/widget_preview_people"
|
||||
android:previewImage="@drawable/widget_preview_people"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
17
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal file
17
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The layout is fully static (no bound data), so it doubles as its own
|
||||
API 31+ preview. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_quick_description"
|
||||
android:initialLayout="@layout/widget_quick_capture"
|
||||
android:previewLayout="@layout/widget_quick_capture"
|
||||
android:previewImage="@drawable/widget_preview_quick"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="70dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="60dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
||||
17
android/app/src/main/res/xml/widget_today_info.xml
Normal file
17
android/app/src/main/res/xml/widget_today_info.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_today_description"
|
||||
android:initialLayout="@layout/widget_shell"
|
||||
android:previewLayout="@layout/widget_preview_today"
|
||||
android:previewImage="@drawable/widget_preview_today"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="com.donetick.app.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:widgetCategory="home_screen" />
|
||||
17
android/app/src/main/res/xml/widget_week_info.xml
Normal file
17
android/app/src/main/res/xml/widget_week_info.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_week_description"
|
||||
android:initialLayout="@layout/widget_shell"
|
||||
android:previewLayout="@layout/widget_preview_week"
|
||||
android:previewImage="@drawable/widget_preview_week"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="3"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="com.donetick.app.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -22,6 +22,12 @@ allprojects {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
// The app does not use Advertising ID. Exclude RevenueCat's transitive
|
||||
// ads identifier dependency so Play Console does not require an AD_ID declaration.
|
||||
exclude group: 'com.google.android.gms', module: 'play-services-ads-identifier'
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-in-app-review'
|
||||
project(':capacitor-community-in-app-review').projectDir = new File('../node_modules/@capacitor-community/in-app-review/android')
|
||||
|
||||
include ':capacitor-community-speech-recognition'
|
||||
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
|
||||
|
||||
include ':capacitor-community-sqlite'
|
||||
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
|
||||
|
||||
@@ -14,6 +20,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-haptics'
|
||||
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||
|
||||
include ':capacitor-local-llm'
|
||||
project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android')
|
||||
|
||||
|
||||
@@ -16,6 +16,22 @@ const config: CapacitorConfig = {
|
||||
iconColor: '#488AFF',
|
||||
sound: 'beep.wav',
|
||||
},
|
||||
CapacitorSQLite: {
|
||||
// The offline cache opens databases with encrypted: false / no-encryption.
|
||||
// @capacitor-community/sqlite defaults native encryption to true when this
|
||||
// block is absent; on Android that can make plugin load fail with
|
||||
// "CapacitorSQLitePlugin: null" before JS gets a chance to open the DB.
|
||||
iosIsEncryption: false,
|
||||
androidIsEncryption: false,
|
||||
},
|
||||
SocialLogin: {
|
||||
providers: {
|
||||
google: true,
|
||||
facebook: false,
|
||||
apple: true,
|
||||
twitter: false,
|
||||
},
|
||||
},
|
||||
// GoogleAuth: {
|
||||
// scopes: ['profile', 'email', 'openid'],
|
||||
// clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
|
||||
187
eslint.config.mjs
Normal file
187
eslint.config.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
import js from '@eslint/js'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import queryPlugin from '@tanstack/eslint-plugin-query'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier/flat'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import sortDestructureKeys from 'eslint-plugin-sort-destructure-keys'
|
||||
import tailwind from 'eslint-plugin-tailwindcss'
|
||||
import globals from 'globals'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
react.configs.flat.recommended,
|
||||
{
|
||||
...react.configs.flat['jsx-runtime'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/exports': 'error',
|
||||
'simple-import-sort/imports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'sort-destructure-keys': sortDestructureKeys,
|
||||
},
|
||||
rules: {
|
||||
'sort-destructure-keys/sort-destructure-keys': 'error',
|
||||
},
|
||||
},
|
||||
...tailwind.configs['flat/recommended'],
|
||||
...queryPlugin.configs['flat/recommended'],
|
||||
eslintConfigPrettier, // Disable any rules that conflict with prettier
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
files: ['*.{js,ts}'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.config.{mjs,ts}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
eqeqeq: 'error',
|
||||
indent: ['error', 2],
|
||||
'space-infix-ops': ['error', { int32Hint: false }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: globals.browser,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/brace-style': 'error',
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/keyword-spacing': 'error',
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/space-in-parens': 'error',
|
||||
'@stylistic/space-infix-ops': ['error', { int32Hint: false }],
|
||||
'@stylistic/type-annotation-spacing': [
|
||||
'error',
|
||||
{
|
||||
after: true,
|
||||
before: true,
|
||||
overrides: {
|
||||
colon: {
|
||||
before: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
eqeqeq: 'warn',
|
||||
'max-depth': ['error', { max: 5 }],
|
||||
'max-len': ['warn', { code: 120 }],
|
||||
'max-lines': [
|
||||
'error',
|
||||
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||
],
|
||||
'max-lines-per-function': ['warn', { max: 500 }],
|
||||
'no-constant-condition': 'warn',
|
||||
'no-loss-of-precision': 'error',
|
||||
'no-undef': 'warn',
|
||||
'no-unused-vars': 'off', // @typescript-eslint/no-unused-vars
|
||||
'space-infix-ops': 'off', // @stylistic/space-infix-ops
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'@typescript-eslint/no-duplicate-enum-values': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-inferrable-types': 'error',
|
||||
'@typescript-eslint/no-mixed-enums': 'error',
|
||||
'@typescript-eslint/no-require-imports': 'error',
|
||||
'@typescript-eslint/no-type-alias': [
|
||||
'error',
|
||||
{
|
||||
allowAliases: 'always',
|
||||
allowCallbacks: 'always',
|
||||
allowConditionalTypes: 'never',
|
||||
allowConstructors: 'never',
|
||||
allowGenerics: 'always',
|
||||
allowLiterals: 'always',
|
||||
allowMappedTypes: 'never',
|
||||
allowTupleTypes: 'always',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/prefer-for-of': 'warn',
|
||||
'@typescript-eslint/prefer-function-type': 'error',
|
||||
'@typescript-eslint/prefer-includes': 'error',
|
||||
'@typescript-eslint/prefer-literal-enum-member': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Reduce existing errors to warnings
|
||||
plugins: {
|
||||
'@tanstack/query': queryPlugin,
|
||||
},
|
||||
rules: {
|
||||
'@tanstack/query/exhaustive-deps': 'warn',
|
||||
'no-case-declarations': 'warn',
|
||||
'no-empty': 'warn',
|
||||
'no-redeclare': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/preserve-manual-memoization': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/static-components': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'react/jsx-no-undef': 'warn',
|
||||
'react/no-unescaped-entities': 'warn',
|
||||
'react/prop-types': 'warn',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -26,6 +26,7 @@ platform :ios do
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: {
|
||||
"com.donetick.app" => "Donetick App Store(fastline)",
|
||||
"com.donetick.app.widget" => "Donetick Widget App Store(fastline)",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D1115B482C653D60004C6043 /* GoogleService-Info.plist */; };
|
||||
D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */; };
|
||||
D0AC000000000000000000B2 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A2 /* MainViewController.swift */; };
|
||||
D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A3 /* DonetickWidget.swift */; };
|
||||
D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -35,8 +39,38 @@
|
||||
D1115B482C653D60004C6043 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
D1115B4A2C659D51004C6043 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = "<group>"; };
|
||||
D0AC000000000000000000A2 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
||||
D0AC000000000000000000A3 /* DonetickWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonetickWidget.swift; sourceTree = "<group>"; };
|
||||
D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DonetickWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0AC000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D0AC000000000000000000A6 /* DonetickWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DonetickWidget.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
D0AC000000000000000000C2 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
D0AC000000000000000000E2 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D0AC000000000000000000D1;
|
||||
remoteInfo = DonetickWidgetExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
@@ -46,6 +80,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0AC000000000000000000D3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -61,6 +102,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3061FED79650016851F /* App */,
|
||||
D0AC000000000000000000C1 /* DonetickWidget */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
@@ -68,10 +110,21 @@
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D0AC000000000000000000C1 /* DonetickWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0AC000000000000000000A3 /* DonetickWidget.swift */,
|
||||
D0AC000000000000000000A5 /* Info.plist */,
|
||||
D0AC000000000000000000A6 /* DonetickWidget.entitlements */,
|
||||
);
|
||||
path = DonetickWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -82,6 +135,8 @@
|
||||
D1115B4A2C659D51004C6043 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
D0AC000000000000000000A2 /* MainViewController.swift */,
|
||||
D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
@@ -114,16 +169,35 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||
D0AC000000000000000000C2 /* Embed App Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
D0AC000000000000000000E1 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
D0AC000000000000000000D1 /* DonetickWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */;
|
||||
buildPhases = (
|
||||
D0AC000000000000000000D2 /* Sources */,
|
||||
D0AC000000000000000000D3 /* Frameworks */,
|
||||
D0AC000000000000000000D4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = DonetickWidgetExtension;
|
||||
productName = DonetickWidgetExtension;
|
||||
productReference = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -138,6 +212,10 @@
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
D0AC000000000000000000D1 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
@@ -156,6 +234,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
D0AC000000000000000000D1 /* DonetickWidgetExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -176,6 +255,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0AC000000000000000000D4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -220,11 +306,29 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
D0AC000000000000000000B2 /* MainViewController.swift in Sources */,
|
||||
D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D0AC000000000000000000D2 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
D0AC000000000000000000E1 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D0AC000000000000000000D1 /* DonetickWidgetExtension */;
|
||||
targetProxy = D0AC000000000000000000E2 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -358,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -381,12 +485,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -395,6 +499,48 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D0AC000000000000000000F2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D0AC000000000000000000F3 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -416,6 +562,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D0AC000000000000000000F2 /* Debug */,
|
||||
D0AC000000000000000000F3 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.donetick.app</string>
|
||||
</array>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="MainViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<string>This app needs access to camera to take photos to attach to task or use as profile photo</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to photo library to select images to attach to task or use as profile photo</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app uses the microphone to let you create tasks by speaking</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>This app uses on-device speech recognition to turn your voice into tasks</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
9
ios/App/App/MainViewController.swift
Normal file
9
ios/App/App/MainViewController.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Capacitor
|
||||
import UIKit
|
||||
|
||||
/// Registers app-local Capacitor plugins; referenced from Main.storyboard.
|
||||
class MainViewController: CAPBridgeViewController {
|
||||
override func capacitorDidLoad() {
|
||||
bridge?.registerPluginInstance(WidgetBridgePlugin())
|
||||
}
|
||||
}
|
||||
46
ios/App/App/WidgetBridgePlugin.swift
Normal file
46
ios/App/App/WidgetBridgePlugin.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
/// JS bridge for the home-screen widgets (see src/service/WidgetService.js).
|
||||
/// Persists the task snapshot + API config in the shared App Group so the
|
||||
/// widget extension can read them, then asks WidgetKit to redraw.
|
||||
@objc(WidgetBridgePlugin)
|
||||
public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "WidgetBridgePlugin"
|
||||
public let jsName = "WidgetBridge"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
static let appGroup = "group.com.donetick.app"
|
||||
static let dataKey = "widget_tasks"
|
||||
static let configKey = "widget_config"
|
||||
|
||||
@objc func update(_ call: CAPPluginCall) {
|
||||
guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
|
||||
call.reject("App Group \(Self.appGroup) unavailable")
|
||||
return
|
||||
}
|
||||
if let data = call.getString("data") {
|
||||
defaults.set(data, forKey: Self.dataKey)
|
||||
}
|
||||
if let config = call.getString("config") {
|
||||
defaults.set(config, forKey: Self.configKey)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func clear(_ call: CAPPluginCall) {
|
||||
guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
|
||||
call.reject("App Group \(Self.appGroup) unavailable")
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.dataKey)
|
||||
defaults.removeObject(forKey: Self.configKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal file
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.donetick.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1107
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
1107
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DoneTick Widget</string>
|
||||
<string>Donetick</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -11,10 +11,13 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
|
||||
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
|
||||
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
|
||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||
pod 'CapacitorLocalLlm', :path => '../../node_modules/@capacitor/local-llm'
|
||||
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
|
||||
@@ -18,6 +18,10 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorBrowser (8.0.3):
|
||||
- Capacitor
|
||||
- CapacitorCommunityInAppReview (8.0.0):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySpeechRecognition (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySqlite (8.1.0):
|
||||
- Capacitor
|
||||
- SQLCipher
|
||||
@@ -25,6 +29,8 @@ PODS:
|
||||
- CapacitorCordova (8.4.1)
|
||||
- CapacitorDevice (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorHaptics (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorLocalLlm (1.0.0):
|
||||
- Capacitor
|
||||
- CapacitorLocalNotifications (8.2.0):
|
||||
@@ -43,20 +49,10 @@ PODS:
|
||||
- Capacitor
|
||||
- CapgoCapacitorNfc (8.2.2):
|
||||
- Capacitor
|
||||
- CapgoCapacitorSocialLogin (8.3.36):
|
||||
- CapgoCapacitorSocialLogin (8.3.38):
|
||||
- Alamofire (~> 5.10.2)
|
||||
- Capacitor
|
||||
- FBSDKCoreKit (~> 18.0)
|
||||
- FBSDKLoginKit (~> 18.0)
|
||||
- GoogleSignIn (~> 9.0.0)
|
||||
- FBAEMKit (18.1.0):
|
||||
- FBSDKCoreKit_Basics (= 18.1.0)
|
||||
- FBSDKCoreKit (18.1.0):
|
||||
- FBAEMKit (= 18.1.0)
|
||||
- FBSDKCoreKit_Basics (= 18.1.0)
|
||||
- FBSDKCoreKit_Basics (18.1.0)
|
||||
- FBSDKLoginKit (18.1.0):
|
||||
- FBSDKCoreKit (= 18.1.0)
|
||||
- FirebaseCore (12.15.0):
|
||||
- FirebaseCoreInternal (~> 12.15.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
@@ -149,9 +145,12 @@ DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
|
||||
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
|
||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
|
||||
- "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)"
|
||||
- "CapacitorLocalLlm (from `../../node_modules/@capacitor/local-llm`)"
|
||||
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
|
||||
- "CapacitorNetwork (from `../../node_modules/@capacitor/network`)"
|
||||
@@ -172,10 +171,6 @@ SPEC REPOS:
|
||||
- Alamofire
|
||||
- AppAuth
|
||||
- AppCheckCore
|
||||
- FBAEMKit
|
||||
- FBSDKCoreKit
|
||||
- FBSDKCoreKit_Basics
|
||||
- FBSDKLoginKit
|
||||
- FirebaseCore
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
@@ -203,12 +198,18 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorBrowser:
|
||||
:path: "../../node_modules/@capacitor/browser"
|
||||
CapacitorCommunityInAppReview:
|
||||
:path: "../../node_modules/@capacitor-community/in-app-review"
|
||||
CapacitorCommunitySpeechRecognition:
|
||||
:path: "../../node_modules/@capacitor-community/speech-recognition"
|
||||
CapacitorCommunitySqlite:
|
||||
:path: "../../node_modules/@capacitor-community/sqlite"
|
||||
CapacitorCordova:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorDevice:
|
||||
:path: "../../node_modules/@capacitor/device"
|
||||
CapacitorHaptics:
|
||||
:path: "../../node_modules/@capacitor/haptics"
|
||||
CapacitorLocalLlm:
|
||||
:path: "../../node_modules/@capacitor/local-llm"
|
||||
CapacitorLocalNotifications:
|
||||
@@ -243,9 +244,12 @@ SPEC CHECKSUMS:
|
||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
||||
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
|
||||
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
||||
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
|
||||
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
|
||||
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
|
||||
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
|
||||
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
|
||||
@@ -255,11 +259,7 @@ SPEC CHECKSUMS:
|
||||
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
|
||||
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
|
||||
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
||||
CapgoCapacitorSocialLogin: adffda2bec52a765bdaebf3fa9a6083bd9341d60
|
||||
FBAEMKit: 64088ff0380f50e4a56e2ee07d984f7f1aef7595
|
||||
FBSDKCoreKit: 8390ea3a8fac186f444275f31d7512e760b47cba
|
||||
FBSDKCoreKit_Basics: 3a0005c78b355388bf2df5c6dbe6381b77ea4be3
|
||||
FBSDKLoginKit: d8e2711a3a03b0026703735c8d28a2b5a0e1f4fd
|
||||
CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414
|
||||
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
||||
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
||||
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
||||
@@ -283,6 +283,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 21b805bdbbb6ac4b8a3527ee8ef346ba0a55ef65
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
4245
package-lock.json
generated
4245
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.34",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -20,7 +20,9 @@
|
||||
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
|
||||
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
|
||||
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint && prettier -c .",
|
||||
"lint:ci": "true # TODO: eslint -f gha && prettier -c .",
|
||||
"lint:fix": "eslint --fix && prettier -w .",
|
||||
"preview": "vite preview",
|
||||
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||
"setup-apple-silicon": "npm install --no-optional && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||
@@ -35,15 +37,19 @@
|
||||
"bump": "node bump-version.js patch",
|
||||
"bump:minor": "node bump-version.js minor",
|
||||
"bump:major": "node bump-version.js major",
|
||||
"bump:patch": "node bump-version.js patch"
|
||||
"bump:patch": "node bump-version.js patch",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/browser": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/device": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/local-llm": "^1.0.0",
|
||||
"@capacitor/local-notifications": "^8.0.0",
|
||||
@@ -53,7 +59,7 @@
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
"@capgo/capacitor-social-login": "^8.0.0",
|
||||
"@capgo/capacitor-social-login": "^8.3.38",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -68,8 +74,11 @@
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "^1.12.5",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
@@ -113,19 +122,20 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"capacitor-set-version": "^2.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-sort-destructure-keys": "^1.5.0",
|
||||
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
||||
"eslint-plugin-tailwindcss": "^3.13.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-formatter-gha": "^2.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
"eslint-plugin-sort-destructure-keys": "^3.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
484
patches/@capacitor-community+speech-recognition+7.0.1.patch
Normal file
484
patches/@capacitor-community+speech-recognition+7.0.1.patch
Normal file
@@ -0,0 +1,484 @@
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..7ed749e
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/bundleLibRuntimeToDirDebug
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex
|
||||
new file mode 100644
|
||||
index 0000000..720cd1e
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex
|
||||
new file mode 100644
|
||||
index 0000000..5d21223
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex
|
||||
new file mode 100644
|
||||
index 0000000..668e631
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex
|
||||
new file mode 100644
|
||||
index 0000000..59b8c5b
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin
|
||||
new file mode 100644
|
||||
index 0000000..d60cb42
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin
|
||||
new file mode 100644
|
||||
index 0000000..0d259dd
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin
|
||||
@@ -0,0 +1 @@
|
||||
+o/classes
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex
|
||||
new file mode 100644
|
||||
index 0000000..d4d71e5
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml
|
||||
new file mode 100644
|
||||
index 0000000..b81ecea
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml
|
||||
@@ -0,0 +1,15 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
+ package="com.getcapacitor.community.speechrecognition.speechrecognition" >
|
||||
+
|
||||
+ <uses-sdk android:minSdkVersion="28" />
|
||||
+
|
||||
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
+
|
||||
+ <queries>
|
||||
+ <intent>
|
||||
+ <action android:name="android.speech.RecognitionService" />
|
||||
+ </intent>
|
||||
+ </queries>
|
||||
+
|
||||
+</manifest>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json
|
||||
new file mode 100644
|
||||
index 0000000..b3c0af9
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json
|
||||
@@ -0,0 +1,18 @@
|
||||
+{
|
||||
+ "version": 3,
|
||||
+ "artifactType": {
|
||||
+ "type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
|
||||
+ "kind": "Directory"
|
||||
+ },
|
||||
+ "applicationId": "com.getcapacitor.community.speechrecognition.speechrecognition",
|
||||
+ "variantName": "debug",
|
||||
+ "elements": [
|
||||
+ {
|
||||
+ "type": "SINGLE",
|
||||
+ "filters": [],
|
||||
+ "attributes": [],
|
||||
+ "outputFile": "AndroidManifest.xml"
|
||||
+ }
|
||||
+ ],
|
||||
+ "elementType": "File"
|
||||
+}
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
|
||||
new file mode 100644
|
||||
index 0000000..1211b1e
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
|
||||
@@ -0,0 +1,6 @@
|
||||
+aarFormatVersion=1.0
|
||||
+aarMetadataVersion=1.0
|
||||
+minCompileSdk=1
|
||||
+minCompileSdkExtension=0
|
||||
+minAndroidGradlePluginVersion=1.0.0
|
||||
+coreLibraryDesugaringEnabled=false
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json
|
||||
new file mode 100644
|
||||
index 0000000..9e26dfe
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json
|
||||
@@ -0,0 +1 @@
|
||||
+{}
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar
|
||||
new file mode 100644
|
||||
index 0000000..ff6ad96
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar
|
||||
new file mode 100644
|
||||
index 0000000..eb6f866
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt
|
||||
new file mode 100644
|
||||
index 0000000..f7ac549
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt
|
||||
@@ -0,0 +1,3 @@
|
||||
+int id webview 0x0
|
||||
+int layout bridge_layout_main 0x0
|
||||
+int string my_string 0x0
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat
|
||||
new file mode 100644
|
||||
index 0000000..183eadd
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
|
||||
new file mode 100644
|
||||
index 0000000..4e5a864
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
|
||||
@@ -0,0 +1,2 @@
|
||||
+#Mon Jul 20 15:42:26 EDT 2026
|
||||
+com.getcapacitor.community.speechrecognition.speechrecognition.capacitor-community-speech-recognition-main-6\:/layout/bridge_layout_main.xml=/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
|
||||
new file mode 100644
|
||||
index 0000000..e95bf66
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
|
||||
@@ -0,0 +1,4 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<resources>
|
||||
+ <string name="my_string">Just a simple string</string>
|
||||
+</resources>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml
|
||||
new file mode 100644
|
||||
index 0000000..3058471
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res"><file name="bridge_layout_main" path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/layout/bridge_layout_main.xml" qualifiers="" type="layout"/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/colors.xml" qualifiers=""/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/styles.xml" qualifiers=""/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/strings.xml" qualifiers=""><string name="my_string">Just a simple string</string></file></source></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/generated/res/resValues/debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/generated/res/resValues/debug"/></dataSet><mergedItems/></merger>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml
|
||||
new file mode 100644
|
||||
index 0000000..8c51637
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/shader_assets/debug/compileDebugShaders/out"/></dataSet></merger>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml
|
||||
new file mode 100644
|
||||
index 0000000..472210d
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/jniLibs"/></dataSet></merger>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml
|
||||
new file mode 100644
|
||||
index 0000000..6bebd8c
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/shaders"/></dataSet></merger>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class
|
||||
new file mode 100644
|
||||
index 0000000..3094bd9
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class
|
||||
new file mode 100644
|
||||
index 0000000..af9be6c
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class
|
||||
new file mode 100644
|
||||
index 0000000..a056329
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class
|
||||
new file mode 100644
|
||||
index 0000000..57a50fe
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt
|
||||
new file mode 100644
|
||||
index 0000000..80ec5cb
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
+R_DEF: Internal format may change without notice
|
||||
+local
|
||||
+id webview
|
||||
+layout bridge_layout_main
|
||||
+string my_string
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt
|
||||
new file mode 100644
|
||||
index 0000000..6010dd3
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt
|
||||
@@ -0,0 +1,21 @@
|
||||
+1<?xml version="1.0" encoding="utf-8"?>
|
||||
+2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
+3 package="com.getcapacitor.community.speechrecognition.speechrecognition" >
|
||||
+4
|
||||
+5 <uses-sdk android:minSdkVersion="28" />
|
||||
+6
|
||||
+7 <uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
+7-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:5-71
|
||||
+7-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:22-68
|
||||
+8
|
||||
+9 <queries>
|
||||
+9-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:5:5-9:15
|
||||
+10 <intent>
|
||||
+10-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:6:7-8:16
|
||||
+11 <action android:name="android.speech.RecognitionService" />
|
||||
+11-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:9-68
|
||||
+11-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:17-65
|
||||
+12 </intent>
|
||||
+13 </queries>
|
||||
+14
|
||||
+15</manifest>
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
|
||||
new file mode 100644
|
||||
index 0000000..b81ecea
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
|
||||
@@ -0,0 +1,15 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
+ package="com.getcapacitor.community.speechrecognition.speechrecognition" >
|
||||
+
|
||||
+ <uses-sdk android:minSdkVersion="28" />
|
||||
+
|
||||
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
+
|
||||
+ <queries>
|
||||
+ <intent>
|
||||
+ <action android:name="android.speech.RecognitionService" />
|
||||
+ </intent>
|
||||
+ </queries>
|
||||
+
|
||||
+</manifest>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json
|
||||
new file mode 100644
|
||||
index 0000000..0637a08
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json
|
||||
@@ -0,0 +1 @@
|
||||
+[]
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt
|
||||
new file mode 100644
|
||||
index 0000000..08f4ebe
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt
|
||||
@@ -0,0 +1 @@
|
||||
+0 Warning/Error
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml
|
||||
new file mode 100644
|
||||
index 0000000..56fec15
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml
|
||||
@@ -0,0 +1,15 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
+ xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
+ xmlns:tools="http://schemas.android.com/tools"
|
||||
+ android:layout_width="match_parent"
|
||||
+ android:layout_height="match_parent"
|
||||
+ tools:context="com.getcapacitor.BridgeActivity"
|
||||
+ >
|
||||
+
|
||||
+ <WebView
|
||||
+ android:id="@+id/webview"
|
||||
+ android:layout_width="fill_parent"
|
||||
+ android:layout_height="fill_parent" />
|
||||
+
|
||||
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml
|
||||
new file mode 100644
|
||||
index 0000000..e95bf66
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml
|
||||
@@ -0,0 +1,4 @@
|
||||
+<?xml version="1.0" encoding="utf-8"?>
|
||||
+<resources>
|
||||
+ <string name="my_string">Just a simple string</string>
|
||||
+</resources>
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class
|
||||
new file mode 100644
|
||||
index 0000000..3094bd9
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class
|
||||
new file mode 100644
|
||||
index 0000000..af9be6c
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class
|
||||
new file mode 100644
|
||||
index 0000000..a056329
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class
|
||||
new file mode 100644
|
||||
index 0000000..57a50fe
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar
|
||||
new file mode 100644
|
||||
index 0000000..c701569
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
|
||||
new file mode 100644
|
||||
index 0000000..f0ee474
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
|
||||
@@ -0,0 +1,4 @@
|
||||
+com.getcapacitor.community.speechrecognition.speechrecognition
|
||||
+id webview
|
||||
+layout bridge_layout_main
|
||||
+string my_string
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt b/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt
|
||||
new file mode 100644
|
||||
index 0000000..102bc47
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt
|
||||
@@ -0,0 +1,28 @@
|
||||
+-- Merging decision tree log ---
|
||||
+manifest
|
||||
+ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:3-10:14
|
||||
+INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:3-10:14
|
||||
+ package
|
||||
+ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml
|
||||
+ xmlns:android
|
||||
+ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:13-71
|
||||
+uses-permission#android.permission.RECORD_AUDIO
|
||||
+ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:5-71
|
||||
+ android:name
|
||||
+ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:22-68
|
||||
+queries
|
||||
+ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:5:5-9:15
|
||||
+intent#action:name:android.speech.RecognitionService
|
||||
+ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:6:7-8:16
|
||||
+action#android.speech.RecognitionService
|
||||
+ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:9-68
|
||||
+ android:name
|
||||
+ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:17-65
|
||||
+uses-sdk
|
||||
+INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml reason: use-sdk injection requested
|
||||
+INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml
|
||||
+INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml
|
||||
+ android:targetSdkVersion
|
||||
+ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml
|
||||
+ android:minSdkVersion
|
||||
+ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1 b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1
|
||||
new file mode 100644
|
||||
index 0000000..dac1e83
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1 differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0 b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0
|
||||
new file mode 100644
|
||||
index 0000000..836f0ad
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0 differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin
|
||||
new file mode 100644
|
||||
index 0000000..c57ddd3
|
||||
Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java
|
||||
index a99ea69..5cabbd7 100644
|
||||
--- a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java
|
||||
@@ -81,13 +81,18 @@ public class SpeechRecognition extends Plugin implements Constants {
|
||||
String prompt = call.getString("prompt", null);
|
||||
boolean partialResults = call.getBoolean("partialResults", false);
|
||||
boolean popup = call.getBoolean("popup", false);
|
||||
- beginListening(language, maxResults, prompt, partialResults, popup, call);
|
||||
+ JSArray contextualStrings = call.getArray("contextualStrings", new JSArray());
|
||||
+ beginListening(language, maxResults, prompt, partialResults, popup, contextualStrings, call);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stop(final PluginCall call) {
|
||||
try {
|
||||
stopListening();
|
||||
+ // This never resolved the call on success, only rejected on
|
||||
+ // exception — any caller doing `await stop()` (e.g. a JS-side
|
||||
+ // restart loop) would hang forever waiting on this promise.
|
||||
+ call.resolve();
|
||||
} catch (Exception ex) {
|
||||
call.reject(ex.getLocalizedMessage());
|
||||
}
|
||||
@@ -157,6 +162,7 @@ public class SpeechRecognition extends Plugin implements Constants {
|
||||
String prompt,
|
||||
final boolean partialResults,
|
||||
boolean showPopup,
|
||||
+ JSArray contextualStrings,
|
||||
PluginCall call
|
||||
) {
|
||||
Logger.info(getLogTag(), "Beginning to listen for audible speech");
|
||||
@@ -168,6 +174,19 @@ public class SpeechRecognition extends Plugin implements Constants {
|
||||
intent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, bridge.getActivity().getPackageName());
|
||||
intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults);
|
||||
intent.putExtra("android.speech.extra.DICTATION_MODE", partialResults);
|
||||
+ intent.putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true);
|
||||
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && contextualStrings != null && contextualStrings.length() > 0) {
|
||||
+ ArrayList<String> biasingStrings = new ArrayList<>();
|
||||
+ for (int i = 0; i < contextualStrings.length(); i++) {
|
||||
+ String value = contextualStrings.optString(i, null);
|
||||
+ if (value != null && !value.trim().isEmpty()) {
|
||||
+ biasingStrings.add(value);
|
||||
+ }
|
||||
+ }
|
||||
+ if (!biasingStrings.isEmpty()) {
|
||||
+ intent.putStringArrayListExtra(RecognizerIntent.EXTRA_BIASING_STRINGS, biasingStrings);
|
||||
+ }
|
||||
+ }
|
||||
|
||||
if (prompt != null) {
|
||||
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
|
||||
@@ -283,6 +302,25 @@ public class SpeechRecognition extends Plugin implements Constants {
|
||||
SpeechRecognition.this.stopListening();
|
||||
String errorMssg = getErrorText(error);
|
||||
|
||||
+ // Unlike onEndOfSpeech, this previously never told the JS side the
|
||||
+ // session ended — silence commonly ends a session via ERROR_SPEECH_TIMEOUT
|
||||
+ // / ERROR_NO_MATCH rather than onEndOfSpeech, so without this the caller's
|
||||
+ // restart loop never fires and listening state gets stuck forever.
|
||||
+ bridge
|
||||
+ .getWebView()
|
||||
+ .post(() -> {
|
||||
+ try {
|
||||
+ SpeechRecognition.this.lock.lock();
|
||||
+ SpeechRecognition.this.listening(false);
|
||||
+
|
||||
+ JSObject ret = new JSObject();
|
||||
+ ret.put("status", "stopped");
|
||||
+ SpeechRecognition.this.notifyListeners(LISTENING_EVENT, ret);
|
||||
+ } finally {
|
||||
+ SpeechRecognition.this.lock.unlock();
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
if (this.call != null) {
|
||||
call.reject(errorMssg);
|
||||
}
|
||||
diff --git a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift
|
||||
index 5d1b35b..899c7a6 100644
|
||||
--- a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift
|
||||
+++ b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift
|
||||
@@ -54,6 +54,7 @@ public class SpeechRecognition: CAPPlugin {
|
||||
let language: String = call.getString("language") ?? "en-US"
|
||||
let maxResults: Int = call.getInt("maxResults") ?? self.defaultMatches
|
||||
let partialResults: Bool = call.getBool("partialResults") ?? false
|
||||
+ let contextualStrings: [String] = call.getArray("contextualStrings", String.self) ?? []
|
||||
|
||||
if self.recognitionTask != nil {
|
||||
self.recognitionTask?.cancel()
|
||||
@@ -79,6 +80,12 @@ public class SpeechRecognition: CAPPlugin {
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = partialResults
|
||||
+ if !contextualStrings.isEmpty {
|
||||
+ self.recognitionRequest?.contextualStrings = contextualStrings
|
||||
+ }
|
||||
+ if #available(iOS 13, *), self.speechRecognizer?.supportsOnDeviceRecognition == true {
|
||||
+ self.recognitionRequest?.requiresOnDeviceRecognition = true
|
||||
+ }
|
||||
|
||||
let inputNode: AVAudioInputNode = self.audioEngine!.inputNode
|
||||
let format: AVAudioFormat = inputNode.outputFormat(forBus: 0)
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
13
prettier.config.mjs
Normal file
13
prettier.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
arrowParens: 'avoid',
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'auto',
|
||||
jsxBracketSameLine: false,
|
||||
jsxSingleQuote: true,
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
printWidth: 80,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
}
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activities",
|
||||
"points": "Points",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Developer Settings",
|
||||
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Preferences } from '@capacitor/preferences'
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
import { beginOAuthExchange } from './utils/OAuthExchangeState'
|
||||
|
||||
// React Router navigate(), injected by <App /> once the router is mounted.
|
||||
// Using client-side navigation (instead of window.location.href) avoids a full
|
||||
@@ -62,7 +63,22 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
|
||||
|
||||
const handleUrlOpen = (url, isColdStart = false) => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/')) {
|
||||
if (url.startsWith('donetick://chores/add')) {
|
||||
// Widget "+" / quick-capture buttons: land on the chore list with the
|
||||
// quick-add modal open (MyChores watches for the add_task param and
|
||||
// consumes it). ?mode=scan|voice opens straight into that capture panel.
|
||||
let mode = null
|
||||
try {
|
||||
mode = new URL(url).searchParams.get('mode')
|
||||
} catch {
|
||||
// malformed URL — fall back to plain text capture
|
||||
}
|
||||
routerNavigate(
|
||||
mode === 'scan' || mode === 'voice'
|
||||
? `/chores?add_task=1&mode=${mode}`
|
||||
: '/chores?add_task=1',
|
||||
)
|
||||
} else if (url.startsWith('donetick://chores/')) {
|
||||
handleNFCChoreDeepLink(url, isColdStart)
|
||||
} else if (url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(url)
|
||||
@@ -89,6 +105,13 @@ const handleOAuthDeepLink = async url => {
|
||||
return
|
||||
}
|
||||
|
||||
// Claim the exchange window synchronously, before the first await: the
|
||||
// resume-driven background sync fires in the same tick as this deep link,
|
||||
// and its 401 must not be mistaken for an expired session. Set after the
|
||||
// early return above so the flag is only ever claimed by the navigation
|
||||
// that Authenticating.jsx will clear.
|
||||
beginOAuthExchange()
|
||||
|
||||
// Store the OAuth params for the app to pick up
|
||||
await Preferences.set({
|
||||
key: 'oauth_callback',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-env node */
|
||||
export const API_URL =
|
||||
import.meta.env.VITE_APP_API_URL === 'AUTO'
|
||||
? `${window.location.hostname}/api`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import LogoSVG from '@/assets/logo.svg'
|
||||
const Logo = () => {
|
||||
const Logo = ({ size = '128px' }) => {
|
||||
return (
|
||||
<div className='logo'>
|
||||
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
|
||||
<img src={LogoSVG} alt='logo' width={size} height={size} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import AppModal from './common/AppModal'
|
||||
import ModalActions from './common/ModalActions'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
|
||||
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 600,
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
overflow: 'auto',
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography level='h3' sx={{ mb: 1 }}>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Upgrade to Plus'
|
||||
description='Unlock reminders, rich task details, and advanced automation.'
|
||||
size='lg'
|
||||
closeOnBackdrop={!isLoading}
|
||||
closeOnEscape={!isLoading}
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||
primary={{
|
||||
label: 'Subscribe',
|
||||
onClick: handleSubscribe,
|
||||
loading: isLoading,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
||||
>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSubscribe}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
component='label'
|
||||
htmlFor={`subscription-plan-${key}`}
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
id={`subscription-plan-${key}`}
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
230
src/components/common/AppModal.jsx
Normal file
230
src/components/common/AppModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const WIDTH_BY_SIZE = {
|
||||
sm: 400,
|
||||
md: 520,
|
||||
lg: 680,
|
||||
xl: 840,
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's modal primitive. It owns modal layout, spacing, accessibility,
|
||||
* responsive presentation, and motion so feature components only own content.
|
||||
*/
|
||||
const AppModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
mobilePresentation = 'sheet',
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
showHandle = false,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
backdropBlur = true,
|
||||
maxHeight = '90dvh',
|
||||
contentSx,
|
||||
footerSx,
|
||||
sx,
|
||||
unmountDelay = 180,
|
||||
...modalProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const generatedId = useId()
|
||||
const detectedMobile = useMediaQuery('(max-width:768px)')
|
||||
const isMobile = isMobileProp ?? detectedMobile
|
||||
const titleId = title ? `${generatedId}-title` : undefined
|
||||
const descriptionId = description ? `${generatedId}-description` : undefined
|
||||
const isSheet = isMobile && mobilePresentation === 'sheet'
|
||||
const isFullscreen = isMobile && mobilePresentation === 'fullscreen'
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
if (reason === 'backdropClick' && !closeOnBackdrop) return
|
||||
if (reason === 'escapeKeyDown' && !closeOnEscape) return
|
||||
onClose?.(event, reason)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
keepMounted
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||
display: 'flex',
|
||||
alignItems: isSheet ? 'flex-end' : 'center',
|
||||
justifyContent: 'center',
|
||||
p: isMobile ? 0 : 2,
|
||||
'& .MuiModal-backdrop': {
|
||||
backgroundColor: 'rgba(8, 15, 24, 0.52)',
|
||||
backdropFilter: backdropBlur ? 'blur(4px)' : 'none',
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
role={role}
|
||||
aria-modal='true'
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: isFullscreen
|
||||
? '100%'
|
||||
: isSheet
|
||||
? '100%'
|
||||
: fullWidth
|
||||
? `min(calc(100vw - 32px), ${WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md}px)`
|
||||
: 'auto',
|
||||
maxWidth: isMobile
|
||||
? 'none'
|
||||
: WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md,
|
||||
height: isFullscreen ? '100dvh' : 'auto',
|
||||
maxHeight: isFullscreen ? '100dvh' : maxHeight,
|
||||
overflow: 'hidden',
|
||||
borderRadius: isFullscreen ? 0 : isSheet ? '16px 16px 0 0' : '16px',
|
||||
borderBottom: isSheet ? 0 : undefined,
|
||||
boxShadow: 'lg',
|
||||
outline: 0,
|
||||
pb: isMobile ? 'env(safe-area-inset-bottom)' : 0,
|
||||
animation: open
|
||||
? `${isSheet ? 'appSheetEnter' : 'appModalEnter'} ${Math.min(unmountDelay, 300)}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
'@keyframes appModalEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(8px) scale(0.99)' },
|
||||
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
},
|
||||
'@keyframes appSheetEnter': {
|
||||
from: { transform: 'translateY(24px)' },
|
||||
to: { transform: 'translateY(0)' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{isSheet && showHandle && (
|
||||
<Box
|
||||
aria-hidden='true'
|
||||
sx={{ display: 'flex', justifyContent: 'center', pt: 1.25 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'neutral.300',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(title || description || showCloseButton) && (
|
||||
<Box
|
||||
component='header'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
pt: isSheet && showHandle ? 1.25 : { xs: 2, sm: 2.5 },
|
||||
pb: description ? 1.5 : 2,
|
||||
pr: showCloseButton ? { xs: 7, sm: 8 } : { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<Typography
|
||||
id={titleId}
|
||||
level='title-lg'
|
||||
sx={{ fontWeight: 650 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography
|
||||
id={descriptionId}
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.tertiary', mt: 0.5 }}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
aria-label='Close dialog'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: isSheet && showHandle ? 6 : 12,
|
||||
right: { xs: 10, sm: 16 },
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
px: { xs: 2, sm: 3 },
|
||||
pb: { xs: 2.5, sm: 3 },
|
||||
...contentSx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box
|
||||
component='footer'
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
py: 2,
|
||||
bgcolor: 'background.surface',
|
||||
...footerSx,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AppModal.displayName = 'AppModal'
|
||||
|
||||
export default AppModal
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BottomSheetModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
backdropBlur = true,
|
||||
showHandle = true,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(open)
|
||||
|
||||
// Handle opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInternalOpen(true)
|
||||
setIsClosing(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle closing with animation
|
||||
useEffect(() => {
|
||||
if (!open && internalOpen) {
|
||||
setIsClosing(true)
|
||||
// Wait for animation to complete before hiding modal
|
||||
const timer = setTimeout(() => {
|
||||
setInternalOpen(false)
|
||||
setIsClosing(false)
|
||||
setIsExpanded(false)
|
||||
}, 250) // Match transition duration
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, internalOpen])
|
||||
|
||||
// Handle toggle expansion
|
||||
const handleToggleExpansion = () => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape' && internalOpen) {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (internalOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
// document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// Restore scroll immediately when modal starts closing
|
||||
// document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [internalOpen, onClose])
|
||||
|
||||
// Calculate current height
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
// Filter out DOM props that shouldn't be passed to Modal
|
||||
const {
|
||||
fullWidth: _fullWidth,
|
||||
unmountDelay: _unmountDelay,
|
||||
...modalProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={internalOpen}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
keepMounted
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minHeight: '20%',
|
||||
width: '100%',
|
||||
height: currentHeight,
|
||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition:
|
||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
transform:
|
||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
||||
// Handle safe area on mobile devices
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Header Section with drag handle, title, and close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'inherit',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Close button positioned absolutely in top-right */}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'neutral.softBg',
|
||||
color: 'neutral.softColor',
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Drag Handle */}
|
||||
{showHandle && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 0 8px 0',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
onClick={handleToggleExpansion}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: showHandle
|
||||
? '0 20px 16px 20px'
|
||||
: '16px 20px 16px 20px',
|
||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '0 20px 20px 20px',
|
||||
minHeight: 0, // Important for flex child with overflow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
||||
|
||||
export default BottomSheetModal
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
* FadeModal component with consistent fade-in/out animations
|
||||
* Can be used as a drop-in replacement for Joy UI's Modal component
|
||||
*/
|
||||
const FadeModal = ({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
backdropBlur = true,
|
||||
title,
|
||||
footer,
|
||||
...props
|
||||
}) => {
|
||||
// Filter out props that shouldn't be passed to Modal
|
||||
const { unmountDelay: _unmountDelay, ...modalProps } = props
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
},
|
||||
}}
|
||||
keepMounted
|
||||
// These transition properties create a smooth fade + slide effect
|
||||
transition={{
|
||||
mount: { opacity: 1, transform: 'translateY(0px)' },
|
||||
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
||||
duration: 250, // Animation duration in ms
|
||||
easing: {
|
||||
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
||||
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<ModalOverflow>
|
||||
<ModalDialog
|
||||
size={size}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minWidth: fullWidth ? '90%' : 'auto',
|
||||
animation: open
|
||||
? 'modalFadeIn 0.35s forwards'
|
||||
: 'modalFadeOut 0.25s forwards',
|
||||
'@keyframes modalFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
'@keyframes modalFadeOut': {
|
||||
from: { opacity: 1, transform: 'translateY(0)' },
|
||||
to: { opacity: 0, transform: 'translateY(8px)' },
|
||||
},
|
||||
// Add staggered animation for child elements
|
||||
'& > *': {
|
||||
opacity: 0,
|
||||
animation: open
|
||||
? 'contentFadeIn 0.35s forwards'
|
||||
: 'contentFadeOut 0.2s forwards',
|
||||
},
|
||||
// Stagger child animations
|
||||
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
|
||||
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
|
||||
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
|
||||
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
|
||||
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
|
||||
'@keyframes contentFadeIn': {
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
'@keyframes contentFadeOut': {
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{footer && <div style={{ marginTop: 16 }}>{footer}</div>}
|
||||
</ModalDialog>
|
||||
</ModalOverflow>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FadeModal
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
import AppModal from './AppModal'
|
||||
import ModalActions from './ModalActions'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: t.toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -49,8 +53,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
const y = new Date(t)
|
||||
y.setDate(t.getDate() - 1)
|
||||
return {
|
||||
from: d(y).toISOString(),
|
||||
to: d(y, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,9 +66,14 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,8 +81,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -79,7 +96,10 @@ const DATE_RANGE_PRESETS = [
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: start.toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -87,8 +107,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 29)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -96,8 +120,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setMonth(t.getMonth() - 3)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -127,7 +155,8 @@ const FilterBar = ({
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
@@ -183,13 +212,18 @@ const FilterBar = ({
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (
|
||||
def.type === 'multi-select' &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
@@ -199,7 +233,10 @@ const FilterBar = ({
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
return (
|
||||
DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ??
|
||||
'Date Range'
|
||||
)
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
@@ -259,7 +296,15 @@ const FilterBar = ({
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
@@ -309,37 +354,42 @@ const FilterBar = ({
|
||||
</Box>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={isOpen}
|
||||
isMobile
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={modalCountChipSx}
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
tertiary={{
|
||||
label: 'Clear all',
|
||||
color: 'danger',
|
||||
disabled: !hasActive,
|
||||
onClick: onClearAll,
|
||||
}}
|
||||
primary={{
|
||||
label:
|
||||
resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done',
|
||||
onClick: () => setIsOpen(false),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
@@ -348,9 +398,18 @@ const FilterBar = ({
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
|
||||
>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
@@ -359,21 +418,41 @@ const FilterBar = ({
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{opt.label}
|
||||
{def.type === 'multi-select' &&
|
||||
(activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
)}
|
||||
{def.type === 'single-select' &&
|
||||
activeFilters[def.id] != null &&
|
||||
(() => {
|
||||
const opt = def.options?.find(
|
||||
o => o.value === activeFilters[def.id],
|
||||
)
|
||||
return opt ? (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
@@ -383,18 +462,28 @@ const FilterBar = ({
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
const isSelected = (activeFilters[def.id] || []).includes(
|
||||
opt.value,
|
||||
)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -415,13 +504,21 @@ const FilterBar = ({
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -438,7 +535,11 @@ const FilterBar = ({
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
startDecorator={
|
||||
activeFilters[def.id] ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
@@ -447,58 +548,84 @@ const FilterBar = ({
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
{def.type === 'date-range' &&
|
||||
(() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() =>
|
||||
handleDateRangePreset(def.id, preset.value)
|
||||
}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
{/* Custom date inputs */}
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'from', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { max: toInputDate(val.to) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', flexShrink: 0 }}
|
||||
>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'to', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { min: toInputDate(val.from) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
61
src/components/common/ModalActions.jsx
Normal file
61
src/components/common/ModalActions.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Button } from '@mui/joy'
|
||||
|
||||
const ActionButton = ({ action, defaults, sx }) => {
|
||||
if (!action) return null
|
||||
|
||||
const { label, sx: actionSx, ...props } = action
|
||||
return (
|
||||
<Button {...defaults} {...props} sx={{ ...sx, ...actionSx }}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent modal action row. Secondary actions are rendered first and the
|
||||
* primary action is always the final, highest-emphasis control.
|
||||
*/
|
||||
const ModalActions = ({
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
children,
|
||||
stackOnMobile = false,
|
||||
sx,
|
||||
}) => {
|
||||
const responsiveButtonStyles = stackOnMobile
|
||||
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||
: undefined
|
||||
|
||||
const layoutSx = {
|
||||
display: 'flex',
|
||||
flexDirection: stackOnMobile ? { xs: 'column-reverse', sm: 'row' } : 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
...responsiveButtonStyles,
|
||||
...sx,
|
||||
}
|
||||
|
||||
if (children) return <Box sx={layoutSx}>{children}</Box>
|
||||
|
||||
return (
|
||||
<Box sx={layoutSx}>
|
||||
<ActionButton
|
||||
action={tertiary}
|
||||
defaults={{ color: 'neutral', variant: 'plain' }}
|
||||
sx={{ mr: { sm: 'auto' } }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={secondary}
|
||||
defaults={{ color: 'neutral', variant: 'outlined' }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={primary}
|
||||
defaults={{ color: 'primary', variant: 'solid' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalActions
|
||||
52
src/components/common/README.md
Normal file
52
src/components/common/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# UI foundations
|
||||
|
||||
## Modals
|
||||
|
||||
Use `AppModal` for new work. Existing features may continue using
|
||||
`useResponsiveModal`; it now renders the same primitive.
|
||||
|
||||
### Presentations
|
||||
|
||||
- Desktop: centered, constrained dialog (`sm` 400, `md` 520, `lg` 680,
|
||||
`xl` 840 pixels).
|
||||
- Mobile default: bottom sheet.
|
||||
- Long mobile workflows: `mobilePresentation='fullscreen'`.
|
||||
- Destructive confirmation: `size='sm'`, `role='alertdialog'`, and
|
||||
`closeOnBackdrop={false}`.
|
||||
|
||||
`AppModal` owns the header, close control, content scrolling, safe-area spacing,
|
||||
and footer. Do not add another close button or duplicate the title inside the
|
||||
content.
|
||||
|
||||
```jsx
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Create item'
|
||||
description='Add a recognizable name.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Create', onClick: onCreate, loading: isSaving }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</AppModal>
|
||||
```
|
||||
|
||||
## Buttons
|
||||
|
||||
- Primary: `solid primary`; one primary action per surface.
|
||||
- Secondary: `outlined neutral`.
|
||||
- Tertiary: `plain neutral`.
|
||||
- Destructive confirmation: `solid danger`.
|
||||
- Destructive trigger: usually `outlined danger` or `plain danger`.
|
||||
- Modal order: secondary first, primary last.
|
||||
- Every icon-only button requires an `aria-label`.
|
||||
- Use the built-in `loading` state to prevent repeated submission.
|
||||
|
||||
Button heights, radii, focus states, and reduced-motion behavior are defined in
|
||||
`src/contexts/ThemeContext.jsx`. Avoid local overrides for those properties.
|
||||
|
||||
The live reference is available at `/test`.
|
||||
@@ -1,5 +1,6 @@
|
||||
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||
import tailwindConfig from '/tailwind.config.js'
|
||||
|
||||
import tailwindConfig from '/tailwind.config.mjs'
|
||||
|
||||
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
|
||||
]
|
||||
|
||||
export const LocalizationProvider = ({ children }) => {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { initWidgetSync } from '../service/WidgetService'
|
||||
|
||||
const QueryContext = ({ children }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 300000, // 5 minutes
|
||||
gcTime: 600000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 300000, // 5 minutes
|
||||
gcTime: 600000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
useEffect(() => initWidgetSync(queryClient), [queryClient])
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
|
||||
@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
|
||||
const shades = [
|
||||
'50',
|
||||
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
||||
]
|
||||
|
||||
const getPallete = (key = primaryColor) => {
|
||||
return shades.reduce((acc, shade) => {
|
||||
acc[shade] = COLORS[key][shade]
|
||||
return acc
|
||||
const getPalette = (key = primaryColor) =>
|
||||
shades.reduce((palette, shade) => {
|
||||
palette[shade] = COLORS[key][shade]
|
||||
return palette
|
||||
}, {})
|
||||
}
|
||||
|
||||
const primaryPalette = getPallete(primaryColor)
|
||||
const primaryPalette = getPalette(primaryColor)
|
||||
|
||||
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
|
||||
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
|
||||
// takes precedence, which is what keeps connected groups looking connected.
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
const theme = extendTheme({
|
||||
radius: {
|
||||
xs: '6px',
|
||||
sm: '8px',
|
||||
md: '10px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
},
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
@@ -42,42 +53,100 @@ const theme = extendTheme({
|
||||
200: '#fbd5d5',
|
||||
300: '#f9c1c1',
|
||||
400: '#f6a8a8',
|
||||
500: '',
|
||||
600: '#f47272',
|
||||
700: '#e33434',
|
||||
800: '#cc1f1a',
|
||||
900: '#b91c1c',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffdf7',
|
||||
100: '#fef8e1',
|
||||
200: '#fdecb2',
|
||||
300: '#fcd982',
|
||||
400: '#fbcf52',
|
||||
500: '#f9c222',
|
||||
600: '#f6b81e',
|
||||
700: '#f3ae1a',
|
||||
800: '#f0a416',
|
||||
900: '#e99b0e',
|
||||
},
|
||||
},
|
||||
warning: {
|
||||
50: '#fffdf7',
|
||||
100: '#fef8e1',
|
||||
200: '#fdecb2',
|
||||
300: '#fcd982',
|
||||
400: '#fbcf52',
|
||||
500: '#f9c222',
|
||||
600: '#f6b81e',
|
||||
700: '#f3ae1a',
|
||||
800: '#f0a416',
|
||||
900: '#e99b0e',
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: primaryPalette,
|
||||
components: {
|
||||
JoyButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
minHeight:
|
||||
ownerState.size === 'lg'
|
||||
? '44px'
|
||||
: ownerState.size === 'sm'
|
||||
? '36px'
|
||||
: '40px',
|
||||
// Read through the CSS variable so parents that own the radius
|
||||
// (ButtonGroup, Input/Select decorators, Card actions) still win.
|
||||
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
|
||||
fontWeight: 600,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyIconButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.96)',
|
||||
},
|
||||
// Touch target only for the default size. `sm`/`lg` are explicit
|
||||
// choices by the call site and keep Joy's own sizing.
|
||||
...(ownerState.size === 'md' && {
|
||||
minWidth: '40px',
|
||||
minHeight: '40px',
|
||||
'@media (max-width: 768px)': {
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
},
|
||||
}),
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'--ButtonGroup-radius': CONTROL_RADIUS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ThemeContext = ({ children }) => {
|
||||
return (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
}
|
||||
const ThemeContext = ({ children }) => (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
|
||||
ThemeContext.propTypes = {
|
||||
children: PropType.node,
|
||||
|
||||
120
src/hooks/useLongPress.js
Normal file
120
src/hooks/useLongPress.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_DELAY_MS = 450
|
||||
// Deliberately smaller than the swipe list's swipeStartThreshold (10px) so the
|
||||
// hold is abandoned before a swipe is even recognized.
|
||||
const MOVE_TOLERANCE_PX = 6
|
||||
|
||||
const haptic = async () => {
|
||||
try {
|
||||
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Press-and-hold gesture that works for both touch and mouse.
|
||||
*
|
||||
* Returns `handlers` to spread on the element and a `cancel` function so the
|
||||
* owner can abandon a pending hold when another gesture wins (e.g. the swipe
|
||||
* list reports a swipe start). A press that drifts more than
|
||||
* MOVE_TOLERANCE_PX, scrolls, or gets cancelled by the browser never fires,
|
||||
* and the click that follows a successful hold is swallowed so the element's
|
||||
* normal click action doesn't also run.
|
||||
*/
|
||||
export const useLongPress = (
|
||||
onLongPress,
|
||||
{ delay = DEFAULT_DELAY_MS, enabled = true } = {},
|
||||
) => {
|
||||
const timerRef = useRef(null)
|
||||
const originRef = useRef(null)
|
||||
const firedRef = useRef(false)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
originRef.current = null
|
||||
}, [])
|
||||
|
||||
// Watch movement on the window rather than only on the element: while the
|
||||
// swipe list drags the row it translates under the finger, and the pointer
|
||||
// can end up over a different element than the one we started on.
|
||||
useEffect(() => {
|
||||
const handleWindowMove = event => {
|
||||
if (!timerRef.current || !originRef.current) return
|
||||
const point = event.touches?.[0] ?? event
|
||||
if (point.clientX === undefined) return
|
||||
const dx = Math.abs(point.clientX - originRef.current.x)
|
||||
const dy = Math.abs(point.clientY - originRef.current.y)
|
||||
if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('touchmove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('scroll', clear, { capture: true, passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleWindowMove, true)
|
||||
window.removeEventListener('touchmove', handleWindowMove, true)
|
||||
window.removeEventListener('scroll', clear, true)
|
||||
clear()
|
||||
}
|
||||
}, [clear])
|
||||
|
||||
const start = useCallback(
|
||||
event => {
|
||||
if (!enabled || !onLongPress) return
|
||||
// Ignore right/middle mouse buttons
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
|
||||
clear()
|
||||
firedRef.current = false
|
||||
originRef.current = { x: event.clientX, y: event.clientY }
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true
|
||||
timerRef.current = null
|
||||
haptic()
|
||||
onLongPress(event)
|
||||
}, delay)
|
||||
},
|
||||
[enabled, onLongPress, delay, clear],
|
||||
)
|
||||
|
||||
const handlers = {
|
||||
onPointerDown: start,
|
||||
onPointerUp: clear,
|
||||
onPointerCancel: clear,
|
||||
onPointerLeave: clear,
|
||||
onDragStart: clear,
|
||||
// Swallow the click that the browser fires after the finger lifts
|
||||
onClickCapture: event => {
|
||||
if (firedRef.current) {
|
||||
firedRef.current = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
onContextMenu: event => {
|
||||
// A touch long-press otherwise pops the native context menu on top
|
||||
if (firedRef.current) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return { handlers, cancel: clear }
|
||||
}
|
||||
|
||||
export default useLongPress
|
||||
@@ -1,18 +1,23 @@
|
||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
||||
import FadeModal from '../components/common/FadeModal'
|
||||
import useWindowWidth from './useWindowWidth'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { createElement } from 'react'
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const MobileAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: true })
|
||||
const DesktopAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: false })
|
||||
|
||||
/**
|
||||
* Hook that returns the appropriate modal component based on screen size
|
||||
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
|
||||
* @returns {Object} - { Modal: Component, isMobile: boolean }
|
||||
* Backwards-compatible access to the app modal system.
|
||||
*
|
||||
* New code may render AppModal directly when it already knows the desired
|
||||
* presentation. Existing callers can continue using ResponsiveModal.
|
||||
*/
|
||||
export const useResponsiveModal = (breakpoint = 768) => {
|
||||
const windowWidth = useWindowWidth()
|
||||
const isMobile = windowWidth <= breakpoint
|
||||
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
|
||||
|
||||
return {
|
||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
||||
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
@@ -90,6 +91,11 @@ export function useSyncOnReconnect() {
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
// Skip while the OAuth code exchange is in flight — there's no session
|
||||
// yet, so a sync here just 401s. Note the app-resume listener fires in
|
||||
// the same tick as the deep link, before the route changes, so this has
|
||||
// to test the shared flag rather than the pathname.
|
||||
if (isOAuthExchangeInProgress()) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
|
||||
@@ -67,6 +67,24 @@ const isNetworkError = error =>
|
||||
((error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError')
|
||||
|
||||
// The backend returns { error: "..." } on failures. Surface that message when
|
||||
// it is there, flagged so callers can tell it apart from our generic fallback.
|
||||
const errorFromResponse = async (resp, fallbackMessage) => {
|
||||
if (!resp) return new Error(fallbackMessage)
|
||||
let serverMessage = null
|
||||
try {
|
||||
const body = await resp.json()
|
||||
if (typeof body?.error === 'string' && body.error.trim() !== '') {
|
||||
serverMessage = body.error
|
||||
}
|
||||
} catch {
|
||||
// body was empty or not JSON, keep the fallback
|
||||
}
|
||||
const error = new Error(serverMessage || fallbackMessage)
|
||||
error.isServerMessage = Boolean(serverMessage)
|
||||
return error
|
||||
}
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
|
||||
try {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
throw await errorFromResponse(resp, 'Failed to create chore')
|
||||
}
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
|
||||
try {
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
throw await errorFromResponse(resp, 'Failed to save chore')
|
||||
}
|
||||
const updatedChoreRes = await resp.json()
|
||||
if (!updatedChoreRes) {
|
||||
|
||||
475
src/service/FeedbackService.js
Normal file
475
src/service/FeedbackService.js
Normal file
@@ -0,0 +1,475 @@
|
||||
import { InAppReview } from '@capacitor-community/in-app-review'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
|
||||
|
||||
const STATE_KEY = 'feedbackState'
|
||||
|
||||
// Eligibility thresholds for the automatic sentiment prompt.
|
||||
const MIN_COMPLETIONS = 10
|
||||
const MIN_DAYS_SINCE_SIGNUP = 7
|
||||
const COOLDOWN_DAYS = 120
|
||||
// A recent crash/API failure poisons the sentiment reading, so hold off.
|
||||
const ERROR_QUIET_PERIOD_MS = 10 * 60 * 1000
|
||||
|
||||
const APP_STORE_URL =
|
||||
'https://apps.apple.com/app/apple-store/id6742807441?pt=127258663&ct=website&mt=8'
|
||||
const PLAY_STORE_URL =
|
||||
'https://play.google.com/store/apps/details?id=com.donetick.app'
|
||||
const GITHUB_URL = 'https://github.com/donetick/donetick'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const defaultState = {
|
||||
completions: 0,
|
||||
// null until the first prompt is shown/snoozed.
|
||||
lastPromptedAt: null,
|
||||
lastPromptedVersion: null,
|
||||
dismissCount: 0,
|
||||
reviewRequestedAt: null,
|
||||
lastSentiment: null,
|
||||
optedOut: false,
|
||||
// Developer Settings escape hatch; never set in normal use.
|
||||
devForced: false,
|
||||
}
|
||||
|
||||
let cachedState = null
|
||||
|
||||
const readState = async () => {
|
||||
if (cachedState) return cachedState
|
||||
try {
|
||||
const { value } = await Preferences.get({ key: STATE_KEY })
|
||||
cachedState = { ...defaultState, ...(value ? JSON.parse(value) : {}) }
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to read state', error)
|
||||
cachedState = { ...defaultState }
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
const writeState = async patch => {
|
||||
const current = await readState()
|
||||
cachedState = { ...current, ...patch }
|
||||
try {
|
||||
await Preferences.set({
|
||||
key: STATE_KEY,
|
||||
value: JSON.stringify(cachedState),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to persist state', error)
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
export const getFeedbackState = () => readState()
|
||||
|
||||
/**
|
||||
* Counts a completed task towards prompt eligibility. Called from the single
|
||||
* network choke point for completions so offline completions are counted when
|
||||
* they sync rather than twice.
|
||||
*/
|
||||
export const recordTaskCompleted = async () => {
|
||||
const state = await readState()
|
||||
// Stop writing once we are well past the threshold; nothing reads the exact
|
||||
// number beyond reporting it as context.
|
||||
if (state.completions > MIN_COMPLETIONS * 100) return
|
||||
await writeState({ completions: state.completions + 1 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent error breadcrumbs (in memory only, never persisted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recentErrors = []
|
||||
const MAX_ERRORS = 5
|
||||
|
||||
export const recordFeedbackError = message => {
|
||||
if (!message) return
|
||||
recentErrors.push({ at: Date.now(), message: String(message).slice(0, 300) })
|
||||
if (recentErrors.length > MAX_ERRORS) recentErrors.shift()
|
||||
}
|
||||
|
||||
let errorListenersInstalled = false
|
||||
|
||||
export const installFeedbackErrorListeners = () => {
|
||||
if (errorListenersInstalled || typeof window === 'undefined') return
|
||||
errorListenersInstalled = true
|
||||
window.addEventListener('error', event => {
|
||||
recordFeedbackError(event?.message)
|
||||
})
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
recordFeedbackError(event?.reason?.message || event?.reason)
|
||||
})
|
||||
}
|
||||
|
||||
const hasRecentError = () =>
|
||||
recentErrors.some(error => Date.now() - error.at < ERROR_QUIET_PERIOD_MS)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getAppVersion = async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const { App } = await import('@capacitor/app')
|
||||
const info = await App.getInfo()
|
||||
return `${info.version} (${info.build})`
|
||||
} catch {
|
||||
// fall through to the web bundle version
|
||||
}
|
||||
}
|
||||
return import.meta.env.VITE_APP_VERSION || 'web'
|
||||
}
|
||||
|
||||
const getDeviceContext = async () => {
|
||||
try {
|
||||
const info = await Device.getInfo()
|
||||
return {
|
||||
deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
|
||||
osVersion: `${info.operatingSystem} ${info.osVersion}`,
|
||||
}
|
||||
} catch {
|
||||
return { deviceModel: 'unknown', osVersion: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything we attach to a submission without asking the user for it.
|
||||
*/
|
||||
export const collectFeedbackContext = async ({ feature, userProfile } = {}) => {
|
||||
const [version, device, state, isCloud] = await Promise.all([
|
||||
getAppVersion(),
|
||||
getDeviceContext(),
|
||||
readState(),
|
||||
isOfficialDonetickInstance().catch(() => false),
|
||||
])
|
||||
|
||||
const signupDate = userProfile?.created_at
|
||||
const daysSinceSignup = signupDate
|
||||
? Math.floor((Date.now() - new Date(signupDate).getTime()) / DAY_MS)
|
||||
: null
|
||||
|
||||
return {
|
||||
appVersion: version,
|
||||
platform: Capacitor.getPlatform(),
|
||||
isNative: Capacitor.isNativePlatform(),
|
||||
deviceModel: device.deviceModel,
|
||||
osVersion: device.osVersion,
|
||||
locale:
|
||||
localStorage.getItem('i18nextLng') || navigator.language || 'unknown',
|
||||
hosting: isCloud ? 'cloud' : 'self-hosted',
|
||||
feature: feature || 'unknown',
|
||||
tasksCompleted: state.completions,
|
||||
daysSinceSignup,
|
||||
userId: userProfile?.id ?? null,
|
||||
subscription: userProfile?.subscription ?? null,
|
||||
recentErrors: recentErrors.map(error => error.message),
|
||||
submittedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eligibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Runs every gate and reports which ones failed, so Developer Settings can
|
||||
* explain why the prompt is or isn't showing rather than just saying "no".
|
||||
*/
|
||||
export const evaluatePromptEligibility = async ({ userProfile } = {}) => {
|
||||
const state = await readState()
|
||||
const version = await getAppVersion()
|
||||
|
||||
if (state.devForced) {
|
||||
return { eligible: true, forced: true, blockers: [], state, version }
|
||||
}
|
||||
|
||||
const blockers = []
|
||||
|
||||
if (state.optedOut) {
|
||||
blockers.push('User opted out (chose a sentiment, or dismissed 3 times)')
|
||||
}
|
||||
if (state.completions < MIN_COMPLETIONS) {
|
||||
blockers.push(
|
||||
`Only ${state.completions} completions, needs ${MIN_COMPLETIONS}`,
|
||||
)
|
||||
}
|
||||
if (hasRecentError()) {
|
||||
blockers.push('An error occurred in the last 10 minutes')
|
||||
}
|
||||
|
||||
const signupDate = userProfile?.createdAt
|
||||
if (signupDate) {
|
||||
const days = (Date.now() - new Date(signupDate).getTime()) / DAY_MS
|
||||
if (days < MIN_DAYS_SINCE_SIGNUP) {
|
||||
blockers.push(
|
||||
`Account is ${Math.floor(days)} days old, needs ${MIN_DAYS_SINCE_SIGNUP}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.lastPromptedAt) {
|
||||
const daysSincePrompt = (Date.now() - state.lastPromptedAt) / DAY_MS
|
||||
if (daysSincePrompt < COOLDOWN_DAYS) {
|
||||
blockers.push(
|
||||
`Cooldown: ${Math.ceil(COOLDOWN_DAYS - daysSincePrompt)} days remaining`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Never ask twice on the same build, even after the cooldown expires.
|
||||
if (state.lastPromptedVersion && state.lastPromptedVersion === version) {
|
||||
blockers.push(`Already prompted on this version (${version})`)
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: blockers.length === 0,
|
||||
forced: false,
|
||||
blockers,
|
||||
state,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldShowSentimentPrompt = async options =>
|
||||
(await evaluatePromptEligibility(options)).eligible
|
||||
|
||||
/** Developer Settings: bypass every gate on the next eligibility check. */
|
||||
export const setDevForcedPrompt = forced => writeState({ devForced: !!forced })
|
||||
|
||||
/** Developer Settings: back to a never-prompted, zero-completions user. */
|
||||
export const resetFeedbackState = async () => {
|
||||
cachedState = { ...defaultState }
|
||||
try {
|
||||
await Preferences.remove({ key: STATE_KEY })
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to clear state', error)
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
export const markPromptShown = async () => {
|
||||
const version = await getAppVersion()
|
||||
return writeState({
|
||||
lastPromptedAt: Date.now(),
|
||||
lastPromptedVersion: version,
|
||||
// A forced prompt is spent once shown, otherwise it would fire on every
|
||||
// visit to My Chores.
|
||||
devForced: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const markPromptDismissed = async () => {
|
||||
const state = await readState()
|
||||
const dismissCount = state.dismissCount + 1
|
||||
// Three dismissals in a row is an answer: stop asking automatically.
|
||||
return writeState({ dismissCount, optedOut: dismissCount >= 3 })
|
||||
}
|
||||
|
||||
export const markSentiment = async sentiment =>
|
||||
writeState({ lastSentiment: sentiment, dismissCount: 0 })
|
||||
|
||||
export const optOutOfFeedbackPrompts = () => writeState({ optedOut: true })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store review + submission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStoreUrl = () => {
|
||||
const platform = Capacitor.getPlatform()
|
||||
if (platform === 'ios') return APP_STORE_URL
|
||||
if (platform === 'android') return PLAY_STORE_URL
|
||||
return GITHUB_URL
|
||||
}
|
||||
|
||||
export const storeLinks = {
|
||||
appStore: APP_STORE_URL,
|
||||
playStore: PLAY_STORE_URL,
|
||||
github: GITHUB_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OS to show its native review dialog. The OS decides whether to
|
||||
* actually display it and gives no feedback either way, so this resolves true
|
||||
* only to mean "the request went through".
|
||||
*/
|
||||
export const requestStoreReview = async () => {
|
||||
if (!Capacitor.isNativePlatform()) return false
|
||||
try {
|
||||
await InAppReview.requestReview()
|
||||
await writeState({ reviewRequestedAt: Date.now(), optedOut: true })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: review request failed', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const WEBHOOK_URL = import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
|
||||
|
||||
/**
|
||||
* A chat-provider webhook pasted straight into the env var. That can't work —
|
||||
* the app posts its own schema, which Discord rejects with "Cannot send an
|
||||
* empty message" (50006) — and it would publish the webhook to every user,
|
||||
* since VITE_ vars are baked into the bundle. Relay through workers/feedback.
|
||||
*/
|
||||
const isRawChatWebhook = url =>
|
||||
/^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
|
||||
url || '',
|
||||
)
|
||||
|
||||
export const isFeedbackSubmissionConfigured = () =>
|
||||
Boolean(WEBHOOK_URL) && !isRawChatWebhook(WEBHOOK_URL)
|
||||
|
||||
/** Developer Settings: flag the misconfiguration in the UI, not just the log. */
|
||||
export const isRawChatWebhookConfigured = () => isRawChatWebhook(WEBHOOK_URL)
|
||||
|
||||
/**
|
||||
* Whether this app is talking to the hosted donetick.com service. Self-hosted
|
||||
* instances route feedback to GitHub instead of the webhook, so their data
|
||||
* never leaves infrastructure they control.
|
||||
*/
|
||||
export const isCloudInstance = () =>
|
||||
isOfficialDonetickInstance().catch(() => false)
|
||||
|
||||
export const SUBMIT_RESULT = {
|
||||
SENT: 'sent',
|
||||
FAILED: 'failed',
|
||||
UNCONFIGURED: 'unconfigured',
|
||||
MISCONFIGURED: 'misconfigured',
|
||||
SELF_HOSTED: 'self-hosted',
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pre-filled GitHub issue for self-hosted users. Everything the
|
||||
* webhook would have collected goes into the issue body, where the user can
|
||||
* see and edit it before anything is published.
|
||||
*/
|
||||
export const buildGithubIssueUrl = ({
|
||||
sentiment,
|
||||
category,
|
||||
message,
|
||||
context,
|
||||
}) => {
|
||||
const labelFor = {
|
||||
bugs: 'bug',
|
||||
missingFeature: 'feature request',
|
||||
tooComplicated: 'usability',
|
||||
slow: 'performance',
|
||||
notifications: 'notifications',
|
||||
ai: 'ai',
|
||||
other: 'feedback',
|
||||
}
|
||||
const title = `[${labelFor[category] || 'feedback'}] ${
|
||||
message?.split('\n')[0]?.slice(0, 80) || 'App feedback'
|
||||
}`
|
||||
|
||||
const body = [
|
||||
message?.trim() || '_no description_',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<details><summary>Environment</summary>',
|
||||
'',
|
||||
`- App version: ${context.appVersion}`,
|
||||
`- Platform: ${context.platform}${context.isNative ? ' (native)' : ''}`,
|
||||
`- Device: ${context.deviceModel}`,
|
||||
`- OS: ${context.osVersion}`,
|
||||
`- Locale: ${context.locale}`,
|
||||
`- Hosting: ${context.hosting}`,
|
||||
`- Screen: ${context.feature}`,
|
||||
`- Sentiment: ${sentiment}`,
|
||||
...(context.recentErrors.length
|
||||
? ['', 'Recent errors:', '```', ...context.recentErrors, '```']
|
||||
: []),
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n')
|
||||
|
||||
return `${GITHUB_URL}/issues/new?title=${encodeURIComponent(
|
||||
title,
|
||||
)}&body=${encodeURIComponent(body)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the structured feedback plus the auto-collected context to the
|
||||
* configured webhook. Never throws, so the UI can show a soft failure without
|
||||
* losing what the user typed.
|
||||
*
|
||||
* Self-hosted instances are never relayed: the caller gets SELF_HOSTED plus a
|
||||
* pre-filled GitHub issue URL to send the user to instead.
|
||||
*/
|
||||
export const submitFeedback = async ({
|
||||
sentiment,
|
||||
category,
|
||||
message,
|
||||
feature,
|
||||
userProfile,
|
||||
}) => {
|
||||
const context = await collectFeedbackContext({ feature, userProfile })
|
||||
const payload = {
|
||||
source: 'donetick-app',
|
||||
sentiment,
|
||||
category: category || null,
|
||||
message: message?.trim() || null,
|
||||
context,
|
||||
}
|
||||
|
||||
// Enforced here rather than only in the UI so no future caller can leak a
|
||||
// self-hosted user's feedback to the hosted relay.
|
||||
if (context.hosting !== 'cloud') {
|
||||
return {
|
||||
result: SUBMIT_RESULT.SELF_HOSTED,
|
||||
githubUrl: buildGithubIssueUrl({ sentiment, category, message, context }),
|
||||
}
|
||||
}
|
||||
|
||||
if (!WEBHOOK_URL) {
|
||||
console.info('FeedbackService: no webhook configured, feedback:', payload)
|
||||
return { result: SUBMIT_RESULT.UNCONFIGURED }
|
||||
}
|
||||
|
||||
if (isRawChatWebhook(WEBHOOK_URL)) {
|
||||
console.error(
|
||||
'FeedbackService: VITE_FEEDBACK_WEBHOOK_URL points directly at a ' +
|
||||
'Discord/Slack webhook. Discord will reject this with 50006 ' +
|
||||
'("Cannot send an empty message") because the app posts its own ' +
|
||||
'schema, and the URL would ship inside the public bundle. Deploy ' +
|
||||
'workers/feedback and point the variable at the Worker instead.',
|
||||
payload,
|
||||
)
|
||||
return { result: SUBMIT_RESULT.MISCONFIGURED }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return {
|
||||
result: response.ok ? SUBMIT_RESULT.SENT : SUBMIT_RESULT.FAILED,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: submission failed', error)
|
||||
return { result: SUBMIT_RESULT.FAILED }
|
||||
}
|
||||
}
|
||||
|
||||
export const FEEDBACK_CATEGORIES = [
|
||||
'bugs',
|
||||
'missingFeature',
|
||||
'tooComplicated',
|
||||
'slow',
|
||||
'notifications',
|
||||
'ai',
|
||||
'other',
|
||||
]
|
||||
|
||||
export const SENTIMENTS = {
|
||||
LOVE: 'love',
|
||||
OKAY: 'okay',
|
||||
ISSUES: 'issues',
|
||||
}
|
||||
416
src/service/VoiceInputService.js
Normal file
416
src/service/VoiceInputService.js
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// Platform-abstracted speech-to-text for voice task capture.
|
||||
// Native: @capacitor-community/speech-recognition — uses the OS recognizer
|
||||
// (on-device where the platform supports it, e.g. iOS dictation models).
|
||||
// Web: Web Speech API (Chrome/Safari) — mainly for development.
|
||||
//
|
||||
// Callbacks:
|
||||
// onPartial(text) — live transcript of the utterance in progress
|
||||
// onSegment(text) — a finalized utterance (silence/pause boundary)
|
||||
// onStateChange(bool) — listening started/stopped
|
||||
// onError(code) — 'denied' | 'error'
|
||||
//
|
||||
// Neither OS gives unlimited continuous listening: Android's recognizer ends on
|
||||
// silence and iOS sessions have a practical duration limit. This service runs a
|
||||
// restart loop — each recognizer stop commits the buffered utterance as a
|
||||
// segment and immediately starts a new session while active. The utterance
|
||||
// boundary doubles as the task boundary.
|
||||
|
||||
const SILENCE_COMMIT_MS = 2200
|
||||
const RESTART_DELAY_MS = 250
|
||||
// Defense-in-depth: some Android OEM recognizers can die (e.g. after a speech
|
||||
// timeout error) without emitting any event at all, which would otherwise
|
||||
// leave the mic looking "still listening" forever with nothing restarting it.
|
||||
// If no native event of any kind has arrived in this long, assume the session
|
||||
// is dead and force a restart even with no pending partial text.
|
||||
const HEARTBEAT_TIMEOUT_MS = 6000
|
||||
// Native recognizers accept a limited vocabulary hint list; keep it small so
|
||||
// the common names/labels actually get weighted rather than diluted.
|
||||
const MAX_CONTEXTUAL_STRINGS = 100
|
||||
// A known Android build of the plugin never resolved stop()'s promise on
|
||||
// success — any `await`ed native call here hanging silently would otherwise
|
||||
// wedge the whole restart loop (and the mic would look stuck "listening"
|
||||
// forever). Cap every native await so a broken plugin promise can't do that.
|
||||
const NATIVE_CALL_TIMEOUT_MS = 1500
|
||||
// Android forwards both the interim AND the true final transcript (from
|
||||
// onResults) through the same partialResults event, with the final one
|
||||
// typically landing a couple hundred ms after the session is reported
|
||||
// "stopped" — and no flag distinguishes them. The final result is usually
|
||||
// MORE accurate than the last interim (it benefits from the full-utterance
|
||||
// language model rather than a streaming guess), which matters most exactly
|
||||
// on names — the same uncertainty behind "Moutaz" being misheard as
|
||||
// "Models". So rather than committing immediately and discarding the late
|
||||
// final as noise, wait this long after a session ends for it to arrive and
|
||||
// supersede the interim before actually committing.
|
||||
const FINAL_RESULT_GRACE_MS = 450
|
||||
// Safety net for a final result arriving even later than the grace window
|
||||
// (or a duplicate slipping through some other path) — still not committed as
|
||||
// a second task if it looks like the same utterance.
|
||||
const DUPLICATE_GUARD_MS = 3000
|
||||
// Below this fraction of shared words, two transcripts are treated as
|
||||
// different utterances rather than a re-delivery of the same one.
|
||||
const DUPLICATE_WORD_OVERLAP = 0.6
|
||||
|
||||
const withTimeout = (promise, ms) =>
|
||||
Promise.race([promise, new Promise(resolve => setTimeout(resolve, ms))])
|
||||
|
||||
const normalizeForDupeCheck = text =>
|
||||
text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.,!?]/g, '')
|
||||
|
||||
// Word-overlap rather than exact/prefix match: names are exactly the words
|
||||
// ASR is least confident about (the same uncertainty behind "Moutaz" heard as
|
||||
// "Models"), so the final transcript commonly comes back with a different
|
||||
// word around a name than the interim partial that already got committed.
|
||||
// Requiring every character to match would miss that; requiring most of the
|
||||
// same words to match still catches it as the same utterance.
|
||||
const wordOverlapRatio = (a, b) => {
|
||||
const wordsA = new Set(a.split(/\s+/).filter(Boolean))
|
||||
const wordsB = new Set(b.split(/\s+/).filter(Boolean))
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0
|
||||
let shared = 0
|
||||
for (const word of wordsA) {
|
||||
if (wordsB.has(word)) shared++
|
||||
}
|
||||
return shared / Math.max(wordsA.size, wordsB.size)
|
||||
}
|
||||
|
||||
class VoiceInputService {
|
||||
constructor() {
|
||||
this._active = false
|
||||
this._callbacks = null
|
||||
this._partial = ''
|
||||
this._lastSpeechAt = 0
|
||||
this._lastNativeEventAt = 0
|
||||
this._silenceTimer = null
|
||||
this._restarting = false
|
||||
this._restartPromise = null
|
||||
this._webRecognition = null
|
||||
this._contextualStrings = []
|
||||
this._lastCommittedText = ''
|
||||
this._lastCommittedAt = 0
|
||||
this._awaitingFinal = false
|
||||
this._resolveAwaitingFinal = null
|
||||
}
|
||||
|
||||
_startOptions() {
|
||||
return {
|
||||
language: 'en-US',
|
||||
maxResults: 1,
|
||||
partialResults: true,
|
||||
popup: false,
|
||||
contextualStrings: this._contextualStrings,
|
||||
}
|
||||
}
|
||||
|
||||
get isNative() {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
async isSupported() {
|
||||
if (this.isNative) {
|
||||
try {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const { available } = await SpeechRecognition.available()
|
||||
return !!available
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
!!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
||||
)
|
||||
}
|
||||
|
||||
async requestPermission() {
|
||||
if (!this.isNative) {
|
||||
// Web prompts for the microphone on first start()
|
||||
return 'granted'
|
||||
}
|
||||
try {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const current = await SpeechRecognition.checkPermissions()
|
||||
if (current.speechRecognition === 'granted') return 'granted'
|
||||
const res = await SpeechRecognition.requestPermissions()
|
||||
return res.speechRecognition === 'granted' ? 'granted' : 'denied'
|
||||
} catch {
|
||||
return 'denied'
|
||||
}
|
||||
}
|
||||
|
||||
// vocabulary: circle member names + label names, used to bias native
|
||||
// recognition toward the words that matter most for task capture (iOS
|
||||
// contextualStrings / Android 13+ EXTRA_BIASING_STRINGS). Without this, an
|
||||
// unfamiliar name like "Moutaz" can get auto-corrected to a dictionary word.
|
||||
async start(callbacks, vocabulary = []) {
|
||||
if (this._active) return
|
||||
this._callbacks = callbacks
|
||||
this._active = true
|
||||
this._partial = ''
|
||||
this._lastSpeechAt = Date.now()
|
||||
this._lastNativeEventAt = Date.now()
|
||||
this._contextualStrings = [...new Set(vocabulary.filter(Boolean))].slice(
|
||||
0,
|
||||
MAX_CONTEXTUAL_STRINGS,
|
||||
)
|
||||
|
||||
if (this.isNative) {
|
||||
await this._startNative()
|
||||
// Web finalizes utterances itself via isFinal results; only the native
|
||||
// path needs a silence watchdog to force utterance boundaries.
|
||||
this._silenceTimer = setInterval(() => this._checkSilence(), 500)
|
||||
} else {
|
||||
this._startWeb()
|
||||
}
|
||||
this._callbacks?.onStateChange?.(true)
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this._active) return
|
||||
this._active = false
|
||||
if (this._silenceTimer) {
|
||||
clearInterval(this._silenceTimer)
|
||||
this._silenceTimer = null
|
||||
}
|
||||
// Let any in-flight restart (triggered by a native "stopped" event or the
|
||||
// heartbeat) finish tearing down first, so it doesn't resurrect a session
|
||||
// right after the user asked to stop.
|
||||
if (this._restartPromise) {
|
||||
await this._restartPromise
|
||||
}
|
||||
if (this.isNative) {
|
||||
let SpeechRecognition
|
||||
try {
|
||||
;({ SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
))
|
||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||
} catch {
|
||||
// recognizer may already be stopped
|
||||
}
|
||||
// Wait for a possible late-arriving final result while listeners are
|
||||
// still attached — removing them first would mean it's never heard.
|
||||
// Always runs, even if the native stop() call above failed, so we
|
||||
// never skip committing whatever was captured.
|
||||
await this._finalizeSegment()
|
||||
try {
|
||||
await withTimeout(
|
||||
SpeechRecognition?.removeAllListeners(),
|
||||
NATIVE_CALL_TIMEOUT_MS,
|
||||
)
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
} else if (this._webRecognition) {
|
||||
const rec = this._webRecognition
|
||||
this._webRecognition = null
|
||||
try {
|
||||
rec.stop()
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
this._commitPartial()
|
||||
} else {
|
||||
this._commitPartial()
|
||||
}
|
||||
this._callbacks?.onStateChange?.(false)
|
||||
}
|
||||
|
||||
// Called when a session has ended (or is being torn down for restart) and
|
||||
// whatever's in `_partial` is ready to become a task — except Android's
|
||||
// true final transcript, if there is one, is usually still in flight and
|
||||
// hasn't replaced it yet. Give it a brief window to land first.
|
||||
async _finalizeSegment() {
|
||||
if (this._partial.trim() && this.isNative) {
|
||||
this._awaitingFinal = true
|
||||
await new Promise(resolve => {
|
||||
this._resolveAwaitingFinal = resolve
|
||||
setTimeout(resolve, FINAL_RESULT_GRACE_MS)
|
||||
})
|
||||
this._awaitingFinal = false
|
||||
this._resolveAwaitingFinal = null
|
||||
}
|
||||
this._commitPartial()
|
||||
}
|
||||
|
||||
_commitPartial() {
|
||||
const text = this._partial.trim()
|
||||
this._partial = ''
|
||||
this._callbacks?.onPartial?.('')
|
||||
if (text) {
|
||||
this._lastCommittedText = normalizeForDupeCheck(text)
|
||||
this._lastCommittedAt = Date.now()
|
||||
this._callbacks?.onSegment?.(text)
|
||||
}
|
||||
}
|
||||
|
||||
// True if `text` looks like a re-delivery of what we just committed (exact
|
||||
// match, or one is a prefix of the other — covers the final result being a
|
||||
// trimmed/extended variant of the last partial we already committed on).
|
||||
_isEchoOfLastCommit(text) {
|
||||
if (!this._lastCommittedText) return false
|
||||
if (Date.now() - this._lastCommittedAt > DUPLICATE_GUARD_MS) return false
|
||||
const a = normalizeForDupeCheck(text)
|
||||
const b = this._lastCommittedText
|
||||
if (a === b || a.startsWith(b) || b.startsWith(a)) return true
|
||||
return wordOverlapRatio(a, b) >= DUPLICATE_WORD_OVERLAP
|
||||
}
|
||||
|
||||
_checkSilence() {
|
||||
if (!this._active || this._restarting) return
|
||||
const now = Date.now()
|
||||
if (this._partial.trim() && now - this._lastSpeechAt > SILENCE_COMMIT_MS) {
|
||||
// A pause means the utterance (= task) is complete: cycle the recognizer
|
||||
// so the buffer commits and a fresh session begins.
|
||||
this._restartNative()
|
||||
return
|
||||
}
|
||||
if (now - this._lastNativeEventAt > HEARTBEAT_TIMEOUT_MS) {
|
||||
// No native event of any kind for too long — the recognizer likely
|
||||
// died silently (seen on some Android devices/OEMs). Force a restart
|
||||
// so the mic doesn't sit "listening" forever with nothing happening.
|
||||
this._restartNative()
|
||||
}
|
||||
}
|
||||
|
||||
async _startNative() {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
await SpeechRecognition.removeAllListeners()
|
||||
|
||||
await SpeechRecognition.addListener('partialResults', ({ matches }) => {
|
||||
this._lastNativeEventAt = Date.now()
|
||||
const text = matches?.[0] || ''
|
||||
if (!text) return
|
||||
|
||||
if (this._awaitingFinal) {
|
||||
// This is the true final result we were waiting for — it's usually
|
||||
// more accurate than the interim it's replacing, so use it and stop
|
||||
// waiting out the rest of the grace window.
|
||||
this._partial = text
|
||||
this._callbacks?.onPartial?.(text)
|
||||
this._resolveAwaitingFinal?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (this._isEchoOfLastCommit(text)) {
|
||||
// Arrived even later than the grace window (or some other stray
|
||||
// delivery) — still don't let it look like a fresh spoken segment.
|
||||
return
|
||||
}
|
||||
|
||||
this._partial = text
|
||||
this._lastSpeechAt = Date.now()
|
||||
this._callbacks?.onPartial?.(text)
|
||||
})
|
||||
|
||||
await SpeechRecognition.addListener('listeningState', ({ status }) => {
|
||||
this._lastNativeEventAt = Date.now()
|
||||
if (status === 'stopped' && this._active && !this._restarting) {
|
||||
// OS ended the session on its own (silence on Android, session limit
|
||||
// on iOS) — commit and start over.
|
||||
this._restartNative()
|
||||
}
|
||||
})
|
||||
|
||||
// With partialResults the transcript arrives via listeners; the promise's
|
||||
// resolution/rejection timing differs per platform, so don't rely on it.
|
||||
SpeechRecognition.start(this._startOptions()).catch(() => {
|
||||
if (this._active && !this._restarting) {
|
||||
this._restartNative()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_restartNative() {
|
||||
if (this._restarting) return this._restartPromise
|
||||
this._restarting = true
|
||||
this._restartPromise = this._doRestartNative().finally(() => {
|
||||
this._restarting = false
|
||||
this._restartPromise = null
|
||||
})
|
||||
return this._restartPromise
|
||||
}
|
||||
|
||||
async _doRestartNative() {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
try {
|
||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
await this._finalizeSegment()
|
||||
// Let the OS recognizer tear down before starting a new session
|
||||
await new Promise(r => setTimeout(r, RESTART_DELAY_MS))
|
||||
if (this._active) {
|
||||
SpeechRecognition.start(this._startOptions()).catch(() => {})
|
||||
this._lastSpeechAt = Date.now()
|
||||
this._lastNativeEventAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
_startWeb() {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
const rec = new SR()
|
||||
rec.continuous = true
|
||||
rec.interimResults = true
|
||||
rec.lang = 'en-US'
|
||||
|
||||
rec.onresult = event => {
|
||||
let interim = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const res = event.results[i]
|
||||
if (res.isFinal) {
|
||||
this._partial = ''
|
||||
this._callbacks?.onPartial?.('')
|
||||
const text = res[0].transcript.trim()
|
||||
if (text) this._callbacks?.onSegment?.(text)
|
||||
} else {
|
||||
interim += res[0].transcript
|
||||
}
|
||||
}
|
||||
if (interim) {
|
||||
this._partial = interim
|
||||
this._lastSpeechAt = Date.now()
|
||||
this._callbacks?.onPartial?.(interim)
|
||||
}
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
if (this._active && this._webRecognition === rec) {
|
||||
try {
|
||||
rec.start()
|
||||
} catch {
|
||||
// restart can race with teardown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec.onerror = e => {
|
||||
if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
|
||||
this._callbacks?.onError?.('denied')
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
this._webRecognition = rec
|
||||
try {
|
||||
rec.start()
|
||||
} catch {
|
||||
this._callbacks?.onError?.('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceInputService = new VoiceInputService()
|
||||
145
src/service/WidgetService.js
Normal file
145
src/service/WidgetService.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
|
||||
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
|
||||
// android/.../widget/WidgetBridgePlugin.java. It persists the snapshot in
|
||||
// storage the home-screen widgets can read (App Group defaults on iOS,
|
||||
// SharedPreferences on Android) and asks the OS to redraw them.
|
||||
const WidgetBridge = registerPlugin('WidgetBridge')
|
||||
|
||||
const WINDOW_DAYS = 7
|
||||
const MAX_TASKS = 100
|
||||
const MAX_MEMBERS = 12
|
||||
const PUSH_DEBOUNCE_MS = 1500
|
||||
|
||||
const isNative = () => Capacitor.isNativePlatform()
|
||||
|
||||
// The snapshot carries every circle member's actionable tasks (due inside the
|
||||
// 7-day window, overdue included, or awaiting approval — status 3). Each task
|
||||
// records its assignee so the widgets can filter down to "mine" (the default)
|
||||
// or show everyone, per the user's widget configuration; the People widget
|
||||
// derives per-member counts from the same list. The Today widget re-derives
|
||||
// its subset natively from dueDate so one snapshot feeds all widgets.
|
||||
export const buildWidgetTasks = chores => {
|
||||
const endOfWindow = new Date()
|
||||
endOfWindow.setHours(23, 59, 59, 999)
|
||||
endOfWindow.setDate(endOfWindow.getDate() + WINDOW_DAYS)
|
||||
const cutoff = endOfWindow.getTime()
|
||||
|
||||
return (chores || [])
|
||||
.map(chore => {
|
||||
if (!chore || chore.id == null) return null
|
||||
const approval = chore.status === 3
|
||||
const dueDate = chore.nextDueDate
|
||||
? new Date(chore.nextDueDate).getTime()
|
||||
: null
|
||||
const inWindow = dueDate !== null && dueDate <= cutoff
|
||||
if (!approval && !inWindow) return null
|
||||
return {
|
||||
id: chore.id,
|
||||
name: chore.name || '',
|
||||
dueDate,
|
||||
priority: chore.priority || 0,
|
||||
approval,
|
||||
assignedTo: chore.assignedTo == null ? null : String(chore.assignedTo),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (a.approval !== b.approval) return a.approval ? -1 : 1
|
||||
if (a.dueDate === null) return b.dueDate === null ? 0 : 1
|
||||
if (b.dueDate === null) return -1
|
||||
return a.dueDate - b.dueDate
|
||||
})
|
||||
.slice(0, MAX_TASKS)
|
||||
}
|
||||
|
||||
// Circle members, trimmed to what the widgets render: avatar + short name.
|
||||
export const buildWidgetMembers = members => {
|
||||
return (members || [])
|
||||
.filter(member => member && member.userId != null)
|
||||
.slice(0, MAX_MEMBERS)
|
||||
.map(member => ({
|
||||
id: String(member.userId),
|
||||
name: member.displayName || member.username || '',
|
||||
image: member.image || null,
|
||||
}))
|
||||
}
|
||||
|
||||
const pushSnapshot = async queryClient => {
|
||||
const choresData = queryClient.getQueryData(['chores', false])
|
||||
const chores = choresData?.res
|
||||
if (!Array.isArray(chores)) return
|
||||
|
||||
const profileQuery = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ['userProfile'] })
|
||||
.find(q => q.state.data?.id != null)
|
||||
const userId = profileQuery?.state.data?.id
|
||||
if (userId == null) return
|
||||
|
||||
const token = apiClient.getToken()
|
||||
if (!token) return
|
||||
|
||||
const members = queryClient.getQueryData(['allCircleMembers'])?.res
|
||||
|
||||
await WidgetBridge.update({
|
||||
data: JSON.stringify({
|
||||
version: 2,
|
||||
lastUpdated: Date.now(),
|
||||
tasks: buildWidgetTasks(chores),
|
||||
members: buildWidgetMembers(members),
|
||||
}),
|
||||
config: JSON.stringify({
|
||||
serverUrl: apiClient.getApiURL(),
|
||||
token,
|
||||
userId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the react-query cache and mirror every chores update into the
|
||||
* home-screen widgets. Covers both fresh fetches and local mutations
|
||||
* (complete/skip/approve write through the same cache key).
|
||||
*/
|
||||
export const initWidgetSync = queryClient => {
|
||||
if (!isNative()) return () => {}
|
||||
|
||||
let timer = null
|
||||
const schedule = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
pushSnapshot(queryClient).catch(err =>
|
||||
console.error('Widget sync failed', err),
|
||||
)
|
||||
}, PUSH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(event => {
|
||||
const key = event?.query?.queryKey
|
||||
if (
|
||||
event?.type === 'updated' &&
|
||||
(key?.[0] === 'chores' ||
|
||||
key?.[0] === 'userProfile' ||
|
||||
key?.[0] === 'allCircleMembers')
|
||||
) {
|
||||
schedule()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipe widget storage so no task data lingers after logout. */
|
||||
export const clearWidgetData = async () => {
|
||||
if (!isNative()) return
|
||||
try {
|
||||
await WidgetBridge.clear()
|
||||
} catch (err) {
|
||||
console.error('Failed to clear widget data', err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import {
|
||||
clearAllTokens,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
saveTokens,
|
||||
} from './TokenStorage'
|
||||
|
||||
const OAUTH_EXCHANGE_IN_PROGRESS = 'OAuth exchange in progress'
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.customServerURL = `${API_URL}/api/v1`
|
||||
@@ -46,6 +49,13 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
// No session exists yet while an OAuth code exchange is in flight, so there
|
||||
// is nothing to refresh. Callers must treat this as a non-fatal failure
|
||||
// (see request()) rather than an expired session.
|
||||
if (isOAuthExchangeInProgress()) {
|
||||
return { success: false, error: OAUTH_EXCHANGE_IN_PROGRESS }
|
||||
}
|
||||
|
||||
// Check if refresh token is expired BEFORE attempting refresh
|
||||
const refreshExpired = await isRefreshTokenExpired()
|
||||
if (refreshExpired) {
|
||||
@@ -132,6 +142,14 @@ class ApiClient {
|
||||
|
||||
// Helper to avoid repeating cleanup code
|
||||
async handleLogout() {
|
||||
// Backstop for every forced-logout path: never tear down the session while
|
||||
// an OAuth exchange is running, or we clear the tokens it just saved and
|
||||
// reload the page out from under it.
|
||||
if (isOAuthExchangeInProgress()) {
|
||||
console.log('Skipping forced logout: OAuth exchange in progress')
|
||||
return
|
||||
}
|
||||
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
@@ -145,6 +163,13 @@ class ApiClient {
|
||||
} catch (e) {
|
||||
console.error('Error clearing image cache on logout', e)
|
||||
}
|
||||
try {
|
||||
// Dynamic import sidesteps the ApiClient <-> WidgetService module cycle
|
||||
const { clearWidgetData } = await import('../service/WidgetService')
|
||||
await clearWidgetData()
|
||||
} catch (e) {
|
||||
console.error('Error clearing widget data on logout', e)
|
||||
}
|
||||
try {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
@@ -223,6 +248,13 @@ class ApiClient {
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
} else if (refreshResult.error === OAUTH_EXCHANGE_IN_PROGRESS) {
|
||||
// Expected 401: the code exchange hasn't produced tokens yet. Fail
|
||||
// just this request — logging out here would wipe storage and hard
|
||||
// navigate to /login, aborting the exchange fetch mid-flight.
|
||||
queuedPromise.catch(() => {}) // not returned below; keep it handled
|
||||
this.processQueue(new Error(refreshResult.error), null)
|
||||
return response
|
||||
} else if (refreshResult.error === 'Already refreshing') {
|
||||
// This shouldn't happen since we check isRefreshing above, but handle it anyway
|
||||
console.log('Already refreshing - waiting for refresh to complete')
|
||||
|
||||
@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify(body),
|
||||
}).then(response => {
|
||||
if (response?.ok) {
|
||||
// Single choke point for completions, so queued offline completions are
|
||||
// counted once, when they sync.
|
||||
import('../service/FeedbackService')
|
||||
.then(({ recordTaskCompleted }) => recordTaskCompleted())
|
||||
.catch(() => {})
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
24
src/utils/OAuthExchangeState.js
Normal file
24
src/utils/OAuthExchangeState.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Tracks whether an OAuth authorization-code exchange is currently in flight.
|
||||
//
|
||||
// During that window the app is legitimately unauthenticated: the deep link has
|
||||
// arrived but Authenticating.jsx has not received tokens yet. Any 401 from an
|
||||
// unrelated request (background sync, a resumed query) must NOT be treated as an
|
||||
// expired session — the forced logout it triggers clears storage and does a hard
|
||||
// `window.location.href = '/login'`, which tears down the page and aborts the
|
||||
// in-flight code exchange.
|
||||
let exchangeInProgress = false
|
||||
|
||||
export const beginOAuthExchange = () => {
|
||||
exchangeInProgress = true
|
||||
}
|
||||
|
||||
export const endOAuthExchange = () => {
|
||||
exchangeInProgress = false
|
||||
}
|
||||
|
||||
// The in-memory flag covers the native deep-link path, where the callback
|
||||
// arrives while the app is already running. On web the provider redirects with a
|
||||
// full page load, so nothing has run to set the flag — the pathname check covers
|
||||
// that case (and doubles as a backstop if the flag is never cleared).
|
||||
export const isOAuthExchangeInProgress = () =>
|
||||
exchangeInProgress || window.location.pathname === '/auth/oauth2'
|
||||
160
src/views/Authorization/AuthFields.jsx
Normal file
160
src/views/Authorization/AuthFields.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { authButtonSx, authInputSx } from './authStyles'
|
||||
|
||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||
|
||||
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
<FormControl error={Boolean(error)} {...formProps}>
|
||||
<FormLabel sx={labelSx}>{label}</FormLabel>
|
||||
{children}
|
||||
{(error || helper) && (
|
||||
<FormHelperText
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
color: error ? 'danger.plainColor' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{error || helper}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||
</AuthField>
|
||||
)
|
||||
|
||||
export const AuthPasswordField = ({
|
||||
label = 'Password',
|
||||
error,
|
||||
helper,
|
||||
sx,
|
||||
...inputProps
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input
|
||||
size='lg'
|
||||
type={visible ? 'text' : 'password'}
|
||||
sx={{ ...authInputSx, ...sx }}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
onClick={() => setVisible(v => !v)}
|
||||
sx={{ borderRadius: '8px' }}
|
||||
>
|
||||
{visible ? (
|
||||
<VisibilityOffOutlined fontSize='small' />
|
||||
) : (
|
||||
<VisibilityOutlined fontSize='small' />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
</AuthField>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthSubmitButton = ({ children, sx, ...props }) => (
|
||||
<Button
|
||||
type='submit'
|
||||
size='lg'
|
||||
variant='solid'
|
||||
fullWidth
|
||||
sx={{ ...authButtonSx, ...sx }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const SocialButton = ({ icon, children, sx, ...props }) => (
|
||||
<Button
|
||||
type='button'
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
startDecorator={icon}
|
||||
sx={{
|
||||
...authButtonSx,
|
||||
fontWeight: 500,
|
||||
justifyContent: 'center',
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const AuthDivider = ({ children = 'or' }) => (
|
||||
<Box
|
||||
role='separator'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
my: 2.5,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
bgcolor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const LegalLinks = () => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
<Link
|
||||
href='https://donetick.com/privacy'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link
|
||||
href='https://donetick.com/terms'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
</Typography>
|
||||
)
|
||||
117
src/views/Authorization/AuthShell.jsx
Normal file
117
src/views/Authorization/AuthShell.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Box, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
/**
|
||||
* Full-height auth layout: edge-to-edge on phones, a centered surface card from
|
||||
* the `sm` breakpoint up. The route renders without a navbar, so the shell owns
|
||||
* its own safe-area padding (the top inset is already reserved by NavBar).
|
||||
*/
|
||||
const AuthShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
logoSize = 48,
|
||||
// In the app the user already came through the app icon and the Get Started
|
||||
// mark, so repeating it here is noise. On the web these routes are the first
|
||||
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
|
||||
// so the mark is the only thing identifying the app. Views reached from an
|
||||
// emailed link override this to always show it.
|
||||
showLogo = !Capacitor.isNativePlatform(),
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
pt: { xs: 3, sm: 5 },
|
||||
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
{/* my:auto centers the column without the top-clipping that
|
||||
justify-content:center causes once the form outgrows the viewport. */}
|
||||
<Box sx={{ width: '100%', maxWidth: 420, my: 'auto' }}>
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: { xs: 0, sm: '20px' },
|
||||
bgcolor: { xs: 'transparent', sm: 'background.surface' },
|
||||
border: { xs: 'none', sm: '1px solid' },
|
||||
borderColor: { sm: 'divider' },
|
||||
boxShadow: { xs: 'none', sm: 'sm' },
|
||||
p: { xs: 0, sm: 3.5 },
|
||||
animation: 'authPanelIn 240ms cubic-bezier(0.22, 1, 0.36, 1) both',
|
||||
'@keyframes authPanelIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{action && (
|
||||
<Box sx={{ position: 'absolute', top: 0, right: 0 }}>{action}</Box>
|
||||
)}
|
||||
|
||||
{/* Mark only: the wordmark sat at nearly the same size and weight as
|
||||
the title below it, so the two competed instead of forming a
|
||||
hierarchy. */}
|
||||
{showLogo && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Logo size={`${logoSize}px`} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<Typography
|
||||
level='h2'
|
||||
sx={{
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: title || subtitle ? 3 : 0 }}>{children}</Box>
|
||||
</Sheet>
|
||||
|
||||
{footer && <Box sx={{ mt: 2.5 }}>{footer}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthShell
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user