Merge branch 'develop' into support-audio-input

This commit is contained in:
Mohamad Tarbin
2026-07-28 02:31:16 -04:00
committed by GitHub
17 changed files with 388 additions and 284 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -230,4 +230,4 @@ DEPENDENCIES
fastlane
BUNDLED WITH
1.17.2
2.4.10

View File

@@ -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.

View File

@@ -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" />

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
View File

@@ -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"

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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}

View File

@@ -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 &quot;{trimmedValue}&quot;
</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>
)
}

View File

@@ -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 || []}
/>

View File

@@ -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,
}
}

View File

@@ -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}
/>
)}