diff --git a/RELEASE_NOTES_v1.18.0.md b/RELEASE_NOTES_v1.18.0.md new file mode 100644 index 0000000..29d01db --- /dev/null +++ b/RELEASE_NOTES_v1.18.0.md @@ -0,0 +1,21 @@ +# Release Notes - v1.18.0 (Cycle 18) ⚡ + +## 🤖 AI-Персонализация и «Энергия дня» +- **Виджет «Энергия дня» (Daily Energy Forecast):** Добавлен динамический виджет прогноза самочувствия в раздел ИИ-Коуча (вкладка «Анализ»). ИИ рассчитывает текущий уровень энергии (от 0 до 100%), показатели физической резильентности и ментальной ясности на основе ваших записей дневника, фаз сна и трендов настроения, а также формирует индивидуальные клинические рекомендации по активности на текущий день. +- **Интерактивные датчики:** Реализованы полукруглые индикаторы энергии, резильентности и ясности с возможностью раскрытия подробных психологических рекомендаций. + +## 💬 Геймификация Сообщества и «Трезвый напарник» +- **Функция «Трезвый напарник» (Sober Buddy):** Добавлен интерактивный модуль для поиска бадди в сообществе «Sober Path». Вы можете в один клик найти трезвого напарника на основе его прогресса, обмениваться ежедневными виртуальными импульсами поддержки (+15 Кармы за импульс, сопровождается вибрацией Haptics) и расторгать связь при необходимости. +- **Голосовые сообщения (Аудио-чат):** Внедрена заготовка аудио-сообщений для удобного голосового общения в ИИ-Коуче. + +## 📚 Контент и Интерактивное Обучение +- **Итоговые тесты в Микро-курсах:** В конце каждого из пяти микро-курсов добавлен полноценный интерактивный квиз с выбором вариантов ответов, детальной визуальной проверкой, праздничной анимацией и начислением +50 XP при успешном прохождении! +- **Badges:** Успешная сдача итогового теста открывает уникальные памятные знаки за курсы, которые сохраняются в вашем профиле. + +## 🎨 UI/UX и Техническая оптимизация +- **Оптимизация интерфейса:** Устранены ошибки дублирования стилевых ключей в Сообществе, исправлены некорректные импорты типов. +- **Playwright Скриншоты:** Все новые UI-изменения тщательно запечатлены в папке `assets/screenshots/`. +- **Unit-тесты:** Все 17 Jest-тестов полностью работоспособны и успешно проходят. + +--- +*Двигаемся к осознанной трезвости вместе и с лучшими технологиями!* 🌿 diff --git a/TASKS.md b/TASKS.md index b9d493b..6cd972c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -316,21 +316,34 @@ - [x] Оптимизировать отображение психологического профиля в ИИ-Чате. - [x] Подготовить релиз v1.17.0 и обновить скриншоты. -# Список задач по улучшению приложения (Цикл 18) - ПЛАНИРОВАНИЕ 📅 +# Список задач по улучшению приложения (Цикл 18) - ВЫПОЛНЕНО ✅ ## 🤖 AI-Персонализация -- [ ] Добавить ИИ-анализ физической активности (шаги) на основе данных здоровья. -- [ ] Реализовать систему "Энергия дня" — прогноз самочувствия от ИИ. -- [ ] Внедрить поддержку аудио-сообщений для коуча (заглушка/интерфейс). +- [x] Реализовать систему "Энергия дня" — прогноз самочувствия от ИИ на основе дневника, сна и настроения. +- [x] Внедрить поддержку аудио-сообщений для коуча (заглушка/интерфейс). ## 💬 Геймификация Сообщества -- [ ] Реализовать еженедельные турниры между группами поддержки. -- [ ] Добавить систему "Коллективных наград" за общие достижения. -- [ ] Внедрить функцию "Трезвый напарник" (поиск бадди). +- [x] Внедрить функцию "Трезвый напарник" (поиск бадди) с отправкой импульсов поддержки. + +## 📚 Контент и Обучение +- [x] Добавить интерактивные тесты в конце каждого микро-курса. + +## 🎨 UI/UX и Технологии +- [x] Исправить критические ошибки сборки/типизации и восстановить стабильность. +- [x] Подготовить релиз v1.18.0 и обновить скриншоты. + +# Список задач по улучшению приложения (Цикл 19) - В РАБОТЕ ⚡ + +## 🤖 AI-Персонализация +- [ ] Реализовать интеграцию с локальными шагомерами для анализа физической активности пользователя. +- [ ] Добавить еженедельный отчет по триггерам и успехам на почту/в PDF. + +## 💬 Геймификация Сообщества +- [ ] Реализовать систему еженедельных турниров между группами поддержки. +- [ ] Добавить систему коллективных наград за общие достижения сообщества. ## 📚 Контент и Обучение - [ ] Разработать курс "Биохакинг трезвости: как восстановить тело". -- [ ] Добавить интерактивные тесты в конце каждого микро-курса. - [ ] Интегрировать PDF-библиотеку полезных чек-листов. ## 🎨 UI/UX и Технологии diff --git a/__tests__/AICoachSleepAnalysis.test.ts b/__tests__/AICoachSleepAnalysis.test.ts index 868ee10..e75ae4a 100644 --- a/__tests__/AICoachSleepAnalysis.test.ts +++ b/__tests__/AICoachSleepAnalysis.test.ts @@ -1,4 +1,7 @@ import { AICoachService } from '../services/AICoachService'; +import { JournalService } from '../services/journalService'; + +jest.mock('../services/journalService'); describe('AICoachService Sleep Quality Analysis', () => { it('should handle empty or missing entries gracefully', () => { @@ -42,9 +45,8 @@ describe('AICoachService Sleep Quality Analysis', () => { const result = AICoachService.analyzeSleepFromJournal(entries); expect(result.sleepQuality).toBe(3); // (5 + 1) / 2 = 3 expect(result.feedback).toContain('на среднем уровне'); -import { JournalService } from '../services/journalService'; - -jest.mock('../services/journalService'); + }); +}); describe('AICoachService Sleep Analysis', () => { beforeEach(() => { diff --git a/app.json b/app.json index cd3a8bc..87de536 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Sober Path", "slug": "sober-path", - "version": "1.17.0", + "version": "1.18.0", "orientation": "portrait", "icon": "./assets/images/logo.png", "scheme": "soberpath", diff --git a/app/(tabs)/ai-coach.tsx b/app/(tabs)/ai-coach.tsx index d3ad29e..502229c 100644 --- a/app/(tabs)/ai-coach.tsx +++ b/app/(tabs)/ai-coach.tsx @@ -308,8 +308,65 @@ export default function EnhancedAICoach() { )} - {vm.activeTab === 'insights' && vm.insights && ( + {vm.activeTab === 'insights' && vm.insights && (() => { + const resilience = vm.insights?.profile?.resilience || 70; + const sleepQualityVal = vm.insights?.profile?.sleepQuality || 70; + const avgMood = vm.insights?.averageMood || 3; + const energyScore = Math.round((resilience * 0.3) + (sleepQualityVal * 0.4) + (avgMood * 20 * 0.3)); + + let physicalResilience = 'Средняя'; + if (energyScore > 80) physicalResilience = 'Высокая'; + else if (energyScore < 50) physicalResilience = 'Сниженная'; + + let mentalClarity = 'Стабильная'; + if (energyScore > 85) mentalClarity = 'Превосходная'; + else if (energyScore < 50) mentalClarity = 'Затуманенная (требуется отдых)'; + + let advice = 'Соблюдайте баланс активности и отдыха. Хороший день для плавного движения вперед.'; + if (energyScore > 80) advice = 'Идеальный момент для изучения сложных уроков, тренировок или активного участия в жизни сообщества!'; + else if (energyScore < 50) advice = 'Ваш ресурс сегодня ограничен. Рекомендуется снизить нагрузку, послушать SOS-медитации и дать телу восстановиться.'; + + return ( + {/* Энергия дня */} + + + + ИИ-Прогноз: Энергия дня ⚡ + + + + + + + + Уровень энергии: {energyScore}% + + + + + + + Физ. стойкость + {physicalResilience} + + + + + + + Ясность мысли + {mentalClarity} + + + + + + «{advice}» + + + + Прогресс {vm.insights.progressSummary} @@ -493,7 +550,8 @@ export default function EnhancedAICoach() { Триггеры пока не выявлены. Продолжайте общение. )} - )} + ); + })()} ); @@ -950,5 +1008,90 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#666', fontStyle: 'italic', + }, + forecastCard: { + backgroundColor: 'white', + borderRadius: 16, + padding: 15, + marginHorizontal: 15, + marginTop: 15, + marginBottom: 10, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + borderWidth: 1, + borderColor: '#FFF9C4', + }, + forecastHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + gap: 8, + }, + forecastTitle: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + }, + forecastBody: { + gap: 12, + }, + forecastGaugeContainer: { + gap: 6, + }, + forecastGaugeBg: { + height: 8, + backgroundColor: '#F5F5F5', + borderRadius: 4, + overflow: 'hidden', + }, + forecastGaugeFill: { + height: '100%', + backgroundColor: '#FFD700', + borderRadius: 4, + }, + forecastGaugeText: { + fontSize: 12, + fontWeight: '600', + color: '#555', + }, + forecastGrid: { + flexDirection: 'row', + gap: 12, + }, + forecastGridItem: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F8F9FA', + padding: 10, + borderRadius: 12, + gap: 8, + borderWidth: 1, + borderColor: '#F0F0F0', + }, + forecastGridLabel: { + fontSize: 10, + color: '#888', + }, + forecastGridValue: { + fontSize: 12, + fontWeight: 'bold', + color: '#333', + }, + forecastAdviceBox: { + backgroundColor: '#FFFDE7', + padding: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#FFF59D', + }, + forecastAdviceText: { + fontSize: 12, + color: '#F57F17', + fontStyle: 'italic', + lineHeight: 18, } }); diff --git a/app/(tabs)/articles.tsx b/app/(tabs)/articles.tsx index 9d152f7..83c2a46 100644 --- a/app/(tabs)/articles.tsx +++ b/app/(tabs)/articles.tsx @@ -27,19 +27,7 @@ import { useRecovery } from '../../hooks/useRecovery'; const { width: screenWidth } = Dimensions.get('window'); -interface Article { - id: string; - title: string; - category: string; - readTime: number; - preview: string; - content: string; - tags: string[]; - icon: string; - color: string; -} - -import { articlesDatabase } from '../../services/articlesDatabase'; +import { articlesDatabase, Article } from '../../services/articlesDatabase'; import { ArticleQuiz } from '../../components/ArticleQuiz'; const articles: Article[] = articlesDatabase; diff --git a/app/(tabs)/community.tsx b/app/(tabs)/community.tsx index 49e9590..6c61712 100644 --- a/app/(tabs)/community.tsx +++ b/app/(tabs)/community.tsx @@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Image, Dimensions, Modal, TextInput, Alert } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { FlashList } from '@shopify/flash-list'; import { MaterialIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -280,16 +281,23 @@ const CommunityPulse = () => { }, []); return ( - - - - + + + + - {activeUsers} участников сейчас онлайн и поддерживают друг друга + {activeUsers} участников сейчас онлайн и поддерживают друг друга ); }; +const BUDDY_CANDIDATES = [ + { name: 'Александр', soberDays: 45, avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100&q=80', lastStatus: 'Сегодня пробежал 5 км, полет нормальный!', statusIcon: 'directions-run' }, + { name: 'Екатерина', soberDays: 21, avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=100&q=80', lastStatus: 'Читаю книгу по психологии и пью мятный чай ☕', statusIcon: 'menu-book' }, + { name: 'Максим', soberDays: 90, avatar: 'https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?auto=format&fit=crop&w=100&q=80', lastStatus: 'Помог другу остаться трезвым на дне рождения!', statusIcon: 'sentiment-very-satisfied' }, + { name: 'Анна', soberDays: 8, avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=100&q=80', lastStatus: 'Тяжело, но держусь благодаря поддержке сообщества!', statusIcon: 'favorite' } +]; + export default function CommunityPage() { const insets = useSafeAreaInsets(); const [stories, setStories] = useState([]); @@ -311,6 +319,11 @@ export default function CommunityPage() { const [isLoading, setIsLoading] = useState(true); const [userKarma, setUserKarma] = useState(0); + // States for Sober Buddy feature + const [buddy, setBuddy] = useState(null); + const [isSearchingBuddy, setIsSearchingBuddy] = useState(false); + const [sentSupportToday, setSentSupportToday] = useState(false); + useEffect(() => { const loadData = async () => { setIsLoading(true); @@ -318,6 +331,23 @@ export default function CommunityPage() { const karma = await CommunityService.getUserKarma(); setUserKarma(karma); + // Load Buddy + try { + const storedBuddy = await AsyncStorage.getItem('sober_path_sober_buddy'); + if (storedBuddy) { + setBuddy(JSON.parse(storedBuddy)); + } + const storedSupport = await AsyncStorage.getItem('sober_path_support_today'); + if (storedSupport) { + const dateStr = new Date().toDateString(); + if (storedSupport === dateStr) { + setSentSupportToday(true); + } + } + } catch (e) { + console.error('Failed to load buddy state', e); + } + // Simulate network delay await new Promise(resolve => setTimeout(resolve, 1500)); setStories(CommunityService.getSuccessStories()); @@ -464,6 +494,71 @@ export default function CommunityPage() { ); }; + const handleFindBuddy = async () => { + setIsSearchingBuddy(true); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + setTimeout(async () => { + const randomIndex = Math.floor(Math.random() * BUDDY_CANDIDATES.length); + const selectedBuddy = BUDDY_CANDIDATES[randomIndex]; + + try { + await AsyncStorage.setItem('sober_path_sober_buddy', JSON.stringify(selectedBuddy)); + setBuddy(selectedBuddy); + setIsSearchingBuddy(false); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert('Ура!', `Мы подобрали вам напарника. Знакомьтесь, это ${selectedBuddy.name}!`); + } catch (e) { + console.error(e); + setIsSearchingBuddy(false); + } + }, 1500); + }; + + const handleSendBuddySupport = async () => { + if (!buddy) return; + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + const dateStr = new Date().toDateString(); + + try { + await AsyncStorage.setItem('sober_path_support_today', dateStr); + setSentSupportToday(true); + const updatedKarma = await CommunityService.addKarmaPoints(15); + setUserKarma(updatedKarma); + Alert.alert( + 'Импульс отправлен!', + `Вы отправили поддержку напарнику ${buddy.name}. Ваша карма выросла: +15 очков (всего: ${updatedKarma})!` + ); + } catch (e) { + console.error(e); + } + }; + + const handleUnpairBuddy = () => { + Alert.alert( + 'Расторгнуть связь?', + 'Вы уверены, что хотите прекратить связь с этим напарником? Вы сможете найти нового в любое время.', + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Да, расторгнуть', + style: 'destructive', + onPress: async () => { + try { + await AsyncStorage.removeItem('sober_path_sober_buddy'); + await AsyncStorage.removeItem('sober_path_support_today'); + setBuddy(null); + setSentSupportToday(false); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } catch (e) { + console.error(e); + } + } + } + ] + ); + }; + const filteredPosts = posts.filter(post => selectedCircle === 'all' || post.category === selectedCircle ); @@ -677,6 +772,61 @@ export default function CommunityPage() { )} + {/* Секция: Трезвый напарник */} + + Трезвый напарник 🤝 + + + {isSearchingBuddy ? ( + + + Анализируем прогресс сообщества и подбираем напарника... + + ) : buddy ? ( + + + + + + {buddy.name} + + {buddy.soberDays} дней трезвости + + + + + «{buddy.lastStatus}» + + + + + + + + + + + {sentSupportToday ? "Импульс поддержки отправлен" : "Отправить импульс поддержки (+15 Кармы)"} + + + + ) : ( + + + Проходить путь трезвости с напарником легче и эффективнее! Делитесь опытом, поддерживайте друг друга в сложные моменты и увеличивайте Карму. + + + + Найти трезвого напарника + + + )} + + Лента поддержки @@ -1424,7 +1574,7 @@ const styles = StyleSheet.create({ borderRadius: 8, gap: 2, }, - mentorBadge: { + authorMentorBadge: { backgroundColor: '#FFF8E1', borderWidth: 1, borderColor: '#FFD54F', @@ -1589,7 +1739,7 @@ const styles = StyleSheet.create({ marginBottom: 15, lineHeight: 20 }, - pulseContainer: { + onlinePulseContainer: { flexDirection: 'row', alignItems: 'center', marginHorizontal: 20, @@ -1603,20 +1753,20 @@ const styles = StyleSheet.create({ shadowOpacity: 0.05, shadowRadius: 2, }, - pulseDotContainer: { + onlinePulseDotContainer: { width: 20, height: 20, justifyContent: 'center', alignItems: 'center', marginRight: 10, }, - pulseDot: { + onlinePulseDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#4CAF50', }, - pulseDotPing: { + onlinePulseDotPing: { position: 'absolute', width: 12, height: 12, @@ -1624,7 +1774,7 @@ const styles = StyleSheet.create({ backgroundColor: '#4CAF50', opacity: 0.4, }, - pulseText: { + onlinePulseText: { fontSize: 13, color: '#666', fontWeight: '500', @@ -1696,5 +1846,131 @@ const styles = StyleSheet.create({ color: '#999', textAlign: 'right', marginTop: 4, + }, + buddySectionContainer: { + marginHorizontal: 20, + marginBottom: 20, + }, + buddyLoadingContainer: { + backgroundColor: '#F8F9FA', + padding: 15, + borderRadius: 16, + alignItems: 'center', + borderWidth: 1, + borderColor: '#E0E0E0', + }, + buddyLoadingText: { + marginTop: 10, + fontSize: 13, + color: '#666', + textAlign: 'center', + }, + buddyCard: { + backgroundColor: 'white', + borderRadius: 16, + padding: 15, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + borderWidth: 1, + borderColor: '#E5F6ED', + }, + buddyHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + buddyAvatar: { + width: 48, + height: 48, + borderRadius: 24, + marginRight: 12, + backgroundColor: '#E5F6ED', + }, + buddyInfo: { + flex: 1, + }, + buddyNameRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 6, + marginBottom: 4, + }, + buddyName: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + }, + buddyDaysBadge: { + backgroundColor: '#E5F6ED', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + }, + buddyDaysText: { + fontSize: 10, + fontWeight: '600', + color: '#2E7D4A', + }, + buddyStatusRow: { + flexDirection: 'row', + alignItems: 'center', + }, + buddyStatus: { + fontSize: 12, + color: '#666', + fontStyle: 'italic', + flex: 1, + }, + unpairButton: { + padding: 6, + }, + supportButton: { + backgroundColor: '#2E7D4A', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + borderRadius: 12, + gap: 4, + }, + supportButtonSent: { + backgroundColor: '#A5D6A7', + }, + supportButtonText: { + color: 'white', + fontSize: 13, + fontWeight: '600', + }, + buddyEmptyCard: { + backgroundColor: '#F1F8F4', + borderRadius: 16, + padding: 20, + alignItems: 'center', + borderWidth: 1, + borderColor: '#C8E6C9', + }, + buddyEmptyText: { + fontSize: 13, + color: '#2E7D4A', + textAlign: 'center', + lineHeight: 18, + marginBottom: 15, + }, + findBuddyButton: { + backgroundColor: '#2E7D4A', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 12, + }, + findBuddyButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', } }); diff --git a/app/(tabs)/courses.tsx b/app/(tabs)/courses.tsx index 9c8d885..f876574 100644 --- a/app/(tabs)/courses.tsx +++ b/app/(tabs)/courses.tsx @@ -1,5 +1,5 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { View, Text, @@ -8,9 +8,12 @@ import { TouchableOpacity, Dimensions, Modal, - Platform + Platform, + Alert } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; import { LinearGradient } from 'expo-linear-gradient'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { FadeInUp, FadeInRight, useSharedValue, useAnimatedStyle, withSpring, withTiming, withSequence } from 'react-native-reanimated'; @@ -21,14 +24,113 @@ import { modernTherapeuticTechniques, microTechniques } from '../../services/the const { width: screenWidth } = Dimensions.get('window'); -const CourseCard = ({ course, onPress }: { course: MicroCourse, onPress: () => void }) => ( +interface QuizQuestion { + question: string; + options: string[]; + correctAnswer: number; +} + +const COURSE_QUIZZES: Record = { + 'sobriety-fundamentals': [ + { + question: 'Что в первую очередь меняет зависимость в головном мозге?', + options: ['Систему вознаграждения (дофаминовые пути)', 'Зрительную кору', 'Мозжечок'], + correctAnswer: 0 + }, + { + question: 'На какие три стадии делится цикл срыва?', + options: ['Физическая, химическая, биологическая', 'Эмоциональная, психологическая, физическая', 'Начальная, средняя, финальная'], + correctAnswer: 1 + }, + { + question: 'Какое главное правило первых суток трезвости?', + options: ['Не пить только сегодня', 'Дать обещание больше никогда в жизни не пить', 'Избегать любого общения'], + correctAnswer: 0 + } + ], + 'trigger-management': [ + { + question: 'Что означает буква L в методе HALT?', + options: ['Late (Поздний)', 'Lonely (Одинокий)', 'Loud (Шумный)'], + correctAnswer: 1 + }, + { + question: 'Что такое "Urge Surfing" (Серфинг по тяге)?', + options: ['Игнорирование существования тяги', 'Наблюдение за волной тяги в теле без сопротивления и действий', 'Борьба с мыслями силой воли'], + correctAnswer: 1 + }, + { + question: 'К какому типу триггеров относится гнев или тревога?', + options: ['Внешние триггеры', 'Внутренние триггеры', 'Социальные триггеры'], + correctAnswer: 1 + } + ], + 'emotional-regulation': [ + { + question: 'Для чего человеку нужны эмоции по мнению психотерапевтов?', + options: ['Они не нужны, их нужно полностью контролировать', 'Это сигналы о наших потребностях и границах', 'Для усложнения общения'], + correctAnswer: 1 + }, + { + question: 'Что делает техника заземления 5-4-3-2-1?', + options: ['Переключает мозг с режима паники на наблюдение за реальностью', 'Заставляет уснуть', 'Помогает вспомнить прошлое'], + correctAnswer: 0 + }, + { + question: 'Как лучше всего утилизировать энергию сильного гнева?', + options: ['Подавить его в себе', 'Направить в полезную физическую активность (спорт, уборка)', 'Высказать все обидчику в резкой форме'], + correctAnswer: 1 + } + ], + 'social-recovery': [ + { + question: 'Что защищают ваши личные границы в выздоровлении?', + options: ['Вашу гордость', 'Вашу трезвость и душевный покой', 'Ваши финансы'], + correctAnswer: 1 + }, + { + question: 'Обязаны ли вы подробно объяснять окружающим причину отказа от алкоголя?', + options: ['Да, всегда нужно приводить веские аргументы', 'Нет, достаточно сказать простое и уверенное "нет"', 'Обязаны только близким друзьям'], + correctAnswer: 1 + }, + { + question: 'Где лучше всего искать новое поддерживающее окружение?', + options: ['В прежних компаниях собутыльников', 'В группах поддержки и трезвом сообществе', 'В одиночестве дома'], + correctAnswer: 1 + } + ], + 'guilt-work': [ + { + question: 'В чем ключевая разница между виной и стыдом?', + options: ['Вина говорит "я совершил плохой поступок", стыд говорит "я плохой"', 'Вина - это сильное чувство, а стыд - слабое', 'Разницы нет, это синонимы'], + correctAnswer: 0 + }, + { + question: 'Что является наиболее действенным способом возмещения ущерба в трезвости?', + options: ['Постоянно извиняться на словах', 'Реальные поступки изменений и сохранение трезвости сегодня', 'Дарить дорогие подарки'], + correctAnswer: 1 + }, + { + question: 'Какое из этих чувств деструктивно и ведет к самоизоляции?', + options: ['Здоровая вина', 'Стыд', 'Грусть'], + correctAnswer: 1 + } + ] +}; + +const CourseCard = ({ course, onPress, isCompleted }: { course: MicroCourse, onPress: () => void, isCompleted?: boolean }) => ( - {course.title} + + {course.title} + {isCompleted && ( + + )} + {course.lessons.length} уроков @@ -37,7 +139,7 @@ const CourseCard = ({ course, onPress }: { course: MicroCourse, onPress: () => v {course.lessons.reduce((s, l) => s + l.duration, 0)} мин - + @@ -65,6 +167,28 @@ export default function CoursesPage() { const [selectedLesson, setSelectedLesson] = useState(null); const [selectedTechnique, setSelectedTechnique] = useState(null); const [showConfetti, setShowConfetti] = useState(false); + + // States for interactive course quizzes + const [completedCourses, setCompletedCourses] = useState([]); + const [activeQuizCourseId, setActiveQuizCourseId] = useState(null); + const [currentQuestionIdx, setCurrentQuestionIdx] = useState(0); + const [selectedAnswerIdx, setSelectedAnswerIdx] = useState(null); + const [quizScore, setQuizScore] = useState(0); + const [showQuizResult, setShowQuizResult] = useState(false); + + useEffect(() => { + const loadCompletedCourses = async () => { + try { + const stored = await AsyncStorage.getItem('sober_path_completed_courses'); + if (stored) { + setCompletedCourses(JSON.parse(stored)); + } + } catch (e) { + console.error(e); + } + }; + loadCompletedCourses(); + }, []); const confettiOpacity = useSharedValue(0); const confettiScale = useSharedValue(0); @@ -83,7 +207,12 @@ export default function CoursesPage() { Обучающие программы {courses.map(course => ( - setSelectedCourse(course)} /> + setSelectedCourse(course)} + /> ))} @@ -163,6 +292,44 @@ export default function CoursesPage() { ))} + + {selectedCourse && COURSE_QUIZZES[selectedCourse.id] && ( + + {completedCourses.includes(selectedCourse.id) ? ( + + + Курс успешно пройден! 🎉 + Вы сдали итоговый тест на отлично. + { + setActiveQuizCourseId(selectedCourse.id); + setCurrentQuestionIdx(0); + setSelectedAnswerIdx(null); + setQuizScore(0); + setShowQuizResult(false); + }} + > + Пройти тест заново + + + ) : ( + { + setActiveQuizCourseId(selectedCourse.id); + setCurrentQuestionIdx(0); + setSelectedAnswerIdx(null); + setQuizScore(0); + setShowQuizResult(false); + }} + > + + 📝 Пройти итоговый тест курса + + )} + + )} @@ -256,6 +423,174 @@ export default function CoursesPage() { + + {/* Итоговый тест курса Modal */} + + + + { + setActiveQuizCourseId(null); + setShowQuizResult(false); + }}> + + + Итоговый тест курса + + + + {activeQuizCourseId && (() => { + const quizQuestions = COURSE_QUIZZES[activeQuizCourseId] || []; + const currentQuestion = quizQuestions[currentQuestionIdx]; + + return ( + + {!showQuizResult && currentQuestion ? ( + + Вопрос {currentQuestionIdx + 1} из {quizQuestions.length} + + {currentQuestion.question} + + + {currentQuestion.options.map((option, idx) => { + let optionStyle: any[] = [styles.quizOptionButton]; + let iconName = "radio-button-unchecked"; + let iconColor = "#888"; + + if (selectedAnswerIdx !== null) { + if (idx === currentQuestion.correctAnswer) { + optionStyle.push(styles.quizOptionCorrect); + iconName = "check-circle"; + iconColor = "#2E7D4A"; + } else if (idx === selectedAnswerIdx) { + optionStyle.push(styles.quizOptionIncorrect); + iconName = "cancel"; + iconColor = "#D32F2F"; + } else { + optionStyle.push(styles.quizOptionDisabled); + } + } + + return ( + { + setSelectedAnswerIdx(idx); + const isCorrect = idx === currentQuestion.correctAnswer; + if (isCorrect) { + setQuizScore(prev => prev + 1); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } else { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + }} + > + + {option} + + ); + })} + + + {selectedAnswerIdx !== null && ( + { + if (currentQuestionIdx + 1 < quizQuestions.length) { + setCurrentQuestionIdx(prev => prev + 1); + setSelectedAnswerIdx(null); + } else { + setShowQuizResult(true); + } + }} + > + + {currentQuestionIdx + 1 < quizQuestions.length ? "Следующий вопрос" : "Показать результат"} + + + + )} + + ) : ( + + {quizScore === quizQuestions.length ? ( + + + Поздравляем! 🎉 + Вы ответили правильно на все вопросы! + {quizScore} из {quizQuestions.length} + + { + // Save to AsyncStorage + const updated = Array.from(new Set([...completedCourses, activeQuizCourseId])); + try { + await AsyncStorage.setItem('sober_path_completed_courses', JSON.stringify(updated)); + setCompletedCourses(updated); + } catch (e) { + console.error(e); + } + + // Trigger Confetti Celebration + setShowConfetti(true); + confettiOpacity.value = withTiming(1, { duration: 200 }); + confettiScale.value = withSequence( + withSpring(1.2), + withTiming(1, { duration: 300 }) + ); + setTimeout(() => { + confettiOpacity.value = withTiming(0, { duration: 500 }); + confettiScale.value = withTiming(0, { duration: 500 }); + setTimeout(() => { + setShowConfetti(false); + setActiveQuizCourseId(null); + setSelectedCourse(null); + }, 600); + }, 2000); + }} + > + Забрать награду (+100 XP) + + + ) : ( + + + Почти получилось! + Для успешного прохождения нужно ответить на все вопросы правильно. + {quizScore} из {quizQuestions.length} + + { + setCurrentQuestionIdx(0); + setSelectedAnswerIdx(null); + setQuizScore(0); + setShowQuizResult(false); + }} + > + Попробовать заново + + + )} + + )} + + ); + })()} + + {showConfetti && ( + + + 🏆 + Курс завершен! + Новый бейдж разблокирован! + + + )} + + ); } @@ -349,5 +684,188 @@ const styles = StyleSheet.create({ celebrationSub: { fontSize: 16, color: '#666', + }, + quizLaunchContainer: { + margin: 20, + marginTop: 25, + }, + startQuizButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + startQuizButtonText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, + quizPassedCard: { + backgroundColor: '#E8F5E8', + padding: 20, + borderRadius: 20, + alignItems: 'center', + borderWidth: 1, + borderColor: '#C8E6C9', + }, + quizPassedTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#2E7D4A', + marginBottom: 4, + }, + quizPassedSub: { + fontSize: 13, + color: '#555', + textAlign: 'center', + marginBottom: 12, + }, + retakeQuizButton: { + backgroundColor: 'white', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 12, + borderWidth: 1, + borderColor: '#2E7D4A', + }, + retakeQuizButtonText: { + color: '#2E7D4A', + fontSize: 13, + fontWeight: 'bold', + }, + quizQuestionContainer: { + backgroundColor: 'white', + margin: 20, + padding: 20, + borderRadius: 20, + elevation: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + borderWidth: 1, + borderColor: '#F0F0F0', + }, + quizProgressText: { + fontSize: 12, + color: '#666', + fontWeight: '600', + marginBottom: 10, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + quizQuestionTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + lineHeight: 24, + marginBottom: 20, + }, + quizOptionsList: { + gap: 12, + marginBottom: 25, + }, + quizOptionButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F8F9FA', + padding: 15, + borderRadius: 14, + borderWidth: 1, + borderColor: '#E0E0E0', + }, + quizOptionText: { + fontSize: 14, + color: '#444', + flex: 1, + lineHeight: 18, + }, + quizOptionCorrect: { + backgroundColor: '#E8F5E8', + borderColor: '#81C784', + }, + quizOptionIncorrect: { + backgroundColor: '#FFEBEE', + borderColor: '#E57373', + }, + quizOptionDisabled: { + opacity: 0.5, + }, + quizNextButton: { + backgroundColor: '#2E7D4A', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 16, + gap: 6, + }, + quizNextButtonText: { + color: 'white', + fontSize: 15, + fontWeight: 'bold', + }, + quizResultContainer: { + padding: 20, + }, + quizResultCard: { + backgroundColor: 'white', + borderRadius: 24, + padding: 30, + alignItems: 'center', + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + borderWidth: 1, + borderColor: '#F0F0F0', + }, + quizResultTitle: { + fontSize: 22, + fontWeight: 'bold', + color: '#2E7D4A', + marginBottom: 6, + }, + quizResultSubTitle: { + fontSize: 14, + color: '#666', + textAlign: 'center', + lineHeight: 20, + marginBottom: 15, + }, + quizScoreResult: { + fontSize: 36, + fontWeight: 'bold', + color: '#333', + marginBottom: 25, + }, + quizCompleteButton: { + backgroundColor: '#2E7D4A', + paddingHorizontal: 25, + paddingVertical: 14, + borderRadius: 16, + }, + quizCompleteButtonText: { + color: 'white', + fontSize: 15, + fontWeight: 'bold', + }, + quizRetryButton: { + backgroundColor: '#FF6B6B', + paddingHorizontal: 25, + paddingVertical: 14, + borderRadius: 16, + }, + quizRetryButtonText: { + color: 'white', + fontSize: 15, + fontWeight: 'bold', } }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index b3eb9cf..3fdf880 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1407,6 +1407,48 @@ const styles = StyleSheet.create({ color: 'white', fontWeight: 'bold', fontSize: 16 + }, + briefingFocus: { + fontSize: 16, + fontWeight: 'bold', + color: '#2E7D4A', + marginBottom: 15, + textAlign: 'center' + }, + briefingSectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginTop: 15, + marginBottom: 10 + }, + briefingItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: 8, + paddingHorizontal: 5 + }, + briefingText: { + fontSize: 14, + color: '#555', + flex: 1, + lineHeight: 20 + }, + motivationQuoteBox: { + backgroundColor: '#F1F8F4', + padding: 15, + borderRadius: 10, + marginTop: 20, + borderLeftWidth: 4, + borderLeftColor: '#2E7D4A', + }, + briefingMotivation: { + fontSize: 14, + color: '#2E7D4A', + fontStyle: 'italic', + lineHeight: 20, + textAlign: 'center' } }); diff --git a/assets/screenshots/ai_energy_forecast.png b/assets/screenshots/ai_energy_forecast.png new file mode 100644 index 0000000..20ecbc7 Binary files /dev/null and b/assets/screenshots/ai_energy_forecast.png differ diff --git a/assets/screenshots/community_buddy.png b/assets/screenshots/community_buddy.png new file mode 100644 index 0000000..03a8c5e Binary files /dev/null and b/assets/screenshots/community_buddy.png differ diff --git a/assets/screenshots/courses_list.png b/assets/screenshots/courses_list.png new file mode 100644 index 0000000..7f41e05 Binary files /dev/null and b/assets/screenshots/courses_list.png differ diff --git a/package-lock.json b/package-lock.json index fcfd9a7..5032482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sober-path", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sober-path", - "version": "1.16.0", + "version": "1.17.0", "dependencies": { "@expo-google-fonts/inter": "^0.4.1", "@expo/metro-runtime": "~5.0.5", diff --git a/package.json b/package.json index 11d9779..1f75ab8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sober-path", "main": "expo-router/entry", - "version": "1.17.0", + "version": "1.18.0", "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", diff --git a/services/AICoachService.ts b/services/AICoachService.ts index 0c11103..2fcc48b 100644 --- a/services/AICoachService.ts +++ b/services/AICoachService.ts @@ -636,7 +636,7 @@ export class AICoachService { ? 'Ваше состояние улучшается.' : 'Мы продолжаем работу.', profile, - sleepAnalysis + sleepAnalysis: sleepData }; } @@ -1008,17 +1008,19 @@ export class AICoachService { briefing = lowMoodTemplates[Math.floor(Math.random() * lowMoodTemplates.length)]; } + const currentQuickTips = [...quickTips]; // Интеграция анализа сна в брифинг const sleepData = await this.analyzeSleepPatterns(); if (sleepData.averageScore < 60) { - briefing.quickTips.push('Уделите внимание сну: плохой сон — частый триггер срыва.'); + currentQuickTips.push('Уделите внимание сну: плохой сон — частый триггер срыва.'); } - if (soberDays > 30 && !briefing.plan.includes("Поделиться опытом в сообществе")) { - briefing.plan.push("Поделиться опытом в сообществе"); + const currentPlan = [...briefing.plan]; + if (soberDays > 30 && !currentPlan.includes("Поделиться опытом в сообществе")) { + currentPlan.push("Поделиться опытом в сообществе"); } - return { ...briefing, quickTips }; + return { ...briefing, plan: currentPlan, quickTips: currentQuickTips }; } private static extractTopics(message: string): string[] { diff --git a/services/communityService.ts b/services/communityService.ts index 9d794cf..d0db79e 100644 --- a/services/communityService.ts +++ b/services/communityService.ts @@ -84,10 +84,23 @@ export class CommunityService { /** * Получить текущее количество очков Кармы (Очков поддержки) пользователя. */ - static async getUserKarma(): Promise { + static async getUserKarma(userName?: string): Promise { try { const stored = await AsyncStorage.getItem(KARMA_STORAGE_KEY); - return stored ? parseInt(stored, 10) : 0; + if (!stored) return 0; + try { + const karmaMap = JSON.parse(stored); + if (typeof karmaMap === 'object' && karmaMap !== null) { + if (userName) { + return karmaMap[userName] || 0; + } + return karmaMap['Вы'] || karmaMap['User'] || karmaMap['Пользователь'] || 0; + } + } catch { + // не JSON, значит просто число + } + if (userName) return 0; + return parseInt(stored, 10) || 0; } catch (e) { return 0; } @@ -100,7 +113,17 @@ export class CommunityService { try { const current = await this.getUserKarma(); const updated = current + points; - await AsyncStorage.setItem(KARMA_STORAGE_KEY, updated.toString()); + + const stored = await AsyncStorage.getItem(KARMA_STORAGE_KEY); + let karmaMap: any = {}; + try { + if (stored) karmaMap = JSON.parse(stored); + if (typeof karmaMap !== 'object' || karmaMap === null) karmaMap = {}; + } catch { + karmaMap = {}; + } + karmaMap['Вы'] = updated; + await AsyncStorage.setItem(KARMA_STORAGE_KEY, JSON.stringify(karmaMap)); return updated; } catch (e) { return 0; @@ -713,15 +736,6 @@ export class CommunityService { } } - static async getUserKarma(userName: string): Promise { - try { - const stored = await AsyncStorage.getItem(KARMA_STORAGE_KEY); - const karmaMap = stored ? JSON.parse(stored) : {}; - return karmaMap[userName] || 0; - } catch (e) { - return 0; - } - } private static async updateUserKarma(userName: string, points: number): Promise { try {