React Native Expo app — full mobile client
- Expo SDK 54, expo-router, NativeWind - 7 screens: login, tasks, calendar, goals, chat, settings, tabs - API client (api.hasdo.info), SecureStore auth, AuthContext - Tab navigation: Ukoly, Kalendar, Cile, Chat, Nastaveni - Pull-to-refresh, FAB, group colors, priority dots - Calendar grid with task dots - AI chat with keyboard avoiding - Web export verified (780 modules, 1.5MB bundle) - Bundle ID: info.hasdo.taskteam Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
264
mobile/app/(tabs)/calendar.tsx
Normal file
264
mobile/app/(tabs)/calendar.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthContext } from '../../lib/AuthContext';
|
||||
import * as api from '../../lib/api';
|
||||
|
||||
const DAYS = ['Po', 'Ut', 'St', 'Ct', 'Pa', 'So', 'Ne'];
|
||||
const MONTHS = [
|
||||
'Leden', 'Unor', 'Brezen', 'Duben', 'Kveten', 'Cerven',
|
||||
'Cervenec', 'Srpen', 'Zari', 'Rijen', 'Listopad', 'Prosinec',
|
||||
];
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number) {
|
||||
const day = new Date(year, month, 1).getDay();
|
||||
return day === 0 ? 6 : day - 1; // Monday = 0
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const { token } = useAuthContext();
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await api.getTasks(token);
|
||||
setTasks(res.data || []);
|
||||
} catch (err: any) {
|
||||
Alert.alert('Chyba', err.message);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await loadTasks();
|
||||
setRefreshing(false);
|
||||
}, [loadTasks]);
|
||||
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1));
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(year, month + 1, 1));
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstDay = getFirstDayOfMonth(year, month);
|
||||
|
||||
const tasksByDate: Record<string, any[]> = {};
|
||||
tasks.forEach((t) => {
|
||||
if (t.due_date) {
|
||||
const d = t.due_date.substring(0, 10);
|
||||
if (!tasksByDate[d]) tasksByDate[d] = [];
|
||||
tasksByDate[d].push(t);
|
||||
}
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const selectedTasks = selectedDate ? tasksByDate[selectedDate] || [] : [];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Month navigator */}
|
||||
<View style={styles.monthNav}>
|
||||
<TouchableOpacity onPress={prevMonth} style={styles.navBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color="#3B82F6" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthTitle}>
|
||||
{MONTHS[month]} {year}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={nextMonth} style={styles.navBtn}>
|
||||
<Ionicons name="chevron-forward" size={24} color="#3B82F6" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Day headers */}
|
||||
<View style={styles.dayHeaders}>
|
||||
{DAYS.map((d) => (
|
||||
<Text key={d} style={styles.dayHeader}>
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<View style={styles.grid}>
|
||||
{Array.from({ length: firstDay }).map((_, i) => (
|
||||
<View key={`empty-${i}`} style={styles.dayCell} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const hasTasks = !!tasksByDate[dateStr];
|
||||
const isToday = dateStr === today;
|
||||
const isSelected = dateStr === selectedDate;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={day}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
isToday && styles.todayCell,
|
||||
isSelected && styles.selectedCell,
|
||||
]}
|
||||
onPress={() => setSelectedDate(dateStr)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayText,
|
||||
isToday && styles.todayText,
|
||||
isSelected && styles.selectedText,
|
||||
]}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
{hasTasks && (
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
isSelected && { backgroundColor: '#fff' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Selected date tasks */}
|
||||
{selectedDate && (
|
||||
<View style={styles.tasksSection}>
|
||||
<Text style={styles.tasksSectionTitle}>
|
||||
Ukoly na {selectedDate}
|
||||
</Text>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<Text style={styles.noTasks}>Zadne ukoly</Text>
|
||||
) : (
|
||||
selectedTasks.map((t) => (
|
||||
<View key={t.id} style={styles.taskItem}>
|
||||
<Ionicons
|
||||
name={t.status === 'done' ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={20}
|
||||
color={t.status === 'done' ? '#22C55E' : '#94A3B8'}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.taskItemText,
|
||||
t.status === 'done' && { textDecorationLine: 'line-through', color: '#94A3B8' },
|
||||
]}
|
||||
>
|
||||
{t.title}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#F8FAFC' },
|
||||
monthNav: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
navBtn: { padding: 8 },
|
||||
monthTitle: { fontSize: 18, fontWeight: '700', color: '#1E293B' },
|
||||
dayHeaders: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E2E8F0',
|
||||
},
|
||||
dayHeader: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
backgroundColor: '#fff',
|
||||
padding: 4,
|
||||
},
|
||||
dayCell: {
|
||||
width: '14.28%',
|
||||
aspectRatio: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
todayCell: {
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderRadius: 20,
|
||||
},
|
||||
selectedCell: {
|
||||
backgroundColor: '#3B82F6',
|
||||
borderRadius: 20,
|
||||
},
|
||||
dayText: { fontSize: 15, color: '#1E293B' },
|
||||
todayText: { color: '#3B82F6', fontWeight: '700' },
|
||||
selectedText: { color: '#fff', fontWeight: '700' },
|
||||
dot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#3B82F6',
|
||||
marginTop: 2,
|
||||
},
|
||||
tasksSection: {
|
||||
margin: 12,
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
},
|
||||
tasksSectionTitle: { fontSize: 16, fontWeight: '600', color: '#1E293B', marginBottom: 12 },
|
||||
noTasks: { color: '#94A3B8', fontSize: 14 },
|
||||
taskItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F5F9',
|
||||
},
|
||||
taskItemText: { fontSize: 14, color: '#1E293B', flex: 1 },
|
||||
});
|
||||
Reference in New Issue
Block a user