Merge branch 'develop' into support-audio-input
This commit is contained in:
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
|
||||
@@ -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 49
|
||||
versionName "1.2.28"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -125,6 +125,12 @@
|
||||
<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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -462,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 = 49;
|
||||
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.28;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -485,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 = 49;
|
||||
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.28;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -504,12 +504,12 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.28;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -527,12 +527,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.28;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -47,20 +47,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)
|
||||
@@ -178,10 +168,6 @@ SPEC REPOS:
|
||||
- Alamofire
|
||||
- AppAuth
|
||||
- AppCheckCore
|
||||
- FBAEMKit
|
||||
- FBSDKCoreKit
|
||||
- FBSDKCoreKit_Basics
|
||||
- FBSDKLoginKit
|
||||
- FirebaseCore
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
@@ -267,11 +253,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
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.27",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||
@@ -26,7 +26,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",
|
||||
@@ -2157,9 +2157,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capgo/capacitor-social-login": {
|
||||
"version": "8.3.36",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.36.tgz",
|
||||
"integrity": "sha512-Y4HYjEJablzbHHwPBrbC2UvHVPsR0bAyCAqBzVBoUEOf0UFHAHyTP3gD9JUTKP/iERU8I+6IA2RWtLqDePCNuA==",
|
||||
"version": "8.3.38",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.38.tgz",
|
||||
"integrity": "sha512-SHrQ9gVJ2oCxf6dQu6Bq4PwdzYIYX7OVuUNyuGPFxLWPR8DCJM50gEhdAmunzEVd1vlty0ShH+84IOU5FEHLPg==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.16",
|
||||
"version": "1.2.28",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -56,7 +56,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",
|
||||
|
||||
@@ -103,7 +103,7 @@ const MyChores = () => {
|
||||
const searchInputRef = useRef(null)
|
||||
const [searchInputFocus, setSearchInputFocus] = useState(0)
|
||||
const [selectedChoreSection, setSelectedChoreSection] = useState(
|
||||
localStorage.getItem('selectedChoreSection') || 'due_date',
|
||||
localStorage.getItem('selectedChoreSection') || 'default',
|
||||
)
|
||||
const [openChoreSections, setOpenChoreSections] = useState(() => {
|
||||
try {
|
||||
|
||||
@@ -61,6 +61,7 @@ import FilterBuilderContent, {
|
||||
} from './FilterBuilderContent'
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
import CustomFilterChips from './CustomFilterChips'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
@@ -481,13 +482,6 @@ const ChoreToolbar = ({
|
||||
// ── display sheet helpers ────────────────────────────────────────────────────
|
||||
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
|
||||
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
|
||||
const displayActive =
|
||||
selectedGroupBy !== 'default' ||
|
||||
viewMode !== 'default' ||
|
||||
projectActive > 0 ||
|
||||
assigneeActive > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
@@ -559,8 +553,8 @@ const ChoreToolbar = ({
|
||||
|
||||
{/* Display button — View + Group combined */}
|
||||
<IconButton
|
||||
variant={displayActive ? 'solid' : 'outlined'}
|
||||
color={displayActive ? 'primary' : 'neutral'}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
@@ -599,8 +593,24 @@ const ChoreToolbar = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||
{hasAnyActive && (
|
||||
{/* ── Row 2: pinned filters quick access ──────────────────────────────── */}
|
||||
{/* Stays visible even while a saved filter is active (it self-highlights)
|
||||
so switching between pinned filters never requires clearing first.
|
||||
Only steps aside for an ad-hoc/temp condition set, which has no
|
||||
matching pinned identity to show. */}
|
||||
{tempConditionCount === 0 && (
|
||||
<CustomFilterChips
|
||||
filters={savedFilters}
|
||||
activeFilterId={activeFilterId}
|
||||
onFilterClick={onSavedFilterClick}
|
||||
onFilterDelete={onSavedFilterDelete}
|
||||
onFilterPin={onSavedFilterPin}
|
||||
onFilterEdit={onSavedFilterEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Row 3: active (temp/ad-hoc) filter chips ──────────────────────────── */}
|
||||
{tempConditionCount > 0 && (
|
||||
<ActiveFilterChips
|
||||
chips={inlineChips}
|
||||
onOpen={openFilterSheet}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Check,
|
||||
Delete,
|
||||
Edit,
|
||||
MoreVert,
|
||||
Settings,
|
||||
Star,
|
||||
StarBorder,
|
||||
@@ -66,158 +67,163 @@ const CustomFilterChips = ({
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const sortedFilters = [...filters].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
return (b.usageCount || 0) - (a.usageCount || 0)
|
||||
})
|
||||
if (sortedFilters.filter(f => f.isPinned).length === 0) return null
|
||||
const pinnedFilters = filters
|
||||
.filter(f => f.isPinned)
|
||||
.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
|
||||
|
||||
if (pinnedFilters.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
overflowX: 'auto',
|
||||
py: 1,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
scrollbarWidth: 'none', // Firefox
|
||||
msOverflowStyle: 'none', // IE and Edge
|
||||
}}
|
||||
>
|
||||
{sortedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
const hasWarning = !filter.isValid
|
||||
const hasCustomColor = !!filter.color && !hasWarning
|
||||
const textColor = hasCustomColor
|
||||
? getTextColorFromBackgroundColor(filter.color)
|
||||
: undefined
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.75,
|
||||
overflowX: 'auto',
|
||||
py: 1,
|
||||
pr: 2,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
scrollbarWidth: 'none', // Firefox
|
||||
msOverflowStyle: 'none', // IE and Edge
|
||||
}}
|
||||
>
|
||||
{pinnedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
const hasWarning = !filter.isValid
|
||||
const badgeColor = filter.color || 'var(--joy-palette-neutral-400)'
|
||||
const badgeTextColor = filter.color
|
||||
? getTextColorFromBackgroundColor(filter.color)
|
||||
: '#ffffff'
|
||||
const displayCount =
|
||||
filter.count > 99 ? '99+' : (filter.count ?? 0)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={filter.id}
|
||||
title={
|
||||
hasWarning
|
||||
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
|
||||
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
|
||||
}
|
||||
placement='bottom'
|
||||
>
|
||||
<div onClick={() => !hasWarning && onFilterClick(filter.id)}>
|
||||
return (
|
||||
<Tooltip
|
||||
key={filter.id}
|
||||
title={
|
||||
hasWarning
|
||||
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
|
||||
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
|
||||
}
|
||||
placement='bottom'
|
||||
>
|
||||
<Chip
|
||||
variant='solid'
|
||||
variant={isActive ? 'solid' : 'outlined'}
|
||||
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
|
||||
size='md'
|
||||
onContextMenu={e => handleContextMenu(e, filter)}
|
||||
onClick={() => !hasWarning && onFilterClick(filter.id)}
|
||||
sx={{
|
||||
cursor: hasWarning ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
px: 1.0,
|
||||
py: 0.5,
|
||||
transition: 'background-color 0.15s ease, color 0.15s ease',
|
||||
px: 1,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
|
||||
...(hasCustomColor && {
|
||||
backgroundColor: `${filter.color} !important`,
|
||||
color: `${textColor} !important`,
|
||||
'&:hover': {
|
||||
backgroundColor: `${filter.color} !important`,
|
||||
filter: 'brightness(0.95)',
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
flexShrink: 0,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
'&:hover': {
|
||||
backgroundColor: isActive ? undefined : 'neutral.softHoverBg',
|
||||
},
|
||||
}}
|
||||
startDecorator={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{isActive ? (
|
||||
<Check
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
color: hasCustomColor ? textColor : 'primary.500',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{
|
||||
...(hasCustomColor
|
||||
? {
|
||||
bgcolor:
|
||||
textColor === '#FFFFFF'
|
||||
? '#00000040'
|
||||
: '#FFFFFF40',
|
||||
color: textColor,
|
||||
border: `1px solid ${textColor}30`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
color={
|
||||
hasCustomColor
|
||||
? undefined
|
||||
: hasWarning
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{filter.count}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
!hasWarning && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
bgcolor: badgeColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check
|
||||
sx={{ fontSize: '0.85rem', color: badgeTextColor }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
lineHeight: 1,
|
||||
color: badgeTextColor,
|
||||
}}
|
||||
>
|
||||
{displayCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleContextMenu(e, filter)
|
||||
}}
|
||||
sx={{
|
||||
'--IconButton-size': '20px',
|
||||
ml: 0.25,
|
||||
opacity: 0.6,
|
||||
'&:hover': { opacity: 1, backgroundColor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: '0.95rem' }} />
|
||||
</IconButton>
|
||||
}
|
||||
// endDecorator={
|
||||
// <Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
// {hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
|
||||
// {!hasWarning && filter.overdueCount > 0 && (
|
||||
// <Chip size='sm' color='danger' variant='solid'>
|
||||
// {filter.overdueCount}
|
||||
// </Chip>
|
||||
// )}
|
||||
// </Box>
|
||||
// }
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
fontWeight={isActive ? 'md' : 'normal'}
|
||||
fontWeight='inherit'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 100,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
lineHeight: 1,
|
||||
...(hasCustomColor && {
|
||||
color: textColor,
|
||||
}),
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{ borderRadius: 24, flexShrink: 0 }}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate('/filters')
|
||||
}}
|
||||
>
|
||||
<Settings sx={{ fontSize: '1.1rem' }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Fade hint that more chips are scrollable off the trailing edge */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
background:
|
||||
'linear-gradient(to right, transparent, var(--joy-palette-background-surface, #fff))',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate('/filters')
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
/>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// AutocompleteDropdown.jsx
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Divider, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
@@ -8,6 +9,7 @@ const AutocompleteDropdown = ({
|
||||
selectedIndex,
|
||||
onSelectSuggestion,
|
||||
onMouseEnterSuggestion, // Added for hover selection
|
||||
onCreateSuggestion, // Called when the "Create new" row is chosen
|
||||
parentRefer, // Ref to the dropdown element
|
||||
}) => {
|
||||
// Scroll selected item into view
|
||||
@@ -26,9 +28,28 @@ const AutocompleteDropdown = ({
|
||||
return null // Don't render if no suggestions
|
||||
}
|
||||
|
||||
const filteredOptions = suggestions.options.filter(option => {
|
||||
if (typeof option === 'string') {
|
||||
return option.toLowerCase().includes(currentValue.toLowerCase())
|
||||
}
|
||||
return option[suggestions.display]
|
||||
.toLowerCase()
|
||||
.includes(currentValue.toLowerCase())
|
||||
})
|
||||
|
||||
const trimmedValue = currentValue.trim()
|
||||
const hasExactMatch = filteredOptions.some(option => {
|
||||
const optionText = suggestions.display
|
||||
? option[suggestions.display]
|
||||
: option
|
||||
return optionText.toLowerCase() === trimmedValue.toLowerCase()
|
||||
})
|
||||
const showCreateOption =
|
||||
suggestions.creatable && trimmedValue.length > 0 && !hasExactMatch
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={Boolean(suggestions?.options?.length)}
|
||||
open={Boolean(suggestions?.options?.length) || showCreateOption}
|
||||
ref={dropdownMenuRef}
|
||||
anchorEl={parentRefer.current}
|
||||
onClose={() => {}}
|
||||
@@ -42,45 +63,45 @@ const AutocompleteDropdown = ({
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
{suggestions?.options
|
||||
.filter(option => {
|
||||
if (typeof option === 'string') {
|
||||
return option.toLowerCase().includes(currentValue.toLowerCase())
|
||||
}
|
||||
return option[suggestions.display]
|
||||
.toLowerCase()
|
||||
.includes(currentValue.toLowerCase())
|
||||
})
|
||||
.map((option, index) => (
|
||||
{filteredOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={suggestions.display ? option[suggestions.value] : option}
|
||||
selected={selectedIndex === index}
|
||||
onClick={() => onSelectSuggestion(option)}
|
||||
onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover
|
||||
className={selectedIndex === index ? 'selected' : ''} // Add class for selected item
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit',
|
||||
}}
|
||||
>
|
||||
{suggestions.display ? option[suggestions.display] : option}
|
||||
</MenuItem>
|
||||
))}
|
||||
{showCreateOption && (
|
||||
<>
|
||||
{filteredOptions.length > 0 && <Divider orientation='horizontal' />}
|
||||
<MenuItem
|
||||
key={suggestions.display ? option[suggestions.value] : option}
|
||||
selected={selectedIndex === index}
|
||||
onClick={() => onSelectSuggestion(option)}
|
||||
onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover
|
||||
className={selectedIndex === index ? 'selected' : ''} // Add class for selected item
|
||||
selected={selectedIndex === filteredOptions.length}
|
||||
onClick={() => onCreateSuggestion(trimmedValue)}
|
||||
onMouseEnter={() => onMouseEnterSuggestion(filteredOptions.length)}
|
||||
className={
|
||||
selectedIndex === filteredOptions.length ? 'selected' : ''
|
||||
}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit',
|
||||
backgroundColor:
|
||||
selectedIndex === filteredOptions.length
|
||||
? 'gray.800'
|
||||
: 'inherit',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{suggestions.display ? option[suggestions.display] : option}
|
||||
<Add fontSize='small' />
|
||||
Create "{trimmedValue}"
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider orientation='horizontal' />
|
||||
{/*
|
||||
<MenuItem
|
||||
selected={selectedIndex === suggestions?.options?.length}
|
||||
onClick={() => {
|
||||
onSelectSuggestion(currentValue)
|
||||
}}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'gray.800',
|
||||
}}
|
||||
>
|
||||
<Add />
|
||||
Add new Label
|
||||
</MenuItem> */}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
@@ -25,7 +27,7 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
@@ -69,9 +71,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
useCircleMembers()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const handleCreateLabel = useCallback(
|
||||
name => {
|
||||
const color =
|
||||
LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))]
|
||||
.value
|
||||
CreateLabel({ name, color })
|
||||
.then(() => queryClient.invalidateQueries(['labels']))
|
||||
.catch(error => console.error('Error creating label:', error))
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
@@ -412,11 +427,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
if (priority.result) setPriority(parseInt(priority.result, 10))
|
||||
if (pointsParsed.result) setPoints(pointsParsed.result)
|
||||
if (labels.result) {
|
||||
// parseLabels returns array of label objects, extract their IDs
|
||||
const labelIds = labels.result
|
||||
.filter(label => label.id) // Only labels with IDs (existing labels)
|
||||
.map(label => label.id)
|
||||
setLabelsV2(labelIds)
|
||||
// parseLabels only returns #mentions matched to an existing label
|
||||
setLabelsV2(labels.result)
|
||||
}
|
||||
|
||||
if (assigneesResult.isAnyone) {
|
||||
@@ -958,6 +970,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
creatable: true,
|
||||
onCreate: handleCreateLabel,
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
@@ -1056,8 +1070,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
/>
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
values={(labelsV2 || []).map(label => label.id)}
|
||||
onChange={ids =>
|
||||
setLabelsV2(
|
||||
(userLabels || []).filter(label => ids.includes(label.id)),
|
||||
)
|
||||
}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
|
||||
@@ -97,64 +97,70 @@ export const parsePriority = inputSentence => {
|
||||
}
|
||||
}
|
||||
export const parseLabels = (inputSentence, userLabels) => {
|
||||
let sentence = inputSentence.toLowerCase()
|
||||
const currentLabels = []
|
||||
const newLabels = []
|
||||
const matchedRanges = []
|
||||
const allHighlights = []
|
||||
|
||||
// Find all #label patterns in the sentence
|
||||
// Use [\p{L}\p{N}_]+ to support Unicode letters (including umlauts) and numbers
|
||||
const labelPattern = /#([\p{L}\p{N}_]+)/giu
|
||||
const matches = [...inputSentence.matchAll(labelPattern)]
|
||||
// Labels can contain spaces (e.g. "New Label"), so a plain word-boundary
|
||||
// regex can't capture them — check each '#' against real label names
|
||||
// instead, longest name first so "New Label" wins over a label named "New".
|
||||
const sortedLabels = [...userLabels].sort(
|
||||
(a, b) => b.name.length - a.name.length,
|
||||
)
|
||||
const isWordChar = ch => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch)
|
||||
|
||||
for (const match of matches) {
|
||||
const labelName = match[1]
|
||||
const fullMatch = match[0]
|
||||
const startIndex = match.index
|
||||
const hashPattern = /#/g
|
||||
let hashMatch
|
||||
while ((hashMatch = hashPattern.exec(inputSentence)) !== null) {
|
||||
const startIndex = hashMatch.index
|
||||
const rest = inputSentence.slice(startIndex + 1)
|
||||
|
||||
// Check if this label already exists
|
||||
const existingLabel = userLabels.find(
|
||||
label => label.name.toLowerCase() === labelName.toLowerCase(),
|
||||
)
|
||||
const existingLabel = sortedLabels.find(label => {
|
||||
const name = label.name
|
||||
if (rest.toLowerCase().slice(0, name.length) !== name.toLowerCase()) {
|
||||
return false
|
||||
}
|
||||
// Require a non-word boundary right after the name so "New" doesn't
|
||||
// match inside a longer typed word like "Newer".
|
||||
return !isWordChar(rest[name.length])
|
||||
})
|
||||
|
||||
if (existingLabel) {
|
||||
currentLabels.push(existingLabel)
|
||||
} else {
|
||||
// Create a new label object for new labels
|
||||
newLabels.push({
|
||||
name: labelName,
|
||||
color: '#3b82f6', // Default blue color
|
||||
isNew: true,
|
||||
})
|
||||
}
|
||||
// Unmatched #mentions (no existing label, not created) are left as plain
|
||||
// text — only #mentions resolving to a real label are extracted/cleaned.
|
||||
if (!existingLabel) continue
|
||||
|
||||
allHighlights.push({
|
||||
text: fullMatch,
|
||||
const fullMatch = `#${existingLabel.name}`
|
||||
currentLabels.push(existingLabel)
|
||||
matchedRanges.push({
|
||||
start: startIndex,
|
||||
end: startIndex + fullMatch.length,
|
||||
})
|
||||
|
||||
// Remove the label from the sentence
|
||||
sentence = sentence.replace(fullMatch.toLowerCase(), '')
|
||||
allHighlights.push({
|
||||
text: inputSentence.slice(startIndex, startIndex + fullMatch.length),
|
||||
start: startIndex,
|
||||
end: startIndex + fullMatch.length,
|
||||
})
|
||||
hashPattern.lastIndex = startIndex + fullMatch.length
|
||||
}
|
||||
|
||||
const allLabels = [...currentLabels, ...newLabels]
|
||||
if (currentLabels.length > 0) {
|
||||
let cleanedSentence = inputSentence
|
||||
for (const range of matchedRanges
|
||||
.slice()
|
||||
.sort((a, b) => b.start - a.start)) {
|
||||
cleanedSentence =
|
||||
cleanedSentence.slice(0, range.start) + cleanedSentence.slice(range.end)
|
||||
}
|
||||
|
||||
if (allLabels.length > 0) {
|
||||
return {
|
||||
result: allLabels,
|
||||
newLabels: newLabels,
|
||||
result: currentLabels,
|
||||
highlight: allHighlights,
|
||||
cleanedSentence: inputSentence
|
||||
.replace(labelPattern, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: null,
|
||||
newLabels: [],
|
||||
cleanedSentence: inputSentence,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,46 +112,71 @@ const SmartTaskTitleInput = ({
|
||||
setCursorPosition(e.target.selectionStart)
|
||||
}
|
||||
|
||||
const selectSuggestionText = suggestionValue => {
|
||||
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
|
||||
onChange(newValue)
|
||||
titleInputRef.current.value = newValue
|
||||
|
||||
setShowSuggestions(false)
|
||||
|
||||
const newCursorPosition =
|
||||
cursorPosition - lastWord.length + suggestionValue.length + 1
|
||||
titleInputRef.current.setSelectionRange(
|
||||
newCursorPosition,
|
||||
newCursorPosition,
|
||||
)
|
||||
}
|
||||
|
||||
const handleTextareaKeyDown = e => {
|
||||
if (showSuggestions) {
|
||||
const currentSuggestions = suggestions[suggestionTrigger].options.filter(
|
||||
option => {
|
||||
if (typeof option === 'string') {
|
||||
return option.toLowerCase().includes(lastWord.toLowerCase())
|
||||
}
|
||||
return option[suggestions[suggestionTrigger].display]
|
||||
.toLowerCase()
|
||||
.includes(lastWord.toLowerCase())
|
||||
},
|
||||
)
|
||||
const activeSuggestions = suggestions[suggestionTrigger]
|
||||
const currentSuggestions = activeSuggestions.options.filter(option => {
|
||||
if (typeof option === 'string') {
|
||||
return option.toLowerCase().includes(lastWord.toLowerCase())
|
||||
}
|
||||
return option[activeSuggestions.display]
|
||||
.toLowerCase()
|
||||
.includes(lastWord.toLowerCase())
|
||||
})
|
||||
|
||||
const trimmedWord = lastWord.trim()
|
||||
const hasExactMatch = currentSuggestions.some(option => {
|
||||
const optionText = activeSuggestions.display
|
||||
? option[activeSuggestions.display]
|
||||
: option
|
||||
return optionText.toLowerCase() === trimmedWord.toLowerCase()
|
||||
})
|
||||
const showCreateOption =
|
||||
activeSuggestions.creatable && trimmedWord.length > 0 && !hasExactMatch
|
||||
const optionCount = currentSuggestions.length + (showCreateOption ? 1 : 0)
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (optionCount === 0) return
|
||||
const newIndex =
|
||||
e.key === 'ArrowDown'
|
||||
? (selectedSuggestionIndex + 1) % currentSuggestions.length
|
||||
: (selectedSuggestionIndex - 1 + currentSuggestions.length) %
|
||||
currentSuggestions.length
|
||||
? (selectedSuggestionIndex + 1) % optionCount
|
||||
: (selectedSuggestionIndex - 1 + optionCount) % optionCount
|
||||
setSelectedSuggestionIndex(newIndex)
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (
|
||||
showCreateOption &&
|
||||
selectedSuggestionIndex === currentSuggestions.length
|
||||
) {
|
||||
e.preventDefault()
|
||||
selectSuggestionText(trimmedWord)
|
||||
activeSuggestions.onCreate?.(trimmedWord)
|
||||
return
|
||||
}
|
||||
|
||||
const selectedSuggestion = currentSuggestions[selectedSuggestionIndex]
|
||||
const suggestionValue = suggestions[suggestionTrigger].display
|
||||
? selectedSuggestion[suggestions[suggestionTrigger].display]
|
||||
const suggestionValue = activeSuggestions.display
|
||||
? selectedSuggestion?.[activeSuggestions.display]
|
||||
: selectedSuggestion
|
||||
|
||||
if (suggestionValue) {
|
||||
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
|
||||
onChange(newValue)
|
||||
titleInputRef.current.value = newValue
|
||||
|
||||
setShowSuggestions(false)
|
||||
|
||||
const newCursorPosition = cursorPosition + suggestionValue.length + 1
|
||||
titleInputRef.current.setSelectionRange(
|
||||
newCursorPosition,
|
||||
newCursorPosition,
|
||||
)
|
||||
e.preventDefault()
|
||||
selectSuggestionText(suggestionValue)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
@@ -364,6 +389,10 @@ const SmartTaskTitleInput = ({
|
||||
)
|
||||
setShowSuggestions(false)
|
||||
}}
|
||||
onCreateSuggestion={name => {
|
||||
selectSuggestionText(name)
|
||||
suggestions[suggestionTrigger].onCreate?.(name)
|
||||
}}
|
||||
parentRefer={dropdownRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user