Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ef8ae82f | ||
|
|
261a6d4d40 | ||
|
|
0b87f3e4ed | ||
|
|
7f8a764131 | ||
|
|
9a9aff0880 | ||
|
|
f188dde6e4 | ||
|
|
4566794f44 | ||
|
|
c727142689 | ||
|
|
4420c72965 | ||
|
|
e55528e69f | ||
|
|
e86aa5e535 | ||
|
|
59f95d8174 | ||
|
|
159777818d | ||
|
|
00ac23160e | ||
|
|
46f9b4434e | ||
|
|
d0cb43435b | ||
|
|
34bc152817 | ||
|
|
fc8c5976f8 | ||
|
|
259d21c968 | ||
|
|
21a2cffcc0 | ||
|
|
865e0b3301 | ||
|
|
7fad4b5eec | ||
|
|
2773255b38 | ||
|
|
85ef3dc38b | ||
|
|
ab4ddb9f8b | ||
|
|
448279d98c | ||
|
|
7e224301cd | ||
|
|
e87d31aded | ||
|
|
964a4fe0f9 | ||
|
|
fd1c473cb6 |
@@ -46,6 +46,8 @@ jobs:
|
||||
"build-tools;35.0.0" \
|
||||
"build-tools;36.0.0" \
|
||||
"ndk;27.1.12297006"
|
||||
mkdir -p carry-your-live/android/app
|
||||
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
||||
|
||||
- name: Install JS dependencies
|
||||
working-directory: carry-your-live
|
||||
@@ -53,6 +55,7 @@ jobs:
|
||||
|
||||
- name: Generate debug keystore (signs the release APK)
|
||||
run: |
|
||||
mkdir -p carry-your-live/android/app
|
||||
keytool -genkeypair -v \
|
||||
-keystore carry-your-live/android/app/debug.keystore \
|
||||
-alias androiddebugkey -storepass android -keypass android \
|
||||
@@ -60,11 +63,12 @@ jobs:
|
||||
-dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Build release APK
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew assembleRelease
|
||||
run: |
|
||||
cd carry-your-live/android
|
||||
./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: carry-your-live-release
|
||||
path: carry-your-live/android/app/build/outputs/apk/release/*.apk
|
||||
path: carry-your-live/android/app/build/outputs/release/app-release.apk
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Architekt Agent
|
||||
|
||||
## Role
|
||||
System architecture design, technical strategy, and scalability planning.
|
||||
|
||||
## Responsibilities
|
||||
- High-level system design
|
||||
- Technology stack selection
|
||||
- Performance optimization
|
||||
- Architecture documentation
|
||||
- Trade-off analysis
|
||||
|
||||
## Key Metrics
|
||||
- System scalability
|
||||
- Performance benchmarks
|
||||
- Technical debt ratio
|
||||
- Architecture compliance
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"agents": {
|
||||
"manager": ".opencode/manager.md",
|
||||
"architekt": ".opencode/architekt.md",
|
||||
"software_engineer": ".opencode/software_engineer.md",
|
||||
"hardware_engineer": ".opencode/hardware_engineer.md",
|
||||
"tester": ".opencode/tester.md",
|
||||
"docs": ".opencode/docs.md",
|
||||
"deployment": ".opencode/deployment.md",
|
||||
"vla": ".opencode/vla.md"
|
||||
},
|
||||
"subagent_depth": 999999
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Deployment Agent
|
||||
|
||||
## Role
|
||||
CI/CD, infrastructure, and release management.
|
||||
|
||||
## Responsibilities
|
||||
- CI/CD pipeline management
|
||||
- Infrastructure as code
|
||||
- Release orchestration
|
||||
- Environment management
|
||||
- Monitoring and alerting
|
||||
|
||||
## Key Metrics
|
||||
- Deployment frequency
|
||||
- Lead time for changes
|
||||
- Mean time to recovery
|
||||
- Change success rate
|
||||
@@ -0,0 +1,17 @@
|
||||
# Docs Agent
|
||||
|
||||
## Role
|
||||
Technical documentation, API docs, and knowledge management.
|
||||
|
||||
## Responsibilities
|
||||
- API documentation
|
||||
- User guides
|
||||
- Architecture docs
|
||||
- Change logs
|
||||
- Knowledge base maintenance
|
||||
|
||||
## Key Metrics
|
||||
- Documentation completeness
|
||||
- Update frequency
|
||||
- User satisfaction
|
||||
- Searchability
|
||||
@@ -0,0 +1,17 @@
|
||||
# Hardware Engineer Agent
|
||||
|
||||
## Role
|
||||
Hardware integration, device management, and embedded systems.
|
||||
|
||||
## Responsibilities
|
||||
- Hardware selection and integration
|
||||
- Device driver development
|
||||
- Embedded firmware
|
||||
- Prototyping and testing
|
||||
- Hardware-software interface
|
||||
|
||||
## Key Metrics
|
||||
- Hardware compatibility rate
|
||||
- Power consumption
|
||||
- Latency measurements
|
||||
- Component reliability
|
||||
@@ -0,0 +1,17 @@
|
||||
# Manager Agent
|
||||
|
||||
## Role
|
||||
Project management, task coordination, prioritization, and team oversight.
|
||||
|
||||
## Responsibilities
|
||||
- Task decomposition and assignment
|
||||
- Progress tracking and status reporting
|
||||
- Risk identification and mitigation
|
||||
- Resource allocation
|
||||
- Stakeholder communication
|
||||
|
||||
## Key Metrics
|
||||
- Sprint velocity
|
||||
- Task completion rate
|
||||
- Blockers resolved
|
||||
- Timeline adherence
|
||||
@@ -0,0 +1,98 @@
|
||||
# Carry Your Live - Project Summary
|
||||
|
||||
## Overview
|
||||
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
|
||||
|
||||
## Core Features
|
||||
- **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
|
||||
- **Auto-Sync**: Background synchronization with custom Postgres API when online
|
||||
- **Cross-Platform**: iOS, Android, and Web from single codebase
|
||||
|
||||
## Tech Stack
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Framework | Expo (React Native) |
|
||||
| Language | TypeScript |
|
||||
| Navigation | Expo Router (file-based) |
|
||||
| Database | WatermelonDB + SQLite |
|
||||
| State | WatermelonDB (reactive queries, no Redux needed) |
|
||||
| Forms | React Hook Form + Zod validation |
|
||||
| UI Components | React Native Paper + Custom SVG icons |
|
||||
| Animations | React Native Reanimated |
|
||||
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
||||
| Build | Gradle (local APK) |
|
||||
| CI/CD | Gitea Actions |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
carry-your-live/
|
||||
├ app/
|
||||
│ ├── _layout.tsx # Root layout + DatabaseProvider
|
||||
│ ├── (tabs)/
|
||||
│ │ ├── _layout.tsx # Bottom tab navigator
|
||||
│ │ ├── index.tsx # Tasks screen
|
||||
│ │ ├── calendar.tsx # Calendar screen
|
||||
│ │ └── settings.tsx # Settings screen
|
||||
│ └── add-task.tsx # Add Task screen
|
||||
├ src/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── database/
|
||||
│ │ ├── schema.ts # WatermelonDB schema
|
||||
│ │ ├── index.ts # Database instance + collections
|
||||
│ │ └── sync.ts # Custom API sync adapter
|
||||
│ ├── models/
|
||||
│ │ ├── Task.ts
|
||||
│ │ ├── Category.ts
|
||||
│ │ └── Subtask.ts
|
||||
│ ├── hooks/
|
||||
│ │ ├── useDatabase.tsx # Database context & providers
|
||||
│ │ └── useTasks.tsx # Task queries with reactive state
|
||||
│ ├── constants/
|
||||
│ │ └── index.ts # Categories, priorities, colors
|
||||
│ └── types/
|
||||
│ └── index.ts # TypeScript interfaces
|
||||
```
|
||||
|
||||
## Data Models
|
||||
- **Task**: id, title, description, categoryId, priority, completed, dueDate, dueTime, createdAt, updatedAt, subtasks
|
||||
- **Category**: id, name, color, order
|
||||
- **Subtask**: id, taskId, title, completed, order, createdAt, updatedAt
|
||||
|
||||
## Default Categories
|
||||
| Name | Color | Order |
|
||||
|------|-------|-------|
|
||||
| Work | #E53935 (Red) | 0 |
|
||||
| Personal | #43A047 (Green) | 1 |
|
||||
| School | #1E88E5 (Blue) | 2 |
|
||||
| Shopping | #FB8C00 (Orange) | 3 |
|
||||
| Health | #8E24AA (Purple) | 4 |
|
||||
| Finance | #FDD835 (Yellow) | 5 |
|
||||
|
||||
## Priority Colors
|
||||
| Priority | Color |
|
||||
|----------|-------|
|
||||
| None | #9E9E9E |
|
||||
| Low | #43A047 |
|
||||
| Medium | #FB8C00 |
|
||||
| High | #E53935 |
|
||||
| Critical | #B71C1C |
|
||||
|
||||
## Sync API
|
||||
Custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
|
||||
- **Pull**: GET /sync?since={timestamp} - returns categories, tasks, subtasks, timestamp
|
||||
- **Push**: POST /sync/push - body with changes { categories, tasks, subtasks } + lastPulledAt
|
||||
|
||||
## Getting Started
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
# Platform-specific:
|
||||
npx expo start --ios
|
||||
npx expo start --android
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
## Build Commands
|
||||
- `cd android && ./gradlew assembleDebug` - Debug APK
|
||||
- `cd android && ./gradlew assembleRelease` - Release APK
|
||||
- `npm run desktop` - Electron desktop app
|
||||
@@ -0,0 +1,17 @@
|
||||
# Software Engineer Agent
|
||||
|
||||
## Role
|
||||
Code implementation, feature development, and software quality.
|
||||
|
||||
## Responsibilities
|
||||
- Feature implementation
|
||||
- Code reviews
|
||||
- Bug fixing
|
||||
- Refactoring
|
||||
- Unit testing
|
||||
|
||||
## Key Metrics
|
||||
- Code quality score
|
||||
- Test coverage
|
||||
- Pull request throughput
|
||||
- Bug resolution time
|
||||
@@ -0,0 +1,17 @@
|
||||
# Tester Agent
|
||||
|
||||
## Role
|
||||
Quality assurance, test automation, and test strategy.
|
||||
|
||||
## Responsibilities
|
||||
- Test case design
|
||||
- Automated test execution
|
||||
- Regression testing
|
||||
- Performance testing
|
||||
- Test coverage analysis
|
||||
|
||||
## Key Metrics
|
||||
- Test pass rate
|
||||
- Defect detection rate
|
||||
- Test execution time
|
||||
- Bug leakage
|
||||
@@ -0,0 +1,17 @@
|
||||
# VLA Agent
|
||||
|
||||
## Role
|
||||
Vision-Language-Action models and multimodal understanding.
|
||||
|
||||
## Responsibilities
|
||||
- VLA model development
|
||||
- Multimodal perception
|
||||
- Action prediction
|
||||
- Policy learning
|
||||
- Sim-to-real transfer
|
||||
|
||||
## Key Metrics
|
||||
- Task success rate
|
||||
- Sample efficiency
|
||||
- Generalization ability
|
||||
- Simulation-to-real transfer rate
|
||||
@@ -8,17 +8,21 @@ import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
|
||||
import { useICalImport } from '@/hooks/useICalImport';
|
||||
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { SubtaskItem } from '@/components/SubtaskItem';
|
||||
import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
import type { TaskData, SubtaskData } from '@/types';
|
||||
|
||||
const WIDTH = Dimensions.get('window').width;
|
||||
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
@@ -26,9 +30,16 @@ const MONTH_NAMES = [
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { theme, sortBy } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const categories = useCategories();
|
||||
const categoryColors = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of categories) {
|
||||
map.set(c.id, c.color);
|
||||
}
|
||||
return map;
|
||||
}, [categories]);
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||
@@ -55,6 +66,25 @@ export default function CalendarScreen() {
|
||||
const monthTasks = useTasksInMonth(visibleMonth);
|
||||
const refreshMonthTasks = monthTasks.refresh;
|
||||
|
||||
const sortedDayTasks = useMemo(() => {
|
||||
const sorted = [...selectedDayTasks];
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
|
||||
break;
|
||||
case 'alpha':
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case 'created':
|
||||
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [selectedDayTasks, sortBy]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshDayTasks();
|
||||
@@ -165,9 +195,45 @@ export default function CalendarScreen() {
|
||||
});
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((taskId: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
} else {
|
||||
next.add(taskId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
<Header
|
||||
title="Calendar"
|
||||
showLogo={false}
|
||||
rightAction={
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
accessible={true}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
@@ -244,78 +310,41 @@ export default function CalendarScreen() {
|
||||
|
||||
<View style={styles.panelHeader}>
|
||||
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||
{selectedDayTasks.length > 0 && (
|
||||
{sortedDayTasks.length > 0 && (
|
||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
|
||||
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{selectedDayTasks.length === 0 ? (
|
||||
{sortedDayTasks.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||
</View>
|
||||
) : (
|
||||
selectedDayTasks.map((task) => (
|
||||
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
onPress={() => handleToggleComplete(task.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.eventTitleTouch}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
sortedDayTasks.map((task) => {
|
||||
const isExpanded = expandedTasks.has(task.id);
|
||||
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
|
||||
return (
|
||||
<View key={task.id} style={styles.eventCard}>
|
||||
<CalendarTaskRow
|
||||
task={task}
|
||||
expanded={isExpanded}
|
||||
subtasks={itemSubtasks}
|
||||
categoryColor={categoryColors.get(task.categoryId)}
|
||||
onToggle={() => handleToggleComplete(task.id)}
|
||||
onPress={(id) =>
|
||||
expandedTasks.has(id)
|
||||
? router.push({ pathname: '/task-detail', params: { id } })
|
||||
: toggleExpand(id)
|
||||
}
|
||||
onMenuOpen={(id) => openTaskEdit(id)}
|
||||
onSubtaskToggle={handleSubtaskToggle}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
||||
<View style={styles.bullets}>
|
||||
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
||||
<View key={sub.id} style={styles.bulletRow}>
|
||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
||||
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||
</Svg>
|
||||
<Text
|
||||
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -351,6 +380,60 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
|
||||
return byDay[day.getDate()] ?? [];
|
||||
}
|
||||
|
||||
interface CalendarTaskRowProps {
|
||||
task: Task;
|
||||
expanded: boolean;
|
||||
subtasks: SubtaskData[];
|
||||
categoryColor?: string;
|
||||
onToggle: (taskId: string) => void | Promise<void>;
|
||||
onPress: (taskId: string) => void;
|
||||
onMenuOpen: (taskId: string) => void;
|
||||
onSubtaskToggle: (subtaskId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const CalendarTaskRow = React.memo(function CalendarTaskRow({
|
||||
task,
|
||||
expanded,
|
||||
subtasks,
|
||||
categoryColor,
|
||||
onToggle,
|
||||
onPress,
|
||||
onMenuOpen,
|
||||
onSubtaskToggle,
|
||||
}: CalendarTaskRowProps) {
|
||||
const { theme } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const sortedSubtasks = useMemo(
|
||||
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||
[subtasks]
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<TaskItem
|
||||
task={task as unknown as TaskData}
|
||||
onToggle={() => onToggle(task.id)}
|
||||
onPress={() => onPress(task.id)}
|
||||
onMenuOpen={() => onMenuOpen(task.id)}
|
||||
expanded={expanded}
|
||||
categoryColor={categoryColor}
|
||||
/>
|
||||
{expanded && sortedSubtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
{sortedSubtasks.map((sub) => (
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
depth={1}
|
||||
categoryColor={categoryColor}
|
||||
onToggle={() => onSubtaskToggle(sub.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function cellTitle(tasks: Task[]): string | null {
|
||||
if (tasks.length === 0) return null;
|
||||
if (tasks.length === 1) return tasks[0].title;
|
||||
@@ -359,8 +442,7 @@ function cellTitle(tasks: Task[]): string | null {
|
||||
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
||||
if (tasks.length === 0) return theme.textMuted;
|
||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
|
||||
return theme.accent;
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
@@ -542,58 +624,16 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
emptySubtext: {
|
||||
fontSize: 13,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
eventCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
eventRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
eventTitleTouch: {
|
||||
flex: 1,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
bullets: {
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.06)',
|
||||
gap: 6,
|
||||
},
|
||||
bulletRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
bulletDot: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
bulletText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
},
|
||||
bulletCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: theme.textMuted,
|
||||
subtaskList: {
|
||||
paddingLeft: 8,
|
||||
paddingRight: 4,
|
||||
paddingTop: 4,
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { useCategories } from '@/hooks/useDatabase';
|
||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||
import { getLastSyncTime } from '@/database/sync';
|
||||
import { rescheduleAllReminders } from '@/services/notifications';
|
||||
import { collections } from '@/database';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import ColorWheel from '@/components/ColorWheel';
|
||||
@@ -22,9 +24,10 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, accentOpacity, setAccentOpacity, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||
const [syncVisible, setSyncVisible] = useState(false);
|
||||
const [friendsVisible, setFriendsVisible] = useState(false);
|
||||
@@ -32,6 +35,7 @@ export default function SettingsScreen() {
|
||||
const [serverUrlVisible, setServerUrlVisible] = useState(false);
|
||||
const [customAccentVisible, setCustomAccentVisible] = useState(false);
|
||||
const [draftAccent, setDraftAccent] = useState(accentColor);
|
||||
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
|
||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||
|
||||
@@ -95,6 +99,14 @@ export default function SettingsScreen() {
|
||||
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
||||
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||
|
||||
const tagReminderLabel = (categoryId: string) => {
|
||||
const selected = tagReminderPreferences[categoryId] ?? [];
|
||||
if (selected.length === 0) return `Default (${reminderLabel})`;
|
||||
return selected
|
||||
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const openCustomAccent = () => {
|
||||
setDraftAccent(accentColor);
|
||||
setCustomAccentVisible(true);
|
||||
@@ -108,6 +120,20 @@ export default function SettingsScreen() {
|
||||
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||
};
|
||||
|
||||
const handleTagReminderPreference = async (categoryId: string, value: string | string[]) => {
|
||||
const selected = (Array.isArray(value) ? value : [value]).filter(
|
||||
(v): v is typeof reminderPreference => v !== 'none'
|
||||
);
|
||||
setTagReminderPreferences({ ...tagReminderPreferences, [categoryId]: selected });
|
||||
if (!notifications) return;
|
||||
try {
|
||||
const tasks = await collections.tasks.query().fetch();
|
||||
await rescheduleAllReminders(tasks, notifications, reminderPreference);
|
||||
} catch {
|
||||
// ignore reschedule failures
|
||||
}
|
||||
};
|
||||
|
||||
const handleTodoAheadDays = (value: string | string[]) => {
|
||||
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
||||
};
|
||||
@@ -163,6 +189,18 @@ export default function SettingsScreen() {
|
||||
onPress={() => notifications && setPicker('reminder')}
|
||||
showChevron
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
<ListItem
|
||||
key={`tag-reminder-${category.id}`}
|
||||
title={category.name}
|
||||
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
|
||||
leftElement={
|
||||
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||
}
|
||||
onPress={() => notifications && setReminderTagPicker(category)}
|
||||
showChevron
|
||||
/>
|
||||
))}
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
||||
<ListItem
|
||||
title="Default Tag"
|
||||
@@ -188,6 +226,19 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Delete by Swiping"
|
||||
subtitle="Swipe right to delete task"
|
||||
rightElement={
|
||||
<Switch
|
||||
value={deleteBySwipe}
|
||||
onValueChange={setDeleteBySwipe}
|
||||
thumbColor="#FFFFFF"
|
||||
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||
/>
|
||||
}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Calendar Tasks"
|
||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||
@@ -294,6 +345,19 @@ export default function SettingsScreen() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
{categories.map((category) => (
|
||||
<OptionPickerModal
|
||||
key={`tag-reminder-picker-${category.id}`}
|
||||
visible={reminderTagPicker?.id === category.id}
|
||||
title={`Reminders · ${category.name}`}
|
||||
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
|
||||
selectedValue={tagReminderPreferences[category.id] ?? []}
|
||||
multiSelect
|
||||
onSelect={(value) => handleTagReminderPreference(category.id, value)}
|
||||
onClose={() => setReminderTagPicker(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker === 'todoAhead'}
|
||||
title="Calendar Tasks in Todo"
|
||||
@@ -330,18 +394,18 @@ export default function SettingsScreen() {
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
|
||||
<ColorWheel color={draftAccent} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
|
||||
<View style={styles.accentModalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
|
||||
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
|
||||
onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
|
||||
|
||||
@@ -194,6 +194,7 @@ export default function AddTaskScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
automaticallyAdjustContentInsets={false}
|
||||
>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
|
||||
@@ -40,11 +40,14 @@ export default function DayViewScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
router.back();
|
||||
return true;
|
||||
});
|
||||
refreshSubtasks();
|
||||
refresh();
|
||||
return () => sub.remove();
|
||||
}, [refreshSubtasks, refresh])
|
||||
}, [router, refreshSubtasks, refresh])
|
||||
);
|
||||
|
||||
const subtasksOnDay = useMemo(() => {
|
||||
@@ -70,7 +73,7 @@ export default function DayViewScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{loading ? (
|
||||
|
||||
@@ -187,6 +187,7 @@ export default function SubtaskDetailScreen() {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
@@ -49,9 +49,9 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
||||
let hour: number;
|
||||
if (is24Hour) {
|
||||
const inner = distance < INNER_NUM_RADIUS + 14;
|
||||
hour = inner ? base + 12 : (base === 0 ? 12 : base);
|
||||
hour = inner ? base + 12 : base === 0 ? 12 : base;
|
||||
} else {
|
||||
hour = isPM ? base + 12 : (base === 0 ? 0 : base);
|
||||
hour = isPM ? base + 12 : base === 0 ? 0 : base;
|
||||
}
|
||||
emit(hour, minutes);
|
||||
setMode('minute');
|
||||
@@ -135,7 +135,8 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
||||
);
|
||||
});
|
||||
|
||||
const minuteDots = mode === 'minute'
|
||||
const minuteDots =
|
||||
mode === 'minute'
|
||||
? Array.from({ length: 12 }, (_, i) => {
|
||||
const angle = (i * 30 * Math.PI) / 180;
|
||||
const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
|
||||
@@ -181,7 +182,7 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
||||
</TouchableOpacity>
|
||||
{!is24Hour && (
|
||||
<View style={styles.periodGroup}>
|
||||
{(['AM', 'PM'] as const).map((period) => {
|
||||
{(['AM', 'PM'] as const).map(period => {
|
||||
const active = isPM === (period === 'PM');
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -215,15 +216,15 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
||||
style={styles.clockSurface}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={(e) => {
|
||||
onResponderGrant={e => {
|
||||
movedRef.current = false;
|
||||
onLocation(e, false);
|
||||
}}
|
||||
onResponderMove={(e) => {
|
||||
onResponderMove={e => {
|
||||
movedRef.current = true;
|
||||
onLocation(e, true);
|
||||
}}
|
||||
onResponderRelease={(e) => {
|
||||
onResponderRelease={e => {
|
||||
if (!movedRef.current) {
|
||||
onLocation(e, false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
|
||||
@@ -82,19 +92,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
||||
<View style={styles.selectorContent}>
|
||||
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.selectorValue,
|
||||
{ color: value ? theme.text : theme.textMuted },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.selectorValue, { color: value ? theme.text : theme.textMuted }]}>
|
||||
{selectedFriend ? selectedFriend.username : 'Unassigned'}
|
||||
</Text>
|
||||
</View>
|
||||
{value && !disabled && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={(e) => {
|
||||
onPress={e => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
@@ -105,7 +110,12 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||
behavior="padding"
|
||||
@@ -113,7 +123,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowModal(false)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -122,7 +135,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionRow,
|
||||
{ backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
|
||||
{
|
||||
backgroundColor: selectedFriend ? theme.accentSoft : 'transparent',
|
||||
borderColor: selectedFriend ? theme.accent : 'transparent',
|
||||
},
|
||||
]}
|
||||
onPress={() => handleClear()}
|
||||
>
|
||||
@@ -143,7 +159,9 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<ActivityIndicator color={theme.accent} size="large" />
|
||||
</View>
|
||||
) : friends.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No friends yet. Add friends to assign tasks.</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||
No friends yet. Add friends to assign tasks.
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={friends}
|
||||
@@ -152,14 +170,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionRow,
|
||||
{ backgroundColor: value === item.id ? theme.accentSoft : 'transparent', borderColor: value === item.id ? theme.accent : 'transparent' },
|
||||
{
|
||||
backgroundColor: value === item.id ? theme.accentSoft : 'transparent',
|
||||
borderColor: value === item.id ? theme.accent : 'transparent',
|
||||
},
|
||||
]}
|
||||
onPress={() => handleSelectFriend(item.id)}
|
||||
>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>👤</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
{value === item.id && (
|
||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||
)}
|
||||
@@ -172,7 +195,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<View style={styles.modalSection}>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.searchInput,
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Search users by username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={searchQuery}
|
||||
@@ -197,15 +227,21 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>➕</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
||||
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>
|
||||
Add as friend & assign
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
|
||||
) : searchQuery.length < 2 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Category from '@/models/Category';
|
||||
import { useSettings } from '@/theme';
|
||||
import { CATEGORY_COLORS } from '@/constants';
|
||||
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
|
||||
import { confirmAction } from '@/utils/confirm';
|
||||
import ColorPickerInput from '@/components/ColorPickerInput';
|
||||
import ColorWheel from '@/components/ColorWheel';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryEditorModalProps {
|
||||
@@ -15,7 +27,12 @@ interface CategoryEditorModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CategoryEditorModal({ visible, category, categoryCount, onClose }: CategoryEditorModalProps) {
|
||||
export function CategoryEditorModal({
|
||||
visible,
|
||||
category,
|
||||
categoryCount,
|
||||
onClose,
|
||||
}: CategoryEditorModalProps) {
|
||||
const { theme } = useSettings();
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||
@@ -64,15 +81,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.overlay}
|
||||
onPress={onClose}
|
||||
accessibilityRole="none"
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={onClose} accessibilityRole="none">
|
||||
<Pressable style={[styles.sheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -82,10 +92,21 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Tag' : 'New Tag'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close tag editor"
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -94,7 +115,11 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Tag name"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
@@ -107,7 +132,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
|
||||
<View style={styles.colorGrid}>
|
||||
{CATEGORY_COLORS.map((c) => (
|
||||
{CATEGORY_COLORS.map(c => (
|
||||
<TouchableOpacity
|
||||
key={c}
|
||||
style={[
|
||||
@@ -123,13 +148,20 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
>
|
||||
{color === c && (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<ColorPickerInput value={color} onChange={setColor} />
|
||||
<ColorWheel color={color} onChange={setColor} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
{category && (
|
||||
@@ -139,13 +171,22 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
disabled={!canDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.deleteButtonText,
|
||||
{ color: canDelete ? theme.accent : theme.textMuted },
|
||||
]}
|
||||
>
|
||||
Delete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ backgroundColor: theme.accent },
|
||||
!name.trim() && styles.saveButtonDisabled,
|
||||
]}
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim()}
|
||||
activeOpacity={0.8}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
isAllCategory={true}
|
||||
scaleAnim={scaleAnim}
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<CategoryButton
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
@@ -72,7 +72,10 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
style={[
|
||||
styles.manageButton,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
|
||||
]}
|
||||
onPress={() => router.push('/settings')}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
@@ -80,10 +83,22 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={9}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
fill="none"
|
||||
/>
|
||||
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
|
||||
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
|
||||
<Path d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -104,7 +119,16 @@ interface CategoryButtonProps {
|
||||
scaleAnim: Animated.Value;
|
||||
}
|
||||
|
||||
function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
|
||||
function CategoryButton({
|
||||
color,
|
||||
selected,
|
||||
onPress,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
theme,
|
||||
isAllCategory,
|
||||
scaleAnim,
|
||||
}: CategoryButtonProps) {
|
||||
const animatedStyle = {
|
||||
transform: [{ scale: scaleAnim }],
|
||||
shadowColor: selected ? theme.accent : '#000',
|
||||
@@ -130,8 +154,24 @@ function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme
|
||||
>
|
||||
{isAllCategory ? (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
|
||||
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={9}
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<Path
|
||||
d="M6 12l4 4 8-8"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity={0.9}
|
||||
/>
|
||||
</Svg>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Modal,
|
||||
Pressable,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
@@ -16,7 +25,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const selected = new Set(value);
|
||||
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
||||
const selectedCategories = categories.filter(c => selected.has(c.id));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
@@ -33,7 +42,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
? 'None'
|
||||
: selectedCategories
|
||||
.slice(0, 2)
|
||||
.map((c) => c.name)
|
||||
.map(c => c.name)
|
||||
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||
|
||||
return (
|
||||
@@ -53,10 +62,18 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<View style={styles.selectorContent}>
|
||||
{selectedCategories.length > 0 ? (
|
||||
<View style={styles.chipRow}>
|
||||
{selectedCategories.slice(0, 3).map((c) => (
|
||||
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
||||
{selectedCategories.slice(0, 3).map(c => (
|
||||
<View
|
||||
key={c.id}
|
||||
style={[
|
||||
styles.chip,
|
||||
{ backgroundColor: theme.cardAlt, borderColor: theme.border },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
||||
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
||||
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>
|
||||
{c.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -65,12 +82,24 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
)}
|
||||
</View>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M9 18l6-6-6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
|
||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||
behavior="padding"
|
||||
@@ -81,7 +110,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
||||
<View style={styles.modalHeaderRight}>
|
||||
<TouchableOpacity
|
||||
onPress={() => { onChange([]); setShowModal(false); }}
|
||||
onPress={() => {
|
||||
onChange([]);
|
||||
setShowModal(false);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear all tags"
|
||||
@@ -102,7 +134,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
||||
A task can have multiple tags. Selected: {selected.size}
|
||||
</Text>
|
||||
{categories.map((category) => {
|
||||
{categories.map(category => {
|
||||
const isSelected = selected.has(category.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -110,7 +142,11 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
isSelected && {
|
||||
borderColor: theme.accent,
|
||||
backgroundColor: theme.accentSoft,
|
||||
borderWidth: 2,
|
||||
},
|
||||
]}
|
||||
onPress={() => toggle(category.id)}
|
||||
activeOpacity={0.8}
|
||||
@@ -118,17 +154,32 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
accessibilityLabel={`Tag ${category.name}`}
|
||||
accessibilityState={{ checked: isSelected }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
<View
|
||||
style={[
|
||||
styles.colorCircle,
|
||||
{ backgroundColor: category.color },
|
||||
isSelected && styles.colorCircleSelected,
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
isSelected && { color: theme.text, fontWeight: '600' },
|
||||
]}>
|
||||
]}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -20,15 +20,23 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
|
||||
const valid = isValidHex(hex);
|
||||
|
||||
const handleChange = (raw: string) => {
|
||||
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
|
||||
const cleaned = raw
|
||||
.replace(/[^0-9a-fA-F]/g, '')
|
||||
.slice(0, 6)
|
||||
.toUpperCase();
|
||||
onChange(`#${cleaned}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
|
||||
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
|
||||
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.preview,
|
||||
{ backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.inputWrap}>
|
||||
<Text style={styles.hash}>#{/**/}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
value={hex}
|
||||
@@ -40,7 +48,7 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
|
||||
<Text style={styles.hint}>{/**/}Hex code</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
|
||||
width: 44,
|
||||
height: 44,
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.borderStrong}`,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
|
||||
<Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,23 +33,48 @@ function hsvToHex(h: number, s: number, v: number) {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
} else if (h < 180) {
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
const toHex = (n: number) =>
|
||||
Math.round((n + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||
}
|
||||
|
||||
interface ColorWheelProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
opacity: number;
|
||||
onChange: (color: string, opacity: number) => void;
|
||||
}
|
||||
|
||||
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
export default function ColorWheel({ color, opacity, onChange }: ColorWheelProps) {
|
||||
const { theme } = useSettings();
|
||||
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||
const [hue, setHue] = useState(initialHsv.h);
|
||||
@@ -66,8 +91,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const newColor = hsvToHex(hue, saturation, value);
|
||||
onChange(newColor);
|
||||
}, [hue, saturation, value, onChange]);
|
||||
onChange(newColor, opacity);
|
||||
}, [hue, saturation, value, opacity, onChange]);
|
||||
|
||||
const updateFromWheel = (locationX: number, locationY: number) => {
|
||||
const center = WHEEL_SIZE / 2;
|
||||
@@ -84,35 +109,49 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
setValue(1 - distance / radius);
|
||||
};
|
||||
|
||||
const wheelPanResponder = React.useMemo(() => PanResponder.create({
|
||||
const wheelPanResponder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (event) => {
|
||||
onPanResponderGrant: event => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
onPanResponderMove: event => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
}), []);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const setHueFromLocation = (locationX: number) => {
|
||||
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
||||
};
|
||||
|
||||
const huePanResponder = React.useMemo(() => PanResponder.create({
|
||||
const huePanResponder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (event) => {
|
||||
onPanResponderGrant: event => {
|
||||
setHueFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
onPanResponderMove: event => {
|
||||
setHueFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
}), []);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||
const thumbX =
|
||||
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
|
||||
WHEEL_SIZE / 2 -
|
||||
THUMB_SIZE / 2;
|
||||
const thumbY =
|
||||
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin(((hue - 180) * Math.PI) / 180) +
|
||||
WHEEL_SIZE / 2 -
|
||||
THUMB_SIZE / 2;
|
||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - THUMB_SIZE / 2;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -180,7 +219,9 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
|
||||
<View style={styles.previewContainer}>
|
||||
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
||||
<RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
|
||||
<RNText style={[styles.previewText, { color: theme.text }]}>
|
||||
{hsvToHex(hue, saturation, value)}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -32,23 +32,48 @@ function hsvToHex(h: number, s: number, v: number) {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
} else if (h < 180) {
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
const toHex = (n: number) =>
|
||||
Math.round((n + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||
}
|
||||
|
||||
interface ColorWheelProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
opacity: number;
|
||||
onChange: (color: string, opacity: number) => void;
|
||||
}
|
||||
|
||||
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
export default function ColorWheel({ color, opacity, onChange }: ColorWheelProps) {
|
||||
const { theme } = useSettings();
|
||||
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||
const [hue, setHue] = useState(initialHsv.h);
|
||||
@@ -65,8 +90,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const newColor = hsvToHex(hue, saturation, value);
|
||||
onChange(newColor);
|
||||
}, [hue, saturation, value, onChange]);
|
||||
onChange(newColor, opacity);
|
||||
}, [hue, saturation, value, opacity, onChange]);
|
||||
|
||||
const handleWheelMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -108,15 +133,25 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
handleMove(e.nativeEvent);
|
||||
};
|
||||
|
||||
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||
const thumbX =
|
||||
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
|
||||
WHEEL_SIZE / 2 -
|
||||
THUMB_SIZE / 2;
|
||||
const thumbY =
|
||||
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin(((hue - 180) * Math.PI) / 180) +
|
||||
WHEEL_SIZE / 2 -
|
||||
THUMB_SIZE / 2;
|
||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - THUMB_SIZE / 2;
|
||||
const currentColor = hsvToHex(hue, saturation, value);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.wheelContainer}>
|
||||
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
||||
<Svg
|
||||
width={WHEEL_SIZE}
|
||||
height={WHEEL_SIZE}
|
||||
{...({ onMouseDown: handleWheelMouseDown } as object)}
|
||||
>
|
||||
<DefsElement>
|
||||
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||
|
||||
@@ -109,7 +109,11 @@ export default function WebDateTimeInput({
|
||||
>
|
||||
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
|
||||
<TouchableOpacity
|
||||
style={[styles.doneButton, { backgroundColor: theme.accent }]}
|
||||
onPress={onDismiss}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -49,14 +49,30 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' &&
|
||||
endTimeInvalid && {
|
||||
borderColor: theme.dangerStrong,
|
||||
backgroundColor: theme.accentSoft,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M12 6v6l4 2"
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text
|
||||
style={[
|
||||
@@ -82,7 +98,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
@@ -142,7 +163,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
@@ -151,7 +177,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
|
||||
style={[
|
||||
styles.calendarButton,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||
]}
|
||||
onPress={() => setPicker('date')}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Open calendar"
|
||||
@@ -184,7 +213,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
thumbColor={field.value ? theme.accent : theme.textMuted}
|
||||
ios_backgroundColor={theme.border}
|
||||
value={field.value ?? false}
|
||||
onValueChange={(value) => {
|
||||
onValueChange={value => {
|
||||
field.onChange(value);
|
||||
if (value) {
|
||||
timeRef.current?.('');
|
||||
@@ -203,10 +232,27 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
</View>
|
||||
|
||||
{endTimeInvalid && (
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.warningRow,
|
||||
{ backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong },
|
||||
]}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.dangerStrong}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M12 8v5M12 16.5v.5"
|
||||
stroke={theme.dangerStrong}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
|
||||
{endTime === startTime
|
||||
@@ -235,12 +281,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
<WheelTimePicker
|
||||
visible={picker === 'time' || picker === 'endTime'}
|
||||
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
||||
initialTime={
|
||||
picker === 'endTime'
|
||||
? endTime || startTime
|
||||
: startTime
|
||||
}
|
||||
onConfirm={(time) => {
|
||||
initialTime={picker === 'endTime' ? endTime || startTime : startTime}
|
||||
onConfirm={time => {
|
||||
if (picker === 'endTime') {
|
||||
setEndTimeValue(time);
|
||||
} else {
|
||||
|
||||
@@ -36,7 +36,8 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
gap: 5,
|
||||
},
|
||||
|
||||
@@ -47,12 +47,7 @@ export function FloatingActionButton() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{ transform: [{ scale: scaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.container, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
||||
onPress={handlePress}
|
||||
|
||||
@@ -44,13 +44,17 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
const result = await forgotPassword(username.trim());
|
||||
if (result.success) {
|
||||
if (result.resetToken) {
|
||||
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
Alert.alert(
|
||||
'Reset Token (Dev Mode)',
|
||||
`Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`,
|
||||
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
Alert.alert(
|
||||
'Reset Email Sent',
|
||||
'If the account exists, a password reset link has been sent.',
|
||||
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to request password reset');
|
||||
@@ -112,7 +116,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
Enter your username to receive a password reset link.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="Username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={username}
|
||||
@@ -130,7 +137,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||
Send Reset Link
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -142,7 +151,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
Enter the reset token and your new password.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="Reset Token"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={resetToken}
|
||||
@@ -151,7 +163,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="New Password (min 8 chars)"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newPassword}
|
||||
@@ -159,7 +174,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
secureTextEntry
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="Confirm New Password"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={confirmPassword}
|
||||
@@ -176,7 +194,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||
Reset Password
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -184,10 +204,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
|
||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||
|
||||
@@ -13,7 +13,10 @@ interface FormButtonsProps {
|
||||
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const cancel = () => {
|
||||
@@ -25,9 +28,21 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.background,
|
||||
borderTopColor: theme.border,
|
||||
paddingBottom: insets.bottom + 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
|
||||
]}
|
||||
onPress={cancel}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.7}
|
||||
@@ -38,7 +53,11 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
|
||||
style={[
|
||||
styles.submitButton,
|
||||
{ backgroundColor: theme.accent },
|
||||
isSubmitting && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.8}
|
||||
|
||||
@@ -21,11 +21,23 @@ interface FriendsModalProps {
|
||||
|
||||
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
const { theme } = useSettings();
|
||||
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
||||
const {
|
||||
friends,
|
||||
incoming,
|
||||
outgoing,
|
||||
loading,
|
||||
sendRequest,
|
||||
acceptRequest,
|
||||
declineRequest,
|
||||
removeFriend,
|
||||
searchUsers,
|
||||
} = useFriends();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
|
||||
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>(
|
||||
'friends'
|
||||
);
|
||||
|
||||
const searchKey = `${selectedTab}:${searchQuery}`;
|
||||
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
|
||||
@@ -41,7 +53,11 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await searchUsers(searchQuery);
|
||||
setSearchResults(results.filter(r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)));
|
||||
setSearchResults(
|
||||
results.filter(
|
||||
r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
} finally {
|
||||
@@ -71,13 +87,17 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
const handleRemove = async (friendId: string) => {
|
||||
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Remove', style: 'destructive', onPress: async () => {
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await removeFriend(friendId);
|
||||
} catch (err) {
|
||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
|
||||
}
|
||||
}},
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -98,7 +118,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
</View>
|
||||
|
||||
<View style={styles.tabs}>
|
||||
{(['friends', 'incoming', 'outgoing', 'add'] as const).map((tab) => (
|
||||
{(['friends', 'incoming', 'outgoing', 'add'] as const).map(tab => (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
style={[
|
||||
@@ -116,10 +136,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
{tab === 'friends' && friends.length > 0 && (
|
||||
<Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
|
||||
<Text style={[styles.badge, { color: theme.accentText }]}>
|
||||
{friends.length}
|
||||
</Text>
|
||||
)}
|
||||
{tab === 'incoming' && incoming.length > 0 && (
|
||||
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
|
||||
<Text style={[styles.badge, { color: theme.accentText }]}>
|
||||
{incoming.length}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -135,7 +159,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
) : friends.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
|
||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
|
||||
{'Tap "Add" to find friends by username'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -143,12 +169,19 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
keyExtractor={f => f.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
||||
style={[
|
||||
styles.removeBtn,
|
||||
{ backgroundColor: theme.accentSoft, borderColor: theme.accent },
|
||||
]}
|
||||
onPress={() => handleRemove(item.id)}
|
||||
>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>
|
||||
Remove
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -165,7 +198,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
</View>
|
||||
) : incoming.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No incoming requests</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
|
||||
No incoming requests
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -173,19 +208,32 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
keyExtractor={r => r.requestId}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
<View style={styles.requestActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
||||
onPress={() => handleAccept(item.requestId)}
|
||||
>
|
||||
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
|
||||
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
||||
style={[
|
||||
styles.actionBtn,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: theme.borderStrong,
|
||||
borderWidth: 1,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleDecline(item.requestId)}
|
||||
>
|
||||
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>Decline</Text>
|
||||
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>
|
||||
Decline
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -203,7 +251,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
</View>
|
||||
) : outgoing.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No outgoing requests</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
|
||||
No outgoing requests
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -211,13 +261,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
keyExtractor={r => r.requestId}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.friendName, { color: theme.textMuted }]}>{item.username}</Text>
|
||||
<Text style={[styles.friendName, { color: theme.textMuted }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
||||
style={[
|
||||
styles.cancelBtn,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: theme.borderStrong,
|
||||
borderWidth: 1,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleDecline(item.requestId)}
|
||||
>
|
||||
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>Cancel</Text>
|
||||
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -230,7 +291,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
<View style={styles.content}>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.searchInput,
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Search by username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={searchQuery}
|
||||
@@ -253,16 +321,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
keyExtractor={u => u.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
||||
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||
{item.username}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
||||
onPress={() => sendRequest(item.username)
|
||||
onPress={() =>
|
||||
sendRequest(item.username)
|
||||
.then(() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSelectedTab('friends');
|
||||
})
|
||||
.catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
|
||||
.catch(err =>
|
||||
Alert.alert(
|
||||
'Error',
|
||||
err instanceof Error ? err.message : 'Failed to send request'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
|
||||
@@ -272,7 +348,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
/>
|
||||
) : searchQuery.length < 2 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
|
||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -20,7 +20,9 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
||||
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
||||
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.spacer}>{rightAction}</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -136,7 +136,12 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,89 +1,69 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Switch } from 'react-native';
|
||||
|
||||
interface ListItemProps {
|
||||
export const ListItem = ({ title, subtitle, onPress, leftIcon, rightIcon, disabled, leftElement, rightElement, showChevron }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitle?: string | undefined;
|
||||
onPress?: () => void;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
leftElement?: React.ReactNode;
|
||||
rightElement?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
showChevron?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({ title, subtitle, leftElement, rightElement, onPress, showChevron }: ListItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const isInteractive = onPress || showChevron;
|
||||
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||
isInteractive && styles.interactive,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
disabled={!isInteractive}
|
||||
accessibilityRole={isInteractive ? 'button' : 'none'}
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={subtitle ? subtitle : undefined}
|
||||
accessibilityState={{ disabled: !isInteractive }}
|
||||
>
|
||||
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
||||
<View style={styles.leftContent}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
{subtitle && <Text style={[styles.subtitle, { color: theme.textMuted }]}>{subtitle}</Text>}
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled} style={styles.item}>
|
||||
<View style={styles.left}>
|
||||
{leftElement}
|
||||
{leftIcon}
|
||||
</View>
|
||||
<View style={styles.rightContent}>
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
{rightElement}
|
||||
{showChevron && !rightElement && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9 18l6-6-6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
{rightIcon}
|
||||
</View>
|
||||
{showChevron && <Text style={styles.chevron}>▶</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
item: {
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 8,
|
||||
backgroundColor: 'white',
|
||||
marginVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
interactive: {},
|
||||
leftElement: {
|
||||
marginRight: 12,
|
||||
},
|
||||
leftContent: {
|
||||
left: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
center: {
|
||||
flex: 3,
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
right: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
color: '#757575',
|
||||
marginTop: 2,
|
||||
},
|
||||
rightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
chevron: {
|
||||
fontSize: 14,
|
||||
color: '#757575',
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
@@ -47,7 +47,7 @@ interface TaskItemProps {
|
||||
}
|
||||
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const { theme, deleteBySwipe } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||
@@ -176,7 +176,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
renderRightActions={renderRightActions}
|
||||
renderLeftActions={renderLeftActions}
|
||||
onSwipeableRightOpen={() => {
|
||||
if (deleteBySwipe) {
|
||||
onDelete?.();
|
||||
}
|
||||
swipeableRef.current?.close();
|
||||
}}
|
||||
onSwipeableLeftOpen={() => {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; parentSubtaskId: string | null; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const {
|
||||
modals,
|
||||
openTaskDelete,
|
||||
@@ -334,8 +334,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
}
|
||||
}, [findHoverTarget]);
|
||||
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
|
||||
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||
@@ -371,16 +371,15 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
if (target === state.subtaskId) return;
|
||||
|
||||
if (target) {
|
||||
if (state.subPositions[target]) {
|
||||
await setSubtaskParent(state.subtaskId, target);
|
||||
} else if (target !== state.parentTaskId) {
|
||||
// Only allow dropping on tasks (not subtasks) to match task behavior
|
||||
// Check if target exists in main task positions, not subtask positions
|
||||
const isTaskTarget = state.positions != null && target != null && target in state.positions;
|
||||
if (target && isTaskTarget) {
|
||||
// Move subtask to target task - makes it a subtask of that task
|
||||
// If no target or target is a subtask: do nothing (matches task behavior)
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
}
|
||||
} else {
|
||||
await convertSubtaskToTask(state.subtaskId);
|
||||
}
|
||||
}, [findHoverTarget]);
|
||||
}, [findHoverTarget, moveSubtaskToTask]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: Task; index: number }) => {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useDatabase } from './useDatabase';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Task from '../models/Task';
|
||||
import parseICal from '../utils/icalParser';
|
||||
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
||||
|
||||
function mapICalEventToTask(event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
rrule?: string;
|
||||
uid?: string;
|
||||
priority?: number;
|
||||
}): {
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: number;
|
||||
dueTime: string;
|
||||
endTime: string;
|
||||
allDay: boolean;
|
||||
repeat: string;
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
seriesId: string;
|
||||
reminder: string;
|
||||
reminders: string;
|
||||
categoryId: string;
|
||||
tags: string;
|
||||
priority: string;
|
||||
completed: boolean;
|
||||
completedAt: number | null;
|
||||
} {
|
||||
const defaultCategoryId = 'cat_default'; // will be resolved later
|
||||
const now = Date.now();
|
||||
|
||||
let dueDate = event.start.getTime();
|
||||
let dueTime = '00:00';
|
||||
let endTime = '00:00';
|
||||
let allDay = event.allDay === true || !event.end;
|
||||
|
||||
if (event.allDay) {
|
||||
dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
|
||||
dueTime = '00:00';
|
||||
endTime = '23:59';
|
||||
} else {
|
||||
dueDate = event.start.getTime();
|
||||
dueTime = `${event.start.getHours().toString().padStart(2, '0')}:${event.start.getMinutes().toString().padStart(2, '0')}`;
|
||||
if (event.end) {
|
||||
endTime = `${event.end.getHours().toString().padStart(2, '0')}:${event.end.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
let repeat = 'none';
|
||||
let repeatInterval = 1;
|
||||
let repeatDays = '';
|
||||
let seriesId = event.uid || `${dueDate}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
let reminder = 'none';
|
||||
let reminders = '';
|
||||
|
||||
if (event.rrule) {
|
||||
repeat = 'custom';
|
||||
// Simple RRULE parsing - extract FREQ and INTERVAL
|
||||
const freqMatch = event.rrule.match(/FREQ=([^;:]+)/i);
|
||||
if (freqMatch) {
|
||||
const freq = freqMatch[1].toLowerCase();
|
||||
if (freq === 'daily') {
|
||||
repeat = 'daily';
|
||||
repeatInterval = 1;
|
||||
} else if (freq === 'weekly') {
|
||||
repeat = 'weekly';
|
||||
repeatInterval = 1;
|
||||
} else if (freq === 'monthly') {
|
||||
repeat = 'monthly';
|
||||
repeatInterval = 1;
|
||||
}
|
||||
}
|
||||
// Extract INTERVAL
|
||||
const intervalMatch = event.rrule.match(/INTERVAL=(\d+)/i);
|
||||
if (intervalMatch) {
|
||||
repeatInterval = parseInt(intervalMatch[1], 10);
|
||||
}
|
||||
// Extract BYDAY for weekly/Monthly
|
||||
const bydayMatch = event.rrule.match(/BYDAY=([^;,]+)/i);
|
||||
if (bydayMatch) {
|
||||
repeatDays = bydayMatch[1].replace(/,/g, ',');
|
||||
}
|
||||
}
|
||||
|
||||
// Map priority (iCal priority: -2=low, 0=normal, 2=high, 3-4=highest, 5=urgent)
|
||||
let priorityValue: string = 'medium';
|
||||
if (event.priority !== undefined) {
|
||||
const p = event.priority;
|
||||
if (p <= -2) priorityValue = 'low';
|
||||
else if (p === 0) priorityValue = 'medium';
|
||||
else if (p === 2) priorityValue = 'high';
|
||||
else if (p >= 5) priorityValue = 'urgent';
|
||||
else priorityValue = 'medium';
|
||||
}
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
description: event.description || '',
|
||||
dueDate,
|
||||
dueTime,
|
||||
endTime,
|
||||
allDay,
|
||||
repeat,
|
||||
repeatInterval,
|
||||
repeatDays,
|
||||
seriesId,
|
||||
reminder,
|
||||
reminders,
|
||||
categoryId: defaultCategoryId,
|
||||
tags: '',
|
||||
priority: priorityValue,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useICalImport() {
|
||||
const { collections } = useDatabase();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const importICal = useCallback(async (icalContent: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const events = parseICal(icalContent);
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error('No events found in the iCal data');
|
||||
}
|
||||
|
||||
const taskData = events.map(mapICalEventToTask);
|
||||
|
||||
// Insert tasks into WatermelonDB
|
||||
const subscription = collections.tasks
|
||||
.query()
|
||||
.splice(
|
||||
0,
|
||||
0,
|
||||
...taskData.map((td) => ({
|
||||
title: td.title,
|
||||
description: td.description,
|
||||
category_id: td.categoryId,
|
||||
tags: td.tags,
|
||||
priority: td.priority,
|
||||
completed: td.completed,
|
||||
completed_at: td.completedAt,
|
||||
due_date: td.dueDate,
|
||||
due_time: td.dueTime,
|
||||
end_time: td.endTime,
|
||||
all_day: td.allDay,
|
||||
repeat: td.repeat,
|
||||
repeat_interval: td.repeatInterval,
|
||||
repeat_days: td.repeatDays,
|
||||
color: '#9E9E9E',
|
||||
series_id: td.seriesId,
|
||||
order: 0,
|
||||
reminder: td.reminder,
|
||||
reminders: td.reminders,
|
||||
assignee_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
);
|
||||
|
||||
// Observe the changes
|
||||
subscription.subscribe({
|
||||
next: (result) => {
|
||||
// Tasks have been imported
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Import error:', err);
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('ICal import failed:', err);
|
||||
setError(err.message || 'Import failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [collections]);
|
||||
|
||||
return { importICal, loading, error };
|
||||
}
|
||||
@@ -109,7 +109,9 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
|
||||
let details: any = null;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body && typeof body.message === 'string') {
|
||||
if (body && typeof body.error?.message === 'string') {
|
||||
message = body.error.message;
|
||||
} else if (body && typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
if (body && body.error && body.error.details) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Platform } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference } from '@/types';
|
||||
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
|
||||
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
|
||||
|
||||
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
|
||||
|
||||
async function loadTagPreferences(): Promise<TagReminderPreferences> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(TAG_REMINDER_PREFERENCES_KEY);
|
||||
return raw ? (JSON.parse(raw) as TagReminderPreferences) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function readIdMap(): Promise<Record<string, string[]>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(ID_MAP_KEY);
|
||||
@@ -51,7 +61,11 @@ function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
|
||||
}
|
||||
}
|
||||
|
||||
function getRemindersForTask(task: any, globalPreference: ReminderPreference): Reminder[] {
|
||||
function getRemindersForTask(
|
||||
task: any,
|
||||
globalPreference: ReminderPreference,
|
||||
tagPreferences: TagReminderPreferences = {}
|
||||
): Reminder[] {
|
||||
const taskReminders: Reminder[] = task.reminders
|
||||
? parseReminders(task.reminders)
|
||||
: task.reminder && task.reminder !== 'none'
|
||||
@@ -62,6 +76,17 @@ function getRemindersForTask(task: any, globalPreference: ReminderPreference): R
|
||||
return taskReminders;
|
||||
}
|
||||
|
||||
const tagReminders: Reminder[] = [];
|
||||
for (const tagId of parseTaskTags(task.tags, task.categoryId)) {
|
||||
for (const pref of tagPreferences[tagId] ?? []) {
|
||||
if (pref === 'none') continue;
|
||||
if (!tagReminders.includes(pref)) tagReminders.push(pref);
|
||||
}
|
||||
}
|
||||
if (tagReminders.length > 0) {
|
||||
return tagReminders;
|
||||
}
|
||||
|
||||
const globalReminder = reminderPreferenceToReminder(globalPreference);
|
||||
if (globalReminder !== 'none') {
|
||||
return [globalReminder];
|
||||
@@ -92,7 +117,8 @@ export async function scheduleTaskReminder(task: any, notificationsEnabled: bool
|
||||
await writeIdMap(idMap);
|
||||
|
||||
// Determine the set of reminders to schedule
|
||||
const reminders = getRemindersForTask(task, globalReminderPreference);
|
||||
const tagPreferences = await loadTagPreferences();
|
||||
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
||||
|
||||
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
|
||||
|
||||
@@ -195,11 +221,12 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
|
||||
// ignore
|
||||
}
|
||||
const idMap: Record<string, string[]> = {};
|
||||
const tagPreferences = await loadTagPreferences();
|
||||
|
||||
for (const task of tasks) {
|
||||
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
||||
|
||||
const reminders = getRemindersForTask(task, globalReminderPreference);
|
||||
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
||||
|
||||
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] =
|
||||
{ value: '1440', label: '1 day before' },
|
||||
];
|
||||
|
||||
export type TagReminderPreferences = Record<string, ReminderPreference[]>;
|
||||
|
||||
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
card: string;
|
||||
@@ -53,6 +57,7 @@ export interface ThemeColors {
|
||||
}
|
||||
|
||||
export const DEFAULT_ACCENT = '#EF5350';
|
||||
export const DEFAULT_ACCENT_OPACITY = 1;
|
||||
|
||||
export const ACCENT_PRESETS: string[] = [
|
||||
'#EF5350',
|
||||
@@ -70,6 +75,10 @@ export const ACCENT_PRESETS: string[] = [
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace(/^#/, '');
|
||||
// Handle both #RRGGBB (6 digits) and #AARRGGBB (8 digits with alpha)
|
||||
if (h.length === 8) {
|
||||
return [parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16)];
|
||||
}
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
@@ -97,8 +106,9 @@ function accentTextColor(accent: string): string {
|
||||
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
||||
}
|
||||
|
||||
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
|
||||
export function colors(accent: string = DEFAULT_ACCENT, opacity: number = 1): ThemeColors {
|
||||
const softAccent = desaturate(accent, 0.35);
|
||||
const accentWithOpacity = opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
|
||||
return {
|
||||
background: '#121212',
|
||||
card: '#1E1E1E',
|
||||
@@ -109,7 +119,7 @@ export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
|
||||
textSecondary: '#E3E4E8',
|
||||
textFaint: '#C4C6CC',
|
||||
textMuted: '#9C9FA7',
|
||||
accent,
|
||||
accent: opacity < 1 ? `${accentWithOpacity}` : accent,
|
||||
accentStrong: mixHex(accent, '#FFFFFF', 0.55),
|
||||
accentSoft: mixHex(softAccent, '#121212', 0.18),
|
||||
accentBorder: mixHex(desaturate(accent, 0.15), '#121212', 0.5),
|
||||
@@ -138,6 +148,8 @@ interface SettingsContextType {
|
||||
setSortBy: (value: SortBy) => void;
|
||||
reminderPreference: ReminderPreference;
|
||||
setReminderPreference: (value: ReminderPreference) => void;
|
||||
tagReminderPreferences: TagReminderPreferences;
|
||||
setTagReminderPreferences: (value: TagReminderPreferences) => void;
|
||||
apiUrl: string;
|
||||
setApiUrl: (value: string) => void;
|
||||
accentColor: string;
|
||||
@@ -146,6 +158,8 @@ interface SettingsContextType {
|
||||
setTodoAheadDays: (value: number) => void;
|
||||
showCompleted: boolean;
|
||||
setShowCompleted: (value: boolean) => void;
|
||||
deleteBySwipe: boolean;
|
||||
setDeleteBySwipe: (value: boolean) => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -156,9 +170,12 @@ const STORAGE_KEYS = {
|
||||
defaultCategoryId: 'settings:defaultCategoryId',
|
||||
sortBy: 'settings:sortBy',
|
||||
reminderPreference: 'settings:reminderPreference',
|
||||
tagReminderPreferences: TAG_REMINDER_PREFERENCES_KEY,
|
||||
accentColor: 'settings:accentColor',
|
||||
accentOpacity: 'settings:accentOpacity',
|
||||
todoAheadDays: 'settings:todoAheadDays',
|
||||
showCompleted: 'settings:showCompleted',
|
||||
deleteBySwipe: 'settings:deleteBySwipe',
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||
@@ -197,10 +214,16 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
STORAGE_KEYS.reminderPreference,
|
||||
'15',
|
||||
);
|
||||
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
|
||||
STORAGE_KEYS.tagReminderPreferences,
|
||||
{},
|
||||
);
|
||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY);
|
||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
||||
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
|
||||
|
||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||
|
||||
@@ -214,6 +237,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setSortBy,
|
||||
reminderPreference,
|
||||
setReminderPreference,
|
||||
tagReminderPreferences,
|
||||
setTagReminderPreferences,
|
||||
apiUrl,
|
||||
setApiUrl,
|
||||
accentColor,
|
||||
@@ -222,6 +247,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
deleteBySwipe,
|
||||
setDeleteBySwipe,
|
||||
theme,
|
||||
}),
|
||||
[
|
||||
@@ -233,6 +260,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setSortBy,
|
||||
reminderPreference,
|
||||
setReminderPreference,
|
||||
tagReminderPreferences,
|
||||
setTagReminderPreferences,
|
||||
apiUrl,
|
||||
setApiUrl,
|
||||
accentColor,
|
||||
@@ -241,7 +270,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
theme,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
function parseICal(content: string): Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
rrule?: string;
|
||||
uid?: string;
|
||||
priority?: number;
|
||||
}> {
|
||||
const events: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
rrule?: string;
|
||||
uid?: string;
|
||||
priority?: number;
|
||||
}> = [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BEGIN:VEVENT')) {
|
||||
const event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
rrule?: string;
|
||||
uid?: string;
|
||||
priority?: number;
|
||||
} = {
|
||||
title: '',
|
||||
start: new Date(0),
|
||||
};
|
||||
|
||||
i++;
|
||||
let inEvent = true;
|
||||
|
||||
while (i < lines.length && inEvent) {
|
||||
const line = lines[i].trim();
|
||||
i++;
|
||||
|
||||
if (line === 'END:VEVENT') {
|
||||
inEvent = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('END:')) {
|
||||
inEvent = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('SUMMARY:')) {
|
||||
event.title = line.substring(8).trim();
|
||||
} else if (line.startsWith('DESCRIPTION:')) {
|
||||
event.description = line.substring(12).trim();
|
||||
} else if (line.startsWith('DTSTART:')) {
|
||||
const dateStr = line.substring(8).trim();
|
||||
event.start = parseICalDateTime(dateStr);
|
||||
} else if (line.startsWith('DTEND:')) {
|
||||
const dateStr = line.substring(6).trim();
|
||||
event.end = parseICalDateTime(dateStr);
|
||||
} else if (line.startsWith('DTSTAMP:')) {
|
||||
// ignore, we use start date
|
||||
} else if (line.startsWith('UID:')) {
|
||||
event.uid = line.substring(4).trim();
|
||||
} else if (line.startsWith('PRIORITY:')) {
|
||||
event.priority = parseInt(line.substring(9).trim(), 10);
|
||||
} else if (line.startsWith('ALL-DAY:')) {
|
||||
event.allDay = line.substring(10).trim().toLowerCase() === 'true';
|
||||
} else if (line.startsWith('RRULE:')) {
|
||||
event.rrule = line.substring(6).trim();
|
||||
} else if (line.startsWith('BEGIN:VALARM')) {
|
||||
// skip alarms
|
||||
i++;
|
||||
let inAlarm = true;
|
||||
while (i < lines.length && inAlarm) {
|
||||
const alarmLine = lines[i].trim();
|
||||
i++;
|
||||
if (alarmLine === 'END:VALARM') {
|
||||
inAlarm = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// skip unrecognized lines
|
||||
}
|
||||
}
|
||||
|
||||
if (event.title) {
|
||||
events.push(event);
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function parseICalDateTime(dateStr: string): Date {
|
||||
// Handle various iCal date/time formats
|
||||
// Format 1: YYYYMMDDTHHMMSSZ (UTC)
|
||||
// Format 2: YYYYMMDDTHHMMSS (local)
|
||||
// Format 3: YYYYMMDD (date only)
|
||||
// Format 4: TZID:... (with timezone)
|
||||
|
||||
let datePart = '';
|
||||
let timePart = '';
|
||||
let hasTime = false;
|
||||
|
||||
// Check for Z (UTC) indicator
|
||||
const hasZ = dateStr.endsWith('Z');
|
||||
const cleanStr = hasZ ? dateStr.slice(0, -1) : dateStr;
|
||||
|
||||
// Try to split at T
|
||||
const tIndex = cleanStr.indexOf('T');
|
||||
if (tIndex !== -1) {
|
||||
datePart = cleanStr.substring(0, tIndex);
|
||||
timePart = cleanStr.substring(tIndex + 1);
|
||||
hasTime = true;
|
||||
} else {
|
||||
datePart = cleanStr;
|
||||
}
|
||||
|
||||
// Parse date (YYYYMMDD)
|
||||
if (datePart.length >= 8) {
|
||||
const year = parseInt(datePart.substring(0, 4), 10);
|
||||
const month = parseInt(datePart.substring(4, 6), 10) - 1; // 0-indexed
|
||||
const day = parseInt(datePart.substring(6, 8), 10);
|
||||
const date = new Date(year, month, day);
|
||||
|
||||
if (hasTime && timePart) {
|
||||
const hour = parseInt(timePart.substring(0, 2), 10);
|
||||
const minute = parseInt(timePart.substring(2, 4), 10);
|
||||
date.setHours(hour, minute, 0, 0);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
// Fallback: try Date.parse
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
export default parseICal;
|
||||
@@ -804,6 +804,19 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < subtaskIds.length; i++) {
|
||||
const subtask = await collections.subtasks.find(subtaskIds[i]);
|
||||
await subtask.update((s) => {
|
||||
s.order = i;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateSubtaskData {
|
||||
taskId: string;
|
||||
parentSubtaskId?: string | null;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
x64:
|
||||
firstOrDefaultFilePatterns:
|
||||
- '**/*'
|
||||
- '!**/node_modules/**'
|
||||
- '!build{,/**/*}'
|
||||
- '!dist{,/**/*}'
|
||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||
- '!**/._*'
|
||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
|
||||
- '!.yarn{,/**/*}'
|
||||
- '!.editorconfig'
|
||||
- '!.yarnrc.yml'
|
||||
nodeModuleFilePatterns: []
|
||||
Vendored
-37
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<title>carry-your-live</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Use static rendering with Expo Router to support running without JavaScript. -->
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<!-- The root element for your Expo app. -->
|
||||
<div id="root"></div>
|
||||
<script src="/_expo/static/js/web/entry-c9e0f94bf4e223fd6879dadc2f9426cb.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
Copyright (c) Electron contributors
|
||||
Copyright (c) 2013-2020 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
-343352
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user