Compare commits

..
1 Commits
Author SHA1 Message Date
tech08mag 2edd603564 fix: create android/app directory before generating debug keystore
The keytool command fails without the directory existing first.
Add mkdir -p before the keytool genkeypair command.
2026-08-24 19:50:01 +02:00
126 changed files with 344806 additions and 5272 deletions
+3 -8
View File
@@ -42,12 +42,8 @@ jobs:
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \ "platform-tools" \
"platforms;android-35" \ "platforms;android-35" \
"platforms;android-36" \
"build-tools;35.0.0" \ "build-tools;35.0.0" \
"build-tools;36.0.0" \
"ndk;27.1.12297006" "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 - name: Install JS dependencies
working-directory: carry-your-live working-directory: carry-your-live
@@ -63,12 +59,11 @@ jobs:
-dname "CN=Android Debug,O=Android,C=US" -dname "CN=Android Debug,O=Android,C=US"
- name: Build release APK - name: Build release APK
run: | working-directory: carry-your-live/android
cd carry-your-live/android run: ./gradlew assembleRelease
./gradlew assembleRelease
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: carry-your-live-release name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/release/app-release.apk path: carry-your-live/android/app/build/outputs/apk/release/*.apk
+1 -2
View File
@@ -1,2 +1 @@
node_modules/ node_modules
dist/
-17
View File
@@ -1,17 +0,0 @@
# 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
-13
View File
@@ -1,13 +0,0 @@
{
"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
}
-17
View File
@@ -1,17 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
-98
View File
@@ -1,98 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
-17
View File
@@ -1,17 +0,0 @@
# 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
+116 -138
View File
@@ -8,21 +8,17 @@ import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals'; import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useICalImport } from '@/hooks/useICalImport'; import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns'; import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import type { ThemeColors } from '@/theme'; import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
const WIDTH = Dimensions.get('window').width; const WIDTH = Dimensions.get('window').width;
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so']; 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 = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December', 'July', 'August', 'September', 'October', 'November', 'December',
@@ -30,16 +26,9 @@ const MONTH_NAMES = [
export default function CalendarScreen() { export default function CalendarScreen() {
const router = useRouter(); const router = useRouter();
const { theme, sortBy } = useSettings(); const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]); const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories(); 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 { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date()); const [visibleMonth, setVisibleMonth] = useState(() => new Date());
@@ -66,25 +55,6 @@ export default function CalendarScreen() {
const monthTasks = useTasksInMonth(visibleMonth); const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh; 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( useFocusEffect(
useCallback(() => { useCallback(() => {
refreshDayTasks(); refreshDayTasks();
@@ -195,27 +165,6 @@ export default function CalendarScreen() {
}); });
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); }, [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 ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} /> <Header title="Calendar" showLogo={false} />
@@ -295,41 +244,78 @@ export default function CalendarScreen() {
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text> <Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{sortedDayTasks.length > 0 && ( {selectedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}> <Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'} {selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
</Text> </Text>
)} )}
</View> </View>
{sortedDayTasks.length === 0 ? ( {selectedDayTasks.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text> <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> <Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
</View> </View>
) : ( ) : (
sortedDayTasks.map((task) => { selectedDayTasks.map((task) => (
const isExpanded = expandedTasks.has(task.id); <View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
const itemSubtasks = subtasksByTask.get(task.id) ?? []; <View style={styles.eventRow}>
return ( <TouchableOpacity
<View key={task.id} style={styles.eventCard}> style={styles.checkCircle}
<CalendarTaskRow onPress={() => handleToggleComplete(task.id)}
task={task} activeOpacity={0.7}
expanded={isExpanded} >
subtasks={itemSubtasks} <Svg width={30} height={30} viewBox="0 0 24 24">
categoryColor={categoryColors.get(task.categoryId)} {task.completed ? (
onToggle={() => handleToggleComplete(task.id)} <>
onPress={(id) => <Circle cx={12} cy={12} r={10} fill={theme.accent} />
expandedTasks.has(id) <Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
? router.push({ pathname: '/task-detail', params: { id } }) </>
: toggleExpand(id) ) : (
} <Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
onMenuOpen={(id) => openTaskEdit(id)} )}
onSubtaskToggle={handleSubtaskToggle} </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>
</View> </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> </ScrollView>
@@ -365,60 +351,6 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? []; 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 { function cellTitle(tasks: Task[]): string | null {
if (tasks.length === 0) return null; if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title; if (tasks.length === 1) return tasks[0].title;
@@ -427,7 +359,8 @@ function cellTitle(tasks: Task[]): string | null {
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string { function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return theme.textMuted; if (tasks.length === 0) return theme.textMuted;
return theme.accent; const cat = categories.find((c) => c.id === tasks[0].categoryId);
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
} }
interface DayCellProps { interface DayCellProps {
@@ -612,10 +545,55 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
eventCard: { eventCard: {
marginHorizontal: 16, marginHorizontal: 16,
marginTop: 6, marginTop: 6,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
}, },
subtaskList: { eventRow: {
paddingLeft: 8, flexDirection: 'row',
paddingRight: 4, alignItems: 'center',
paddingTop: 4, },
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,
}, },
}); });
+1 -64
View File
@@ -15,8 +15,6 @@ import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth'; import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates'; import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync'; import { getLastSyncTime } from '@/database/sync';
import { rescheduleAllReminders } from '@/services/notifications';
import { collections } from '@/database';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel'; import ColorWheel from '@/components/ColorWheel';
@@ -24,10 +22,9 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings(); const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
const categories = useCategories(); const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null); 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 [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false); const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false); const [friendsVisible, setFriendsVisible] = useState(false);
@@ -98,14 +95,6 @@ export default function SettingsScreen() {
const defaultCategoryLabel = defaultCategory?.name ?? 'None'; const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; 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 = () => { const openCustomAccent = () => {
setDraftAccent(accentColor); setDraftAccent(accentColor);
setCustomAccentVisible(true); setCustomAccentVisible(true);
@@ -119,20 +108,6 @@ export default function SettingsScreen() {
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference); 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[]) => { const handleTodoAheadDays = (value: string | string[]) => {
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10)); setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
}; };
@@ -188,18 +163,6 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')} onPress={() => notifications && setPicker('reminder')}
showChevron 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> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem <ListItem
title="Default Tag" title="Default Tag"
@@ -225,19 +188,6 @@ 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 <ListItem
title="Show Calendar Tasks" title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`} subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
@@ -344,19 +294,6 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)} 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 <OptionPickerModal
visible={picker === 'todoAhead'} visible={picker === 'todoAhead'}
title="Calendar Tasks in Todo" title="Calendar Tasks in Todo"
-1
View File
@@ -194,7 +194,6 @@ export default function AddTaskScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
automaticallyAdjustContentInsets={false}
> >
<CategorySelector <CategorySelector
value={tags} value={tags}
+3 -6
View File
@@ -40,14 +40,11 @@ export default function DayViewScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => { const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
router.back();
return true;
});
refreshSubtasks(); refreshSubtasks();
refresh(); refresh();
return () => sub.remove(); return () => sub.remove();
}, [router, refreshSubtasks, refresh]) }, [refreshSubtasks, refresh])
); );
const subtasksOnDay = useMemo(() => { const subtasksOnDay = useMemo(() => {
@@ -73,7 +70,7 @@ export default function DayViewScreen() {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" /> <Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding"> <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> <ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{loading ? ( {loading ? (
-1
View File
@@ -187,7 +187,6 @@ export default function SubtaskDetailScreen() {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
> >
<Controller <Controller
control={control} control={control}
@@ -49,9 +49,9 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
let hour: number; let hour: number;
if (is24Hour) { if (is24Hour) {
const inner = distance < INNER_NUM_RADIUS + 14; const inner = distance < INNER_NUM_RADIUS + 14;
hour = inner ? base + 12 : base === 0 ? 12 : base; hour = inner ? base + 12 : (base === 0 ? 12 : base);
} else { } else {
hour = isPM ? base + 12 : base === 0 ? 0 : base; hour = isPM ? base + 12 : (base === 0 ? 0 : base);
} }
emit(hour, minutes); emit(hour, minutes);
setMode('minute'); setMode('minute');
@@ -135,8 +135,7 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
); );
}); });
const minuteDots = const minuteDots = mode === 'minute'
mode === 'minute'
? Array.from({ length: 12 }, (_, i) => { ? Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 * Math.PI) / 180; const angle = (i * 30 * Math.PI) / 180;
const x = CENTER + RADIUS * 0.82 * Math.sin(angle); const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
@@ -182,7 +181,7 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
</TouchableOpacity> </TouchableOpacity>
{!is24Hour && ( {!is24Hour && (
<View style={styles.periodGroup}> <View style={styles.periodGroup}>
{(['AM', 'PM'] as const).map(period => { {(['AM', 'PM'] as const).map((period) => {
const active = isPM === (period === 'PM'); const active = isPM === (period === 'PM');
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -216,15 +215,15 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
style={styles.clockSurface} style={styles.clockSurface}
onStartShouldSetResponder={() => true} onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true} onMoveShouldSetResponder={() => true}
onResponderGrant={e => { onResponderGrant={(e) => {
movedRef.current = false; movedRef.current = false;
onLocation(e, false); onLocation(e, false);
}} }}
onResponderMove={e => { onResponderMove={(e) => {
movedRef.current = true; movedRef.current = true;
onLocation(e, true); onLocation(e, true);
}} }}
onResponderRelease={e => { onResponderRelease={(e) => {
if (!movedRef.current) { if (!movedRef.current) {
onLocation(e, false); onLocation(e, false);
} }
@@ -1,15 +1,5 @@
import React from 'react'; import React from 'react';
import { import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
View,
Text,
TouchableOpacity,
StyleSheet,
Modal,
FlatList,
ActivityIndicator,
TextInput,
KeyboardAvoidingView,
} from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -92,14 +82,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text> <Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text> <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'} {selectedFriend ? selectedFriend.username : 'Unassigned'}
</Text> </Text>
</View> </View>
{value && !disabled && ( {value && !disabled && (
<TouchableOpacity <TouchableOpacity
style={styles.clearButton} style={styles.clearButton}
onPress={e => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation();
handleClear(); handleClear();
}} }}
@@ -110,12 +105,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
)} )}
</TouchableOpacity> </TouchableOpacity>
<Modal <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]} style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding" behavior="padding"
@@ -123,10 +113,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
<TouchableOpacity <TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
onPress={() => setShowModal(false)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text> <Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -135,10 +122,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.optionRow, styles.optionRow,
{ { backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
backgroundColor: selectedFriend ? theme.accentSoft : 'transparent',
borderColor: selectedFriend ? theme.accent : 'transparent',
},
]} ]}
onPress={() => handleClear()} onPress={() => handleClear()}
> >
@@ -159,9 +143,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<ActivityIndicator color={theme.accent} size="large" /> <ActivityIndicator color={theme.accent} size="large" />
</View> </View>
) : friends.length === 0 ? ( ) : friends.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}> <Text style={[styles.emptyText, { color: theme.textFaint }]}>No friends yet. Add friends to assign tasks.</Text>
No friends yet. Add friends to assign tasks.
</Text>
) : ( ) : (
<FlatList <FlatList
data={friends} data={friends}
@@ -170,19 +152,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.optionRow, 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)} onPress={() => handleSelectFriend(item.id)}
> >
<View style={styles.optionIcon}> <View style={styles.optionIcon}>
<Text style={styles.optionIconText}>👤</Text> <Text style={styles.optionIconText}>👤</Text>
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}> <Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{item.username}
</Text>
{value === item.id && ( {value === item.id && (
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text> <Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
)} )}
@@ -195,14 +172,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<View style={styles.modalSection}> <View style={styles.modalSection}>
<View style={styles.searchContainer}> <View style={styles.searchContainer}>
<TextInput <TextInput
style={[ style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.searchInput,
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Search users by username" placeholder="Search users by username"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={searchQuery} value={searchQuery}
@@ -227,21 +197,15 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<View style={styles.optionIcon}> <View style={styles.optionIcon}>
<Text style={styles.optionIconText}>➕</Text> <Text style={styles.optionIconText}>➕</Text>
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}> <Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{item.username} <Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
</Text>
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>
Add as friend & assign
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
/> />
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? ( ) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text> <Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
) : searchQuery.length < 2 ? ( ) : searchQuery.length < 2 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}> <Text style={[styles.emptyText, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
Type at least 2 characters to search
</Text>
) : null} ) : null}
</View> </View>
</View> </View>
@@ -1,23 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
Modal,
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
Pressable,
useWindowDimensions,
} from 'react-native';
import Category from '@/models/Category'; import Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions'; import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm'; import { confirmAction } from '@/utils/confirm';
import ColorWheel from '@/components/ColorWheel'; import ColorPickerInput from '@/components/ColorPickerInput';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryEditorModalProps { interface CategoryEditorModalProps {
@@ -27,12 +15,7 @@ interface CategoryEditorModalProps {
onClose: () => void; onClose: () => void;
} }
export function CategoryEditorModal({ export function CategoryEditorModal({ visible, category, categoryCount, onClose }: CategoryEditorModalProps) {
visible,
category,
categoryCount,
onClose,
}: CategoryEditorModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]); const [color, setColor] = useState(CATEGORY_COLORS[0]);
@@ -81,8 +64,15 @@ export function CategoryEditorModal({
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0} keyboardVerticalOffset={0}
> >
<Pressable style={styles.overlay} onPress={onClose} accessibilityRole="none"> <Pressable
<Pressable style={[styles.sheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}> style={styles.overlay}
onPress={onClose}
accessibilityRole="none"
>
<Pressable
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
onPress={() => {}}
>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -92,21 +82,10 @@ export function CategoryEditorModal({
<Text style={[styles.title, { color: theme.text }]}> <Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Tag' : 'New Tag'} {category ? 'Edit Tag' : 'New Tag'}
</Text> </Text>
<TouchableOpacity <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
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"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} /> <Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
<Path <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -115,11 +94,7 @@ export function CategoryEditorModal({
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
{ { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]} ]}
placeholder="Tag name" placeholder="Tag name"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
@@ -132,7 +107,7 @@ export function CategoryEditorModal({
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text> <Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
<View style={styles.colorGrid}> <View style={styles.colorGrid}>
{CATEGORY_COLORS.map(c => ( {CATEGORY_COLORS.map((c) => (
<TouchableOpacity <TouchableOpacity
key={c} key={c}
style={[ style={[
@@ -148,20 +123,13 @@ export function CategoryEditorModal({
> >
{color === c && ( {color === c && (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path <Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M5 12l5 5 9-10"
stroke="#FFFFFF"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
)} )}
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<ColorWheel color={color} onChange={setColor} /> <ColorPickerInput value={color} onChange={setColor} />
<View style={styles.actions}> <View style={styles.actions}>
{category && ( {category && (
@@ -171,22 +139,13 @@ export function CategoryEditorModal({
disabled={!canDelete} disabled={!canDelete}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
style={[
styles.deleteButtonText,
{ color: canDelete ? theme.accent : theme.textMuted },
]}
>
Delete Delete
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
styles.saveButton,
{ backgroundColor: theme.accent },
!name.trim() && styles.saveButtonDisabled,
]}
onPress={handleSave} onPress={handleSave}
disabled={!name.trim()} disabled={!name.trim()}
activeOpacity={0.8} activeOpacity={0.8}
@@ -57,7 +57,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
isAllCategory={true} isAllCategory={true}
scaleAnim={scaleAnim} scaleAnim={scaleAnim}
/> />
{categories.map(category => ( {categories.map((category) => (
<CategoryButton <CategoryButton
key={category.id} key={category.id}
id={category.id} id={category.id}
@@ -72,10 +72,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
/> />
))} ))}
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
styles.manageButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
]}
onPress={() => router.push('/settings')} onPress={() => router.push('/settings')}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button" accessibilityRole="button"
@@ -83,22 +80,10 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle <Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
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={8.5} r={1.2} fill={theme.textSecondary} />
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} /> <Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
<Path <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" />
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> </Svg>
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text> <Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -119,16 +104,7 @@ interface CategoryButtonProps {
scaleAnim: Animated.Value; scaleAnim: Animated.Value;
} }
function CategoryButton({ function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
color,
selected,
onPress,
onPressIn,
onPressOut,
theme,
isAllCategory,
scaleAnim,
}: CategoryButtonProps) {
const animatedStyle = { const animatedStyle = {
transform: [{ scale: scaleAnim }], transform: [{ scale: scaleAnim }],
shadowColor: selected ? theme.accent : '#000', shadowColor: selected ? theme.accent : '#000',
@@ -154,24 +130,8 @@ function CategoryButton({
> >
{isAllCategory ? ( {isAllCategory ? (
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle <Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
cx={12} <Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
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> </Svg>
) : null} ) : null}
</TouchableOpacity> </TouchableOpacity>
@@ -1,14 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Modal,
Pressable,
KeyboardAvoidingView,
} from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -25,7 +16,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const selected = new Set(value); 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 toggle = (id: string) => {
const next = new Set(selected); const next = new Set(selected);
@@ -42,7 +33,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
? 'None' ? 'None'
: selectedCategories : selectedCategories
.slice(0, 2) .slice(0, 2)
.map(c => c.name) .map((c) => c.name)
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : ''); .join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
return ( return (
@@ -62,18 +53,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
{selectedCategories.length > 0 ? ( {selectedCategories.length > 0 ? (
<View style={styles.chipRow}> <View style={styles.chipRow}>
{selectedCategories.slice(0, 3).map(c => ( {selectedCategories.slice(0, 3).map((c) => (
<View <View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
key={c.id}
style={[
styles.chip,
{ backgroundColor: theme.cardAlt, borderColor: theme.border },
]}
>
<View style={[styles.colorCircle, { backgroundColor: c.color }]} /> <View style={[styles.colorCircle, { backgroundColor: c.color }]} />
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}> <Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
{c.name}
</Text>
</View> </View>
))} ))}
</View> </View>
@@ -82,24 +65,12 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
)} )}
</View> </View>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M9 18l6-6-6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>} {error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<Modal <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]} style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding" behavior="padding"
@@ -110,10 +81,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
<View style={styles.modalHeaderRight}> <View style={styles.modalHeaderRight}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => { onChange([]); setShowModal(false); }}
onChange([]);
setShowModal(false);
}}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Clear all tags" accessibilityLabel="Clear all tags"
@@ -134,7 +102,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[styles.modalHint, { color: theme.textMuted }]}> <Text style={[styles.modalHint, { color: theme.textMuted }]}>
A task can have multiple tags. Selected: {selected.size} A task can have multiple tags. Selected: {selected.size}
</Text> </Text>
{categories.map(category => { {categories.map((category) => {
const isSelected = selected.has(category.id); const isSelected = selected.has(category.id);
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -142,11 +110,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
style={[ style={[
styles.modalOption, styles.modalOption,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: theme.card, borderColor: theme.borderStrong },
isSelected && { isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]} ]}
onPress={() => toggle(category.id)} onPress={() => toggle(category.id)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -154,32 +118,17 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
accessibilityLabel={`Tag ${category.name}`} accessibilityLabel={`Tag ${category.name}`}
accessibilityState={{ checked: isSelected }} accessibilityState={{ checked: isSelected }}
> >
<View <View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
style={[ <Text style={[
styles.colorCircle,
{ backgroundColor: category.color },
isSelected && styles.colorCircleSelected,
]}
/>
<Text
style={[
styles.categoryName, styles.categoryName,
{ color: theme.textSecondary }, { color: theme.textSecondary },
isSelected && { color: theme.text, fontWeight: '600' }, isSelected && { color: theme.text, fontWeight: '600' },
]} ]}>
>
{category.name} {category.name}
</Text> </Text>
{isSelected && ( {isSelected && (
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M5 12l5 5 9-10"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -20,23 +20,15 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
const valid = isValidHex(hex); const valid = isValidHex(hex);
const handleChange = (raw: string) => { const handleChange = (raw: string) => {
const cleaned = raw const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
.replace(/[^0-9a-fA-F]/g, '')
.slice(0, 6)
.toUpperCase();
onChange(`#${cleaned}`); onChange(`#${cleaned}`);
}; };
return ( return (
<View style={styles.row}> <View style={styles.row}>
<View <View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
style={[ <View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
styles.preview, <Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
{ backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
]}
/>
<View style={styles.inputWrap}>
<Text style={styles.hash}>#{/**/}</Text>
<TextInput <TextInput
style={[styles.input, { color: theme.text }]} style={[styles.input, { color: theme.text }]}
value={hex} value={hex}
@@ -48,7 +40,7 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
maxLength={6} maxLength={6}
/> />
</View> </View>
<Text style={styles.hint}>{/**/}Hex code</Text> <Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
</View> </View>
); );
} }
@@ -28,13 +28,13 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
width: 44, width: 44,
height: 44, height: 44,
padding: 0, padding: 0,
border: `1px solid ${theme.accent}`, border: `1px solid ${theme.borderStrong}`,
borderRadius: 12, borderRadius: 12,
backgroundColor: 'transparent', backgroundColor: 'transparent',
cursor: 'pointer', cursor: 'pointer',
}, },
})} })}
<Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text> <Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
</View> </View>
); );
} }
@@ -33,38 +33,14 @@ function hsvToHex(h: number, s: number, v: number) {
const c = v * s; const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c; const m = v - c;
let r = 0, let r = 0, g = 0, b = 0;
g = 0, if (h < 60) { r = c; g = x; b = 0; }
b = 0; else if (h < 120) { r = x; g = c; b = 0; }
if (h < 60) { else if (h < 180) { r = 0; g = c; b = x; }
r = c; else if (h < 240) { r = 0; g = x; b = c; }
g = x; else if (h < 300) { r = x; g = 0; b = c; }
b = 0; else { r = c; g = 0; b = x; }
} else if (h < 120) { const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
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(); return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
} }
@@ -108,49 +84,35 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
setValue(1 - distance / radius); setValue(1 - distance / radius);
}; };
const wheelPanResponder = React.useMemo( const wheelPanResponder = React.useMemo(() => PanResponder.create({
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: event => { onPanResponderGrant: (event) => {
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY); updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
}, },
onPanResponderMove: event => { onPanResponderMove: (event) => {
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY); updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
}, },
}), }), []);
[]
);
const setHueFromLocation = (locationX: number) => { const setHueFromLocation = (locationX: number) => {
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360))); setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
}; };
const huePanResponder = React.useMemo( const huePanResponder = React.useMemo(() => PanResponder.create({
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: event => { onPanResponderGrant: (event) => {
setHueFromLocation(event.nativeEvent.locationX); setHueFromLocation(event.nativeEvent.locationX);
}, },
onPanResponderMove: event => { onPanResponderMove: (event) => {
setHueFromLocation(event.nativeEvent.locationX); setHueFromLocation(event.nativeEvent.locationX);
}, },
}), }), []);
[]
);
const thumbX = const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) + const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
WHEEL_SIZE / 2 - const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_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 ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -218,9 +180,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
<View style={styles.previewContainer}> <View style={styles.previewContainer}>
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} /> <View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
<RNText style={[styles.previewText, { color: theme.text }]}> <RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
{hsvToHex(hue, saturation, value)}
</RNText>
</View> </View>
</View> </View>
); );
@@ -32,38 +32,14 @@ function hsvToHex(h: number, s: number, v: number) {
const c = v * s; const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c; const m = v - c;
let r = 0, let r = 0, g = 0, b = 0;
g = 0, if (h < 60) { r = c; g = x; b = 0; }
b = 0; else if (h < 120) { r = x; g = c; b = 0; }
if (h < 60) { else if (h < 180) { r = 0; g = c; b = x; }
r = c; else if (h < 240) { r = 0; g = x; b = c; }
g = x; else if (h < 300) { r = x; g = 0; b = c; }
b = 0; else { r = c; g = 0; b = x; }
} else if (h < 120) { const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
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(); return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
} }
@@ -132,25 +108,15 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
handleMove(e.nativeEvent); handleMove(e.nativeEvent);
}; };
const thumbX = const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) + const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
WHEEL_SIZE / 2 - const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_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); const currentColor = hsvToHex(hue, saturation, value);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.wheelContainer}> <View style={styles.wheelContainer}>
<Svg <Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
width={WHEEL_SIZE}
height={WHEEL_SIZE}
{...({ onMouseDown: handleWheelMouseDown } as object)}
>
<DefsElement> <DefsElement>
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%"> <LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} /> <Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
@@ -109,11 +109,7 @@ export default function WebDateTimeInput({
> >
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text> <Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
style={[styles.doneButton, { backgroundColor: theme.accent }]}
onPress={onDismiss}
activeOpacity={0.8}
>
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text> <Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -49,30 +49,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
styles.pickerButton, styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong }, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
fieldName === 'endTime' && fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
endTimeInvalid && {
borderColor: theme.dangerStrong,
backgroundColor: theme.accentSoft,
},
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle <Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
cx={12} <Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
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> </Svg>
<Text <Text
style={[ style={[
@@ -98,12 +82,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
@@ -163,12 +142,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
@@ -177,10 +151,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
}} }}
/> />
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
styles.calendarButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
]}
onPress={() => setPicker('date')} onPress={() => setPicker('date')}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityLabel="Open calendar" accessibilityLabel="Open calendar"
@@ -213,7 +184,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
thumbColor={field.value ? theme.accent : theme.textMuted} thumbColor={field.value ? theme.accent : theme.textMuted}
ios_backgroundColor={theme.border} ios_backgroundColor={theme.border}
value={field.value ?? false} value={field.value ?? false}
onValueChange={value => { onValueChange={(value) => {
field.onChange(value); field.onChange(value);
if (value) { if (value) {
timeRef.current?.(''); timeRef.current?.('');
@@ -232,27 +203,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
</View> </View>
{endTimeInvalid && ( {endTimeInvalid && (
<View <View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
style={[
styles.warningRow,
{ backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong },
]}
>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle <Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
cx={12} <Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
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> </Svg>
<Text style={[styles.warningText, { color: theme.dangerStrong }]}> <Text style={[styles.warningText, { color: theme.dangerStrong }]}>
{endTime === startTime {endTime === startTime
@@ -281,8 +235,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<WheelTimePicker <WheelTimePicker
visible={picker === 'time' || picker === 'endTime'} visible={picker === 'time' || picker === 'endTime'}
title={picker === 'endTime' ? 'End Time' : 'Start Time'} title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={picker === 'endTime' ? endTime || startTime : startTime} initialTime={
onConfirm={time => { picker === 'endTime'
? endTime || startTime
: startTime
}
onConfirm={(time) => {
if (picker === 'endTime') { if (picker === 'endTime') {
setEndTimeValue(time); setEndTimeValue(time);
} else { } else {
@@ -36,8 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
); );
} }
const createStyles = (theme: ThemeColors) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({
container: { container: {
gap: 5, gap: 5,
}, },
@@ -47,7 +47,12 @@ export function FloatingActionButton() {
}); });
return ( return (
<Animated.View style={[styles.container, { transform: [{ scale: scaleAnim }] }]}> <Animated.View
style={[
styles.container,
{ transform: [{ scale: scaleAnim }] },
]}
>
<TouchableOpacity <TouchableOpacity
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]} style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
onPress={handlePress} onPress={handlePress}
@@ -44,17 +44,13 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
const result = await forgotPassword(username.trim()); const result = await forgotPassword(username.trim());
if (result.success) { if (result.success) {
if (result.resetToken) { if (result.resetToken) {
Alert.alert( Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
'Reset Token (Dev Mode)', { text: 'OK', onPress: () => setStep('enterResetCode') },
`Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, ]);
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
);
} else { } else {
Alert.alert( Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
'Reset Email Sent', { text: 'OK', onPress: () => setStep('enterResetCode') },
'If the account exists, a password reset link has been sent.', ]);
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
);
} }
} else { } else {
setError('Failed to request password reset'); setError('Failed to request password reset');
@@ -116,10 +112,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
Enter your username to receive a password reset link. Enter your username to receive a password reset link.
</Text> </Text>
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Username" placeholder="Username"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={username} value={username}
@@ -137,9 +130,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
{busy ? ( {busy ? (
<ActivityIndicator color={theme.accentText} /> <ActivityIndicator color={theme.accentText} />
) : ( ) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}> <Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
Send Reset Link
</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -151,10 +142,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
Enter the reset token and your new password. Enter the reset token and your new password.
</Text> </Text>
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Reset Token" placeholder="Reset Token"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={resetToken} value={resetToken}
@@ -163,10 +151,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
autoCorrect={false} autoCorrect={false}
/> />
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="New Password (min 8 chars)" placeholder="New Password (min 8 chars)"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={newPassword} value={newPassword}
@@ -174,10 +159,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
secureTextEntry secureTextEntry
/> />
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Confirm New Password" placeholder="Confirm New Password"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={confirmPassword} value={confirmPassword}
@@ -194,9 +176,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
{busy ? ( {busy ? (
<ActivityIndicator color={theme.accentText} /> <ActivityIndicator color={theme.accentText} />
) : ( ) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}> <Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
Reset Password
</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -204,7 +184,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}> <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} /> <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} /> <View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
+4 -23
View File
@@ -13,10 +13,7 @@ interface FormButtonsProps {
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) { export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { const { handleSubmit, formState: { isSubmitting } } = useFormContext();
handleSubmit,
formState: { isSubmitting },
} = useFormContext();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const cancel = () => { const cancel = () => {
@@ -28,21 +25,9 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
}; };
return ( return (
<View <View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
style={[
styles.container,
{
backgroundColor: theme.background,
borderTopColor: theme.border,
paddingBottom: insets.bottom + 12,
},
]}
>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
styles.cancelButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
onPress={cancel} onPress={cancel}
disabled={isSubmitting} disabled={isSubmitting}
activeOpacity={0.7} activeOpacity={0.7}
@@ -53,11 +38,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text> <Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
styles.submitButton,
{ backgroundColor: theme.accent },
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
disabled={isSubmitting} disabled={isSubmitting}
activeOpacity={0.8} activeOpacity={0.8}
+26 -104
View File
@@ -21,23 +21,11 @@ interface FriendsModalProps {
export function FriendsModal({ visible, onClose }: FriendsModalProps) { export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const { const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
friends,
incoming,
outgoing,
loading,
sendRequest,
acceptRequest,
declineRequest,
removeFriend,
searchUsers,
} = useFriends();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]); const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>( const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
'friends'
);
const searchKey = `${selectedTab}:${searchQuery}`; const searchKey = `${selectedTab}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = useState(searchKey); const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
@@ -53,11 +41,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
setSearching(true); setSearching(true);
try { try {
const results = await searchUsers(searchQuery); const results = await searchUsers(searchQuery);
setSearchResults( setSearchResults(results.filter(r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)));
results.filter(
r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)
)
);
} catch (err) { } catch (err) {
console.error('Search failed:', err); console.error('Search failed:', err);
} finally { } finally {
@@ -87,17 +71,13 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const handleRemove = async (friendId: string) => { const handleRemove = async (friendId: string) => {
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [ Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ { text: 'Remove', style: 'destructive', onPress: async () => {
text: 'Remove',
style: 'destructive',
onPress: async () => {
try { try {
await removeFriend(friendId); await removeFriend(friendId);
} catch (err) { } catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend'); Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
} }
}, }},
},
]); ]);
}; };
@@ -118,7 +98,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View> </View>
<View style={styles.tabs}> <View style={styles.tabs}>
{(['friends', 'incoming', 'outgoing', 'add'] as const).map(tab => ( {(['friends', 'incoming', 'outgoing', 'add'] as const).map((tab) => (
<TouchableOpacity <TouchableOpacity
key={tab} key={tab}
style={[ style={[
@@ -136,14 +116,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
> >
{tab.charAt(0).toUpperCase() + tab.slice(1)} {tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'friends' && friends.length > 0 && ( {tab === 'friends' && friends.length > 0 && (
<Text style={[styles.badge, { color: theme.accentText }]}> <Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
{friends.length}
</Text>
)} )}
{tab === 'incoming' && incoming.length > 0 && ( {tab === 'incoming' && incoming.length > 0 && (
<Text style={[styles.badge, { color: theme.accentText }]}> <Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
{incoming.length}
</Text>
)} )}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -159,9 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
) : friends.length === 0 ? ( ) : friends.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text> <Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}> <Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
{'Tap "Add" to find friends by username'}
</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -169,19 +143,12 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={f => f.id} keyExtractor={f => f.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}> <View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}> <Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
{item.username}
</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
styles.removeBtn,
{ backgroundColor: theme.accentSoft, borderColor: theme.accent },
]}
onPress={() => handleRemove(item.id)} onPress={() => handleRemove(item.id)}
> >
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}> <Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
Remove
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -198,9 +165,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View> </View>
) : incoming.length === 0 ? ( ) : incoming.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}> <Text style={[styles.emptyText, { color: theme.textMuted }]}>No incoming requests</Text>
No incoming requests
</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -208,32 +173,19 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={r => r.requestId} keyExtractor={r => r.requestId}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}> <View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}> <Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
{item.username}
</Text>
<View style={styles.requestActions}> <View style={styles.requestActions}>
<TouchableOpacity <TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.accent }]} style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)} onPress={() => handleAccept(item.requestId)}
> >
<Text style={[styles.actionBtnText, { color: theme.accentText }]}> <Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
Accept
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
styles.actionBtn,
{
backgroundColor: 'transparent',
borderColor: theme.borderStrong,
borderWidth: 1,
},
]}
onPress={() => handleDecline(item.requestId)} onPress={() => handleDecline(item.requestId)}
> >
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}> <Text style={[styles.actionBtnText, { color: theme.textMuted }]}>Decline</Text>
Decline
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -251,9 +203,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View> </View>
) : outgoing.length === 0 ? ( ) : outgoing.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}> <Text style={[styles.emptyText, { color: theme.textMuted }]}>No outgoing requests</Text>
No outgoing requests
</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -261,24 +211,13 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={r => r.requestId} keyExtractor={r => r.requestId}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}> <View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.textMuted }]}> <Text style={[styles.friendName, { color: theme.textMuted }]}>{item.username}</Text>
{item.username}
</Text>
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text> <Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.cancelBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
styles.cancelBtn,
{
backgroundColor: 'transparent',
borderColor: theme.borderStrong,
borderWidth: 1,
},
]}
onPress={() => handleDecline(item.requestId)} onPress={() => handleDecline(item.requestId)}
> >
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}> <Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>Cancel</Text>
Cancel
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -291,14 +230,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<View style={styles.content}> <View style={styles.content}>
<View style={styles.searchContainer}> <View style={styles.searchContainer}>
<TextInput <TextInput
style={[ style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
styles.searchInput,
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Search by username" placeholder="Search by username"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={searchQuery} value={searchQuery}
@@ -321,24 +253,16 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={u => u.id} keyExtractor={u => u.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}> <View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}> <Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
{item.username}
</Text>
<TouchableOpacity <TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]} style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={() => onPress={() => sendRequest(item.username)
sendRequest(item.username)
.then(() => { .then(() => {
setSearchQuery(''); setSearchQuery('');
setSearchResults([]); setSearchResults([]);
setSelectedTab('friends'); setSelectedTab('friends');
}) })
.catch(err => .catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
Alert.alert(
'Error',
err instanceof Error ? err.message : 'Failed to send request'
)
)
} }
> >
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text> <Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
@@ -348,9 +272,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
/> />
) : searchQuery.length < 2 ? ( ) : searchQuery.length < 2 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}> <Text style={[styles.emptyHint, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
Type at least 2 characters to search
</Text>
</View> </View>
) : null} ) : null}
</View> </View>
+1 -3
View File
@@ -20,9 +20,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text> <Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
</View> </View>
)} )}
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header"> <Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
{title}
</Text>
<View style={styles.spacer}>{rightAction}</View> <View style={styles.spacer}>{rightAction}</View>
</View> </View>
</View> </View>
@@ -136,12 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}> <TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -0,0 +1,89 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface ListItemProps {
title: string;
subtitle?: string;
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>}
</View>
<View style={styles.rightContent}>
{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>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
interactive: {},
leftElement: {
marginRight: 12,
},
leftContent: {
flex: 1,
gap: 2,
},
title: {
fontSize: 16,
fontWeight: '500',
},
subtitle: {
fontSize: 13,
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
});
+1 -3
View File
@@ -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) { 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, deleteBySwipe } = useSettings(); const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]); const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0)); const [dragTranslateX] = React.useState(new Animated.Value(0));
@@ -176,9 +176,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions} renderLeftActions={renderLeftActions}
onSwipeableRightOpen={() => { onSwipeableRightOpen={() => {
if (deleteBySwipe) {
onDelete?.(); onDelete?.();
}
swipeableRef.current?.close(); swipeableRef.current?.close();
}} }}
onSwipeableLeftOpen={() => { onSwipeableLeftOpen={() => {
+10 -9
View File
@@ -334,8 +334,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
}, [findHoverTarget]); }, [findHoverTarget]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId?: string) => { const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) }; subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
}, [measureAll]); }, [measureAll]);
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => { const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
@@ -371,15 +371,16 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions); const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
if (target === state.subtaskId) return; if (target === state.subtaskId) return;
// Only allow dropping on tasks (not subtasks) to match task behavior if (target) {
// Check if target exists in main task positions, not subtask positions if (state.subPositions[target]) {
const isTaskTarget = state.positions && target in state.positions; await setSubtaskParent(state.subtaskId, target);
if (target && isTaskTarget) { } else if (target !== state.parentTaskId) {
// 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); await moveSubtaskToTask(state.subtaskId, target);
} }
}, [findHoverTarget, moveSubtaskToTask]); } else {
await convertSubtaskToTask(state.subtaskId);
}
}, [findHoverTarget]);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => { ({ item, index }: { item: Task; index: number }) => {
-194
View File
@@ -1,194 +0,0 @@
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 };
}
+1 -3
View File
@@ -109,9 +109,7 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
let details: any = null; let details: any = null;
try { try {
const body = JSON.parse(text); const body = JSON.parse(text);
if (body && typeof body.error?.message === 'string') { if (body && typeof body.message === 'string') {
message = body.error.message;
} else if (body && typeof body.message === 'string') {
message = body.message; message = body.message;
} }
if (body && body.error && body.error.details) { if (body && body.error && body.error.details) {
+4 -31
View File
@@ -1,19 +1,9 @@
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types'; import { Reminder, reminderToMinutes, parseReminders, ReminderPreference } from '@/types';
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds'; 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[]>> { async function readIdMap(): Promise<Record<string, string[]>> {
try { try {
const raw = await AsyncStorage.getItem(ID_MAP_KEY); const raw = await AsyncStorage.getItem(ID_MAP_KEY);
@@ -61,11 +51,7 @@ function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
} }
} }
function getRemindersForTask( function getRemindersForTask(task: any, globalPreference: ReminderPreference): Reminder[] {
task: any,
globalPreference: ReminderPreference,
tagPreferences: TagReminderPreferences = {}
): Reminder[] {
const taskReminders: Reminder[] = task.reminders const taskReminders: Reminder[] = task.reminders
? parseReminders(task.reminders) ? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none' : task.reminder && task.reminder !== 'none'
@@ -76,17 +62,6 @@ function getRemindersForTask(
return taskReminders; 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); const globalReminder = reminderPreferenceToReminder(globalPreference);
if (globalReminder !== 'none') { if (globalReminder !== 'none') {
return [globalReminder]; return [globalReminder];
@@ -117,8 +92,7 @@ export async function scheduleTaskReminder(task: any, notificationsEnabled: bool
await writeIdMap(idMap); await writeIdMap(idMap);
// Determine the set of reminders to schedule // Determine the set of reminders to schedule
const tagPreferences = await loadTagPreferences(); const reminders = getRemindersForTask(task, globalReminderPreference);
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
if (reminders.length === 0 || !dueDate || !task.dueTime) return; if (reminders.length === 0 || !dueDate || !task.dueTime) return;
@@ -221,12 +195,11 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
// ignore // ignore
} }
const idMap: Record<string, string[]> = {}; const idMap: Record<string, string[]> = {};
const tagPreferences = await loadTagPreferences();
for (const task of tasks) { for (const task of tasks) {
const dueDate = task.dueDate ? new Date(task.dueDate) : null; const dueDate = task.dueDate ? new Date(task.dueDate) : null;
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences); const reminders = getRemindersForTask(task, globalReminderPreference);
if (reminders.length === 0 || !dueDate || !task.dueTime) continue; if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
+1 -21
View File
@@ -23,10 +23,6 @@ export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] =
{ value: '1440', label: '1 day before' }, { value: '1440', label: '1 day before' },
]; ];
export type TagReminderPreferences = Record<string, ReminderPreference[]>;
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
export interface ThemeColors { export interface ThemeColors {
background: string; background: string;
card: string; card: string;
@@ -142,8 +138,6 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void; setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference; reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void; setReminderPreference: (value: ReminderPreference) => void;
tagReminderPreferences: TagReminderPreferences;
setTagReminderPreferences: (value: TagReminderPreferences) => void;
apiUrl: string; apiUrl: string;
setApiUrl: (value: string) => void; setApiUrl: (value: string) => void;
accentColor: string; accentColor: string;
@@ -152,8 +146,6 @@ interface SettingsContextType {
setTodoAheadDays: (value: number) => void; setTodoAheadDays: (value: number) => void;
showCompleted: boolean; showCompleted: boolean;
setShowCompleted: (value: boolean) => void; setShowCompleted: (value: boolean) => void;
deleteBySwipe: boolean;
setDeleteBySwipe: (value: boolean) => void;
theme: ThemeColors; theme: ThemeColors;
} }
@@ -164,11 +156,9 @@ const STORAGE_KEYS = {
defaultCategoryId: 'settings:defaultCategoryId', defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy', sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference', reminderPreference: 'settings:reminderPreference',
tagReminderPreferences: TAG_REMINDER_PREFERENCES_KEY,
accentColor: 'settings:accentColor', accentColor: 'settings:accentColor',
todoAheadDays: 'settings:todoAheadDays', todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted', showCompleted: 'settings:showCompleted',
deleteBySwipe: 'settings:deleteBySwipe',
}; };
const SettingsContext = createContext<SettingsContextType | null>(null); const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -207,15 +197,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
STORAGE_KEYS.reminderPreference, STORAGE_KEYS.reminderPreference,
'15', '15',
); );
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
STORAGE_KEYS.tagReminderPreferences,
{},
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL); const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS); const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false); const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
const theme = useMemo(() => colors(accentColor), [accentColor]); const theme = useMemo(() => colors(accentColor), [accentColor]);
@@ -229,8 +214,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
@@ -239,8 +222,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
theme, theme,
}), }),
[ [
@@ -252,8 +233,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
@@ -262,6 +241,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
theme,
] ]
); );
-156
View File
@@ -1,156 +0,0 @@
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;
-13
View File
@@ -804,19 +804,6 @@ 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 { export interface CreateSubtaskData {
taskId: string; taskId: string;
parentSubtaskId?: string | null; parentSubtaskId?: string | null;
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
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: []
+37
View File
@@ -0,0 +1,37 @@
<!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
View File
@@ -0,0 +1,21 @@
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.
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More