diff --git a/back/bots/migrations/0035_deck_flashcard.py b/back/bots/migrations/0035_deck_flashcard.py new file mode 100644 index 0000000..ac9d94b --- /dev/null +++ b/back/bots/migrations/0035_deck_flashcard.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.3 on 2026-04-16 05:30 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0034_profile_oauth_email'), + ] + + operations = [ + migrations.CreateModel( + name='Deck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deck_id', models.UUIDField(default=uuid.uuid4, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='decks', to='bots.chat')), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='decks', to='bots.profile')), + ], + ), + migrations.CreateModel( + name='Flashcard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('flashcard_id', models.UUIDField(default=uuid.uuid4, unique=True)), + ('front', models.TextField()), + ('back', models.TextField()), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deck', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flashcards', to='bots.deck')), + ], + options={ + 'indexes': [models.Index(fields=['deck', 'order'], name='bots_flashc_deck_id_a95ed4_idx')], + 'constraints': [models.UniqueConstraint(fields=('deck', 'order'), name='unique_flashcard_order_per_deck')], + }, + ), + ] diff --git a/back/bots/models/__init__.py b/back/bots/models/__init__.py index e5f1049..64e2bd8 100644 --- a/back/bots/models/__init__.py +++ b/back/bots/models/__init__.py @@ -6,6 +6,7 @@ from .usage_limit_hit import UsageLimitHit from .ai_model import AiModel from .device import Device +from .flashcard import Deck, Flashcard __all__ = [ 'Chat', @@ -16,5 +17,7 @@ 'UsageLimitHit', 'AiModel', 'Device', - 'RevenueCatWebhookEvent' + 'RevenueCatWebhookEvent', + 'Deck', + 'Flashcard', ] diff --git a/back/bots/models/chat.py b/back/bots/models/chat.py index c5ded61..7b9c4f6 100644 --- a/back/bots/models/chat.py +++ b/back/bots/models/chat.py @@ -13,6 +13,7 @@ from .profile import Profile from .bot import Bot from .ai_model import AiModel +from .flashcard import Deck, Flashcard logger = logging.getLogger(__name__) @@ -114,17 +115,82 @@ def web_search(query: str) -> str: logger.error(f"🔍 WEB_SEARCH_ERROR: {str(e)}") return f"Error during search: {str(e)}" + # Create flashcard tools outside the web_search if-block for reuse + @tool + def create_flashcard_deck(name: str, description: str = "", flashcards: list = None) -> str: + """Create a new flashcard deck with flashcards. Use this when the user wants to create flashcards for studying. + + Args: + name: The name of the deck (e.g., "Biology Test Terms") + description: Optional description of the deck + flashcards: Optional list of flashcards, each with 'front' and 'back' keys + """ + logger.info(f"🃏 CREATE_FLASHCARD_DECK_TOOL_INVOKED: name='{name}'") + try: + deck = Deck.objects.create( + profile=self.profile, + chat=self, + name=name, + description=description or "" + ) + created_cards = 0 + if flashcards: + for i, card in enumerate(flashcards): + Flashcard.objects.create( + deck=deck, + front=card.get('front', ''), + back=card.get('back', ''), + order=i + ) + created_cards += 1 + logger.info(f"🃏 CREATE_FLASHCARD_DECK_SUCCESS: deck_id={deck.deck_id}, cards={created_cards}") + return f"Created deck '{name}' with {created_cards} flashcards. Deck ID: {deck.deck_id}" + except Exception as e: + logger.error(f"🃏 CREATE_FLASHCARD_DECK_ERROR: {str(e)}") + return f"Error creating deck: {str(e)}" + + @tool + def create_flashcard(deck_name: str, front: str, back: str) -> str: + """Add a single flashcard to an existing deck or create a new deck. Use this when the user wants to add flashcards to study. + + Args: + deck_name: The name of the deck to add the card to + front: The front of the flashcard (question/term) + back: The back of the flashcard (answer/definition) + """ + logger.info(f"🃏 CREATE_FLASHCARD_TOOL_INVOKED: deck_name='{deck_name}'") + try: + deck = Deck.objects.filter(profile=self.profile, name=deck_name).first() + if not deck: + deck = Deck.objects.create( + profile=self.profile, + chat=self, + name=deck_name, + description="" + ) + max_order = Flashcard.objects.filter(deck=deck).aggregate(models.Max('order'))['order__max'] or -1 + Flashcard.objects.create( + deck=deck, + front=front, + back=back, + order=max_order + 1 + ) + logger.info(f"🃏 CREATE_FLASHCARD_SUCCESS: deck={deck.name}") + return f"Added flashcard to deck '{deck_name}'. Deck ID: {deck.deck_id}" + except Exception as e: + logger.error(f"🃏 CREATE_FLASHCARD_ERROR: {str(e)}") + return f"Error creating flashcard: {str(e)}" # Create chat model with tool binding chat_model = ChatBedrock(model_id=self.ai.model_id) - tools = [web_search] + tools = [web_search, create_flashcard_deck, create_flashcard] # Bind tools to the model for proper tool calling model_with_tools = chat_model.bind_tools(tools) # Use the full message_list which already contains conversation history logger.info(f"Invoking agent with full context ({len(message_list)} messages)") - logger.info("🤖 AGENT_INVOKE_START: web_search tool available") + logger.info("🤖 AGENT_INVOKE_START: web_search and flashcard tools available") # Build and run the agent loop manually with full conversation context messages = message_list @@ -155,6 +221,10 @@ def web_search(query: str) -> str: # Execute the tool if tool_name == "web_search": tool_result = web_search.invoke(tool_args) + elif tool_name == "create_flashcard_deck": + tool_result = create_flashcard_deck.invoke(tool_args) + elif tool_name == "create_flashcard": + tool_result = create_flashcard.invoke(tool_args) else: tool_result = f"Unknown tool: {tool_name}" diff --git a/back/bots/models/flashcard.py b/back/bots/models/flashcard.py new file mode 100644 index 0000000..48e371b --- /dev/null +++ b/back/bots/models/flashcard.py @@ -0,0 +1,30 @@ +from django.db import models +import uuid + + +class Deck(models.Model): + deck_id = models.UUIDField(default=uuid.uuid4, unique=True) + profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='decks') + chat = models.ForeignKey('Chat', on_delete=models.SET_NULL, null=True, blank=True, related_name='decks') + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class Flashcard(models.Model): + flashcard_id = models.UUIDField(default=uuid.uuid4, unique=True) + deck = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name='flashcards') + front = models.TextField() + back = models.TextField() + order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["deck", "order"], name="unique_flashcard_order_per_deck") + ] + indexes = [ + models.Index(fields=["deck", "order"]) + ] \ No newline at end of file diff --git a/back/bots/serializers/__init__.py b/back/bots/serializers/__init__.py index 1c626f5..1ce6e5d 100644 --- a/back/bots/serializers/__init__.py +++ b/back/bots/serializers/__init__.py @@ -4,6 +4,7 @@ from .ai_model_serializer import AiModelSerializer from .chat_serializer import ChatSerializer, ChatListSerializer from .message_serializer import MessageSerializer +from .flashcard_serializer import FlashcardSerializer, DeckSerializer, DeckListSerializer __all__ = [ 'BotSerializer', @@ -14,4 +15,7 @@ 'ChatSerializer', 'ChatListSerializer', 'MessageSerializer', + 'FlashcardSerializer', + 'DeckSerializer', + 'DeckListSerializer', ] \ No newline at end of file diff --git a/back/bots/serializers/flashcard_serializer.py b/back/bots/serializers/flashcard_serializer.py new file mode 100644 index 0000000..dd9856a --- /dev/null +++ b/back/bots/serializers/flashcard_serializer.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from bots.models import Deck, Flashcard + + +class FlashcardSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Flashcard + fields = ['id', 'flashcard_id', 'deck', 'front', 'back', 'order', 'created_at', 'updated_at'] + + +class DeckSerializer(serializers.HyperlinkedModelSerializer): + flashcards = FlashcardSerializer(many=True, read_only=True) + card_count = serializers.SerializerMethodField() + + class Meta: + model = Deck + fields = ['id', 'deck_id', 'profile', 'chat', 'name', 'description', 'flashcards', 'card_count', 'created_at', 'updated_at'] + + def get_card_count(self, obj): + return obj.flashcards.count() + + +class DeckListSerializer(serializers.HyperlinkedModelSerializer): + card_count = serializers.SerializerMethodField() + + class Meta: + model = Deck + fields = ['id', 'deck_id', 'name', 'description', 'card_count', 'created_at', 'updated_at'] + + def get_card_count(self, obj): + return obj.flashcards.count() \ No newline at end of file diff --git a/back/bots/viewsets/__init__.py b/back/bots/viewsets/__init__.py index c711ddd..83956e7 100644 --- a/back/bots/viewsets/__init__.py +++ b/back/bots/viewsets/__init__.py @@ -1,3 +1,4 @@ from .chat_viewset import ChatViewSet +from .flashcard_viewset import DeckViewSet, FlashcardViewSet -__all__ = ['ChatViewSet'] \ No newline at end of file +__all__ = ['ChatViewSet', 'DeckViewSet', 'FlashcardViewSet'] \ No newline at end of file diff --git a/back/bots/viewsets/flashcard_viewset.py b/back/bots/viewsets/flashcard_viewset.py new file mode 100644 index 0000000..a17cd11 --- /dev/null +++ b/back/bots/viewsets/flashcard_viewset.py @@ -0,0 +1,105 @@ +from rest_framework import viewsets +from django.db.models import Count, Max +import uuid +from bots.models import Deck, Flashcard, Profile +from bots.permissions import IsOwner +from bots.serializers import FlashcardSerializer, DeckSerializer, DeckListSerializer + + +class FlashcardViewSet(viewsets.ModelViewSet): + permission_classes = [IsOwner] + serializer_class = FlashcardSerializer + queryset = Flashcard.objects.all() + + def get_queryset(self): + deck_id = self.kwargs['deck_pk'] + + try: + deck_uuid = uuid.UUID(deck_id) + deck = Deck.objects.get(deck_id=deck_uuid) + except ValueError: + deck = Deck.objects.get(id=deck_id) + + self.check_object_permissions(self.request, deck) + + return Flashcard.objects.filter(deck=deck).order_by('order') + + def perform_create(self, serializer): + deck_id = self.kwargs['deck_pk'] + try: + deck_uuid = uuid.UUID(deck_id) + deck = Deck.objects.get(deck_id=deck_uuid) + except ValueError: + deck = Deck.objects.get(id=deck_id) + + max_order = Flashcard.objects.filter(deck=deck).aggregate(Max('order'))['order__max'] or -1 + serializer.save(deck=deck, order=max_order + 1) + + +class DeckViewSet(viewsets.ModelViewSet): + permission_classes = [IsOwner] + serializer_class = DeckSerializer + queryset = Deck.objects.all() + + def get_queryset(self): + user = self.request.user + profile_id = self.request.query_params.get('profileId') + + queryset = Deck.objects.filter(profile__user=user) + + if profile_id: + try: + profile_uuid = uuid.UUID(profile_id) + queryset = queryset.filter(profile__profile_id=profile_uuid) + except ValueError: + queryset = queryset.none() + + return queryset.annotate(card_count=Count('flashcards')).order_by('-created_at') + + def get_serializer_class(self): + if self.action == 'list': + return DeckListSerializer + return DeckSerializer + + def get_object(self): + lookup_field_value = self.kwargs[self.lookup_field] + + try: + deck_uuid = uuid.UUID(lookup_field_value) + deck = Deck.objects.get(deck_id=deck_uuid) + except ValueError: + deck = Deck.objects.get(id=lookup_field_value) + + self.check_object_permissions(self.request, deck) + return deck + + def perform_create(self, serializer): + user = self.request.user + profile_id = self.request.data.get('profile') + chat_id = self.request.data.get('chat') + + if profile_id: + try: + profile_uuid = uuid.UUID(profile_id) + profile = Profile.objects.get(profile_id=profile_uuid, user=user) + except (ValueError, Profile.DoesNotExist): + profile = Profile.objects.filter(user=user).first() + else: + profile = Profile.objects.filter(user=user).first() + + chat = None + if chat_id: + try: + chat_uuid = uuid.UUID(chat_id) + from bots.models import Chat + chat = Chat.objects.get(chat_id=chat_uuid, user=user) + except (ValueError, Chat.DoesNotExist): + chat = None + + serializer.save(profile=profile, chat=chat) + + def perform_update(self, serializer): + serializer.save() + + def perform_destroy(self, instance): + instance.delete() \ No newline at end of file diff --git a/back/server/urls.py b/back/server/urls.py index 6091d16..5e8d116 100644 --- a/back/server/urls.py +++ b/back/server/urls.py @@ -24,6 +24,7 @@ from bots.viewsets.bot_viewset import BotViewSet from bots.viewsets.ai_model_viewset import AiModelViewSet from bots.viewsets.device_viewset import DeviceViewSet +from bots.viewsets.flashcard_viewset import DeckViewSet, FlashcardViewSet from bots.views.get_chat_response import get_chat_response from bots.views.get_jwt import get_jwt, start_web_login from bots.views.user_account_view import DeleteUserAccountView, user_account_view @@ -42,10 +43,14 @@ router.register(r'bots', BotViewSet) router.register(r'ai_models', AiModelViewSet) router.register(r'devices', DeviceViewSet) +router.register(r'decks', DeckViewSet) chats_router = NestedDefaultRouter(router, r'chats', lookup='chat') chats_router.register(r'messages', MessageViewSet, basename='chat-messages') +decks_router = NestedDefaultRouter(router, r'decks', lookup='deck') +decks_router.register(r'flashcards', FlashcardViewSet, basename='deck-flashcards') + favicon_view = RedirectView.as_view(url='/static/favicon.ico', permanent=True) diff --git a/front/api/flashcards.ts b/front/api/flashcards.ts new file mode 100644 index 0000000..b0b1d60 --- /dev/null +++ b/front/api/flashcards.ts @@ -0,0 +1,163 @@ +import { apiClient } from "./apiClient"; + +export interface Flashcard { + id: number; + flashcard_id: string; + deck: number; + front: string; + back: string; + order: number; + created_at: string; + updated_at: string; +} + +export interface Deck { + id: number; + deck_id: string; + profile: string; + chat: string | null; + name: string; + description: string; + flashcards: Flashcard[]; + card_count: number; + created_at: string; + updated_at: string; +} + +export interface DeckListItem { + id: number; + deck_id: string; + name: string; + description: string; + card_count: number; + created_at: string; + updated_at: string; +} + +export const fetchDecks = async (profileId: string): Promise => { + const response = await apiClient( + `/decks.json?profileId=${profileId}`, + { method: "GET" } + ); + if (!response.ok || !response.data) { + return []; + } + return response.data; +}; + +export const fetchDeck = async (deckId: string): Promise => { + const response = await apiClient(`/decks/${deckId}.json`, { method: "GET" }); + if (!response.ok || !response.data) { + return null; + } + return response.data; +}; + +export const createDeck = async ( + name: string, + description: string, + profileId: string, + chatId?: string +): Promise => { + const response = await apiClient("/decks.json", { + method: "POST", + body: JSON.stringify({ + name, + description, + profile: profileId, + chat: chatId || null, + }), + }); + if (!response.ok || !response.data) { + return null; + } + return response.data; +}; + +export const updateDeck = async ( + deckId: string, + name: string, + description: string +): Promise => { + const response = await apiClient(`/decks/${deckId}.json`, { + method: "PUT", + body: JSON.stringify({ + name, + description, + }), + }); + if (!response.ok || !response.data) { + return null; + } + return response.data; +}; + +export const deleteDeck = async (deckId: string): Promise => { + const response = await apiClient(`/decks/${deckId}.json`, { + method: "DELETE", + }); + return response.ok; +}; + +export const fetchFlashcards = async (deckId: string): Promise => { + const response = await apiClient(`/decks/${deckId}/flashcards.json`, { + method: "GET", + }); + if (!response.ok || !response.data) { + return []; + } + return response.data; +}; + +export const createFlashcard = async ( + deckId: string, + front: string, + back: string +): Promise => { + const response = await apiClient(`/decks/${deckId}/flashcards.json`, { + method: "POST", + body: JSON.stringify({ + front, + back, + }), + }); + if (!response.ok || !response.data) { + return null; + } + return response.data; +}; + +export const updateFlashcard = async ( + deckId: string, + flashcardId: string, + front: string, + back: string +): Promise => { + const response = await apiClient( + `/decks/${deckId}/flashcards/${flashcardId}.json`, + { + method: "PUT", + body: JSON.stringify({ + front, + back, + }), + } + ); + if (!response.ok || !response.data) { + return null; + } + return response.data; +}; + +export const deleteFlashcard = async ( + deckId: string, + flashcardId: string +): Promise => { + const response = await apiClient( + `/decks/${deckId}/flashcards/${flashcardId}.json`, + { + method: "DELETE", + } + ); + return response.ok; +}; \ No newline at end of file diff --git a/front/app/_layout.tsx b/front/app/_layout.tsx index 78113a6..6755abe 100644 --- a/front/app/_layout.tsx +++ b/front/app/_layout.tsx @@ -226,6 +226,29 @@ export default function RootLayout() { ); }, + headerLeft: () => ( + { + const currentPath = pathname; + if (currentPath === "/" || currentPath.startsWith("/(tabs)")) { + router.push("/flashcards"); + } else if (currentPath === "/flashcards" || currentPath.startsWith("/flashcards")) { + router.replace("/"); + } else if (pathname?.includes("/flashcards")) { + router.replace("/"); + } else { + router.push("/flashcards"); + } + }} + > + + + ), headerRight: () => ( { @@ -249,6 +272,37 @@ export default function RootLayout() { headerTintColor: textColor, }} /> + + + + ([]); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newDeckName, setNewDeckName] = useState(""); + const [newDeckDescription, setNewDeckDescription] = useState(""); + + const getProfileId = useCallback(async () => { + const profileData = await AsyncStorage.getItem("selectedProfile"); + if (profileData) { + const profile = JSON.parse(profileData); + return profile.profile_id; + } + return null; + }, []); + + const refresh = useCallback(async () => { + setRefreshing(true); + try { + const profileId = await getProfileId(); + if (!profileId) { + setRefreshing(false); + return; + } + const data = await fetchDecks(profileId); + setDecks(data || []); + } catch (error) { + Sentry.captureException(error); + } finally { + setRefreshing(false); + setLoading(false); + } + }, [getProfileId]); + + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]) + ); + + const handleCreateDeck = async () => { + if (!newDeckName.trim()) { + Alert.alert("Error", "Please enter a deck name"); + return; + } + try { + const profileId = await getProfileId(); + if (!profileId) { + return; + } + await createDeck(newDeckName.trim(), newDeckDescription.trim(), profileId); + setShowCreateModal(false); + setNewDeckName(""); + setNewDeckDescription(""); + refresh(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to create deck"); + } + }; + + const handleDeckPress = (deck: DeckListItem) => { + if (process.env.EXPO_OS === "ios") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + router.push({ + pathname: "/flashcards/deck", + params: { deckId: deck.deck_id, title: deck.name }, + }); + }; + + if (showCreateModal) { + return ( + + + Create New Deck + + + + { + setShowCreateModal(false); + setNewDeckName(""); + setNewDeckDescription(""); + }} + > + Cancel + + + Create + + + + + ); + } + + return ( + + setShowCreateModal(true)}> + + + + {loading ? ( + + ) : ( + item.deck_id} + refreshControl={ + + } + renderItem={({ item }) => ( + handleDeckPress(item)} + > + + + {item.name} + + + {item.card_count} cards + + + {item.description ? ( + + {item.description} + + ) : null} + + )} + ListEmptyComponent={ + + + No flashcard decks yet + + + Tap + to create your first deck + + + } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flex: 1, + marginHorizontal: 10, + }, + itemContainer: { + padding: 12, + borderBottomWidth: 1, + borderBottomColor: "#ccc", + }, + itemContent: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + deckName: { + fontSize: 16, + fontWeight: "600", + flex: 1, + }, + cardCount: { + fontSize: 14, + color: "#888", + marginLeft: 10, + }, + description: { + fontSize: 14, + color: "#666", + marginTop: 4, + }, + fab: { + position: "absolute", + bottom: 30, + right: 30, + backgroundColor: "#03465b", + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + elevation: 5, + zIndex: 15, + }, + activityIndicator: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingTop: 100, + }, + emptyText: { + fontSize: 18, + color: "#888", + }, + emptySubtext: { + fontSize: 14, + color: "#666", + marginTop: 8, + }, + modalContainer: { + flex: 1, + padding: 20, + justifyContent: "center", + }, + modalTitle: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 20, + textAlign: "center", + }, + input: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + padding: 12, + fontSize: 16, + marginBottom: 12, + }, + descriptionInput: { + height: 80, + textAlignVertical: "top", + }, + modalButtons: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 20, + }, + cancelButton: { + flex: 1, + padding: 12, + marginRight: 10, + borderRadius: 8, + backgroundColor: "#ccc", + alignItems: "center", + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + }, + saveButton: { + flex: 1, + padding: 12, + marginLeft: 10, + borderRadius: 8, + backgroundColor: "#03465b", + alignItems: "center", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "white", + }, +}); \ No newline at end of file diff --git a/front/app/flashcards/cardEdit.tsx b/front/app/flashcards/cardEdit.tsx new file mode 100644 index 0000000..b94eacb --- /dev/null +++ b/front/app/flashcards/cardEdit.tsx @@ -0,0 +1,161 @@ +import { + StyleSheet, + View, + TextInput, + Alert, +} from "react-native"; +import { useRouter, useLocalSearchParams } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; +import { useState, useEffect } from "react"; +import * as Sentry from "@sentry/react-native"; + +import { updateFlashcard, deleteFlashcard } from "@/api/flashcards"; +import { PlatformPressable } from "@react-navigation/elements"; + +export default function CardEdit() { + const router = useRouter(); + const { deckId, flashcardId, front, back } = useLocalSearchParams<{ + deckId: string; + flashcardId: string; + front: string; + back: string; + }>(); + const [cardFront, setCardFront] = useState(front || ""); + const [cardBack, setCardBack] = useState(back || ""); + + useEffect(() => { + setCardFront(front || ""); + setCardBack(back || ""); + }, [front, back]); + + const handleSave = async () => { + if (!cardFront.trim() || !cardBack.trim()) { + Alert.alert("Error", "Please fill in both front and back of the card"); + return; + } + try { + await updateFlashcard(deckId, flashcardId, cardFront.trim(), cardBack.trim()); + router.back(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to update card"); + } + }; + + const handleDelete = () => { + Alert.alert( + "Delete Card", + "Are you sure you want to delete this card?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteFlashcard(deckId, flashcardId); + router.back(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to delete card"); + } + }, + }, + ] + ); + }; + + return ( + + + Front (question) + + + Back (answer) + + + + + Delete + + + Save + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 20, + }, + label: { + fontSize: 16, + fontWeight: "600", + marginBottom: 8, + marginTop: 16, + }, + input: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + cardInput: { + height: 120, + textAlignVertical: "top", + }, + buttons: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 30, + }, + deleteButton: { + flex: 1, + padding: 14, + marginRight: 10, + borderRadius: 8, + backgroundColor: "#d33", + alignItems: "center", + }, + deleteButtonText: { + fontSize: 16, + fontWeight: "600", + color: "white", + }, + saveButton: { + flex: 1, + padding: 14, + marginLeft: 10, + borderRadius: 8, + backgroundColor: "#03465b", + alignItems: "center", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "white", + }, +}); \ No newline at end of file diff --git a/front/app/flashcards/deck.tsx b/front/app/flashcards/deck.tsx new file mode 100644 index 0000000..6eadd4a --- /dev/null +++ b/front/app/flashcards/deck.tsx @@ -0,0 +1,451 @@ +import { + StyleSheet, + View, + FlatList, + RefreshControl, + ActivityIndicator, + TextInput, + Alert, +} from "react-native"; +import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; +import { IconSymbol } from "@/components/ui/IconSymbol"; +import * as Haptics from "expo-haptics"; +import { useCallback, useState } from "react"; +import * as Sentry from "@sentry/react-native"; + +import { + fetchDeck, + updateDeck, + deleteDeck, + fetchFlashcards, + createFlashcard, + deleteFlashcard, + Deck, + Flashcard, +} from "@/api/flashcards"; +import { PlatformPressable } from "@react-navigation/elements"; + +export default function DeckDetail() { + const router = useRouter(); + const { deckId, title } = useLocalSearchParams<{ deckId: string; title: string }>(); + const [deck, setDeck] = useState(null); + const [flashcards, setFlashcards] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [showAddCard, setShowAddCard] = useState(false); + const [newCardFront, setNewCardFront] = useState(""); + const [newCardBack, setNewCardBack] = useState(""); + + const refresh = useCallback(async () => { + setRefreshing(true); + try { + const deckData = await fetchDeck(deckId); + if (deckData) { + setDeck(deckData); + setFlashcards(deckData.flashcards || []); + setEditName(deckData.name); + setEditDescription(deckData.description); + } + } catch (error) { + Sentry.captureException(error); + } finally { + setRefreshing(false); + setLoading(false); + } + }, [deckId]); + + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]) + ); + + const handleSaveDeck = async () => { + if (!editName.trim()) { + Alert.alert("Error", "Deck name cannot be empty"); + return; + } + try { + await updateDeck(deckId, editName.trim(), editDescription.trim()); + setIsEditing(false); + refresh(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to update deck"); + } + }; + + const handleDeleteDeck = () => { + Alert.alert( + "Delete Deck", + "Are you sure you want to delete this deck? All cards will be lost.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteDeck(deckId); + router.back(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to delete deck"); + } + }, + }, + ] + ); + }; + + const handleAddCard = async () => { + if (!newCardFront.trim() || !newCardBack.trim()) { + Alert.alert("Error", "Please fill in both front and back of the card"); + return; + } + try { + await createFlashcard(deckId, newCardFront.trim(), newCardBack.trim()); + setShowAddCard(false); + setNewCardFront(""); + setNewCardBack(""); + refresh(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to add card"); + } + }; + + const handleDeleteCard = (flashcard: Flashcard) => { + Alert.alert( + "Delete Card", + "Are you sure you want to delete this card?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteFlashcard(deckId, flashcard.flashcard_id); + refresh(); + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to delete card"); + } + }, + }, + ] + ); + }; + + const handleStudyPress = () => { + router.push({ + pathname: "/flashcards/study", + params: { deckId, title: deck?.name }, + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (showAddCard) { + return ( + + + Add New Card + + + + { + setShowAddCard(false); + setNewCardFront(""); + setNewCardBack(""); + }} + > + Cancel + + + Add + + + + + ); + } + + if (isEditing) { + return ( + + + Edit Deck + + + + { + setIsEditing(false); + setEditName(deck?.name || ""); + setEditDescription(deck?.description || ""); + }} + > + Cancel + + + Save + + + + + ); + } + + return ( + + + setIsEditing(true)}> + + + + + + + Study + + + + {deck?.description ? ( + {deck.description} + ) : null} + + setShowAddCard(true)}> + + + + item.flashcard_id} + refreshControl={ + + } + renderItem={({ item, index }) => ( + + router.push({ + pathname: "/flashcards/cardEdit", + params: { + deckId, + flashcardId: item.flashcard_id, + front: item.front, + back: item.back, + }, + }) + } + onLongPress={() => handleDeleteCard(item)} + > + #{index + 1} + + {item.front} + + + )} + ListEmptyComponent={ + + No cards yet + + Tap + to add your first card + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 12, + borderBottomWidth: 1, + borderBottomColor: "#ccc", + }, + headerButton: { + padding: 8, + }, + studyButton: { + backgroundColor: "#03465b", + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + studyButtonText: { + color: "white", + fontWeight: "600", + }, + description: { + fontSize: 14, + color: "#666", + padding: 12, + paddingTop: 0, + }, + list: { + flex: 1, + marginHorizontal: 10, + }, + cardItem: { + flexDirection: "row", + padding: 12, + borderBottomWidth: 1, + borderBottomColor: "#ccc", + alignItems: "center", + }, + cardNumber: { + fontSize: 14, + color: "#888", + marginRight: 12, + width: 30, + }, + cardFront: { + flex: 1, + fontSize: 16, + }, + fab: { + position: "absolute", + bottom: 30, + right: 30, + backgroundColor: "#03465b", + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + elevation: 5, + zIndex: 15, + }, + activityIndicator: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingTop: 100, + }, + emptyText: { + fontSize: 18, + color: "#888", + }, + emptySubtext: { + fontSize: 14, + color: "#666", + marginTop: 8, + }, + modalContainer: { + flex: 1, + padding: 20, + justifyContent: "center", + }, + modalTitle: { + fontSize: 20, + fontWeight: "bold", + marginBottom: 20, + textAlign: "center", + }, + input: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + padding: 12, + fontSize: 16, + marginBottom: 12, + }, + cardInput: { + height: 100, + textAlignVertical: "top", + }, + descriptionInput: { + height: 80, + textAlignVertical: "top", + }, + modalButtons: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 20, + }, + cancelButton: { + flex: 1, + padding: 12, + marginRight: 10, + borderRadius: 8, + backgroundColor: "#ccc", + alignItems: "center", + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + }, + saveButton: { + flex: 1, + padding: 12, + marginLeft: 10, + borderRadius: 8, + backgroundColor: "#03465b", + alignItems: "center", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "white", + }, +}); \ No newline at end of file diff --git a/front/app/flashcards/study.tsx b/front/app/flashcards/study.tsx new file mode 100644 index 0000000..78799e7 --- /dev/null +++ b/front/app/flashcards/study.tsx @@ -0,0 +1,224 @@ +import { + StyleSheet, + View, + TouchableOpacity, + Dimensions, +} from "react-native"; +import { useLocalSearchParams } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; +import { IconSymbol } from "@/components/ui/IconSymbol"; +import { useState, useEffect } from "react"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + interpolate, + Easing, +} from "react-native-reanimated"; +import * as Haptics from "expo-haptics"; + +import { fetchFlashcards, Flashcard } from "@/api/flashcards"; +import { PlatformPressable } from "@react-navigation/elements"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const CARD_WIDTH = SCREEN_WIDTH - 40; + +export default function Study() { + const { deckId } = useLocalSearchParams<{ deckId: string }>(); + const [cards, setCards] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + const flip = useSharedValue(0); + + useEffect(() => { + const loadCards = async () => { + const flashcards = await fetchFlashcards(deckId); + setCards(flashcards); + }; + loadCards(); + }, [deckId]); + + const flipCard = () => { + if (process.env.EXPO_OS === "ios") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + flip.value = withTiming(isFlipped ? 0 : 1, { + duration: 400, + easing: Easing.inOut(Easing.ease), + }); + setIsFlipped(!isFlipped); + }; + + const goToNext = () => { + if (currentIndex < cards.length - 1) { + if (process.env.EXPO_OS === "ios") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + flip.value = 0; + setIsFlipped(false); + setCurrentIndex(currentIndex + 1); + } + }; + + const goToPrev = () => { + if (currentIndex > 0) { + if (process.env.EXPO_OS === "ios") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + flip.value = 0; + setIsFlipped(false); + setCurrentIndex(currentIndex - 1); + } + }; + + const frontAnimatedStyle = useAnimatedStyle(() => { + const rotateY = interpolate(flip.value, [0, 1], [0, 180]); + return { + transform: [{ perspective: 1000 }, { rotateY: `${rotateY}deg` }], + backfaceVisibility: "hidden" as const, + }; + }); + + const backAnimatedStyle = useAnimatedStyle(() => { + const rotateY = interpolate(flip.value, [0, 1], [180, 360]); + return { + transform: [{ perspective: 1000 }, { rotateY: `${rotateY}deg` }], + backfaceVisibility: "hidden" as const, + }; + }); + + if (cards.length === 0) { + return ( + + No cards to study + + ); + } + + const currentCard = cards[currentIndex]; + + return ( + + + + {currentIndex + 1} / {cards.length} + + + + + + {currentCard?.front} + Tap to reveal + + + {currentCard?.back} + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + }, + header: { + alignItems: "center", + marginBottom: 20, + }, + progress: { + fontSize: 18, + fontWeight: "600", + }, + cardContainer: { + width: CARD_WIDTH, + height: 300, + alignSelf: "center", + }, + card: { + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#fff", + borderRadius: 16, + padding: 20, + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + cardFront: { + backgroundColor: "#fff", + }, + cardBack: { + backgroundColor: "#f0f8ff", + }, + cardText: { + fontSize: 20, + textAlign: "center", + color: "#333", + }, + tapHint: { + position: "absolute", + bottom: 20, + fontSize: 14, + color: "#888", + }, + navigation: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 40, + paddingHorizontal: 20, + }, + navButton: { + padding: 20, + borderRadius: 30, + backgroundColor: "#f0f0f0", + }, + navButtonDisabled: { + opacity: 0.5, + }, + emptyText: { + fontSize: 18, + color: "#888", + textAlign: "center", + marginTop: 100, + }, +}); \ No newline at end of file