diff --git a/openspec/changes/lms-ask-doubt/.openspec.yaml b/openspec/changes/lms-ask-doubt/.openspec.yaml new file mode 100644 index 00000000..bea0667b --- /dev/null +++ b/openspec/changes/lms-ask-doubt/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-17 diff --git a/openspec/changes/lms-ask-doubt/design.md b/openspec/changes/lms-ask-doubt/design.md new file mode 100644 index 00000000..c35e7cf3 --- /dev/null +++ b/openspec/changes/lms-ask-doubt/design.md @@ -0,0 +1,68 @@ +# Design: LMS Ask Doubt (AI Assistant) + +## Context +The "Ask a Doubt" feature is a dedicated route within the AI Assistant package. It leverages the global design tokens and localization system so the screen feels native to the Cortex shell rather than like a one-off custom surface. + +## Goals / Non-Goals + +**Goals:** +- Provide a dedicated `AskDoubtScreen`. +- Implement a 2x2 grid of "Quick Suggestions" in the empty chat state. +- Create a floating Chat Input bar with an integrated attachment button, placeholder voice affordance, and prominent send action. +- Keep all user-facing labels, prompts, mock responses, and accessibility copy localized through `packages/core` l10n resources. +- Use existing Cortex semantic colors, typography, spacing, and surfaces instead of hardcoded values. + +**Non-Goals:** +- Implementing the "History Drawer" fully if it requires complex backend storage changes in the first sprint (mock history is acceptable). +- Voice recording functionality (placeholder only). + +## Decisions + +### 1. Screen Architecture +- **Widget**: `AskDoubtScreen` (StatefulWidget or ConsumerWidget). +- **Layout**: `Column` containing a custom `AppHeader` (mimicking the minimal reference header), a scrollable `ListView` for messages, and a fixed `PreferredSize` or `BottomAppBar` equivalent for the input bar. +- **Shell Context**: Ask Doubt currently renders within the existing shell/navigation context reached from the AI Assistant hub rather than introducing a separate shell root. +- **Routing**: The `AskDoubtScreen` is pushed directly from `AIAssistantPage` using `AppRoute` instead of being registered as a root immersive route. +- **Separation**: Ask Doubt UI concerns should be split into focused widgets when a screen file grows beyond lightweight orchestration. The screen should coordinate state and composition, while drawer, menu, empty state, and message-list presentation live in dedicated files. + +### 2. Message UI +- **Student Bubbles**: Right-aligned, using a soft neutral grey bubble that is darker than the page background but not near-black, with support for optional image headers. +- **AI Responses**: Left-aligned and rendered without a surrounding bubble/container so the assistant copy reads directly on the page, closer to the reference chat style. +- **Actions**: Bubble action affordances should inherit design-token icon colors and spacing. + +### 3. Input System +- **State**: `TextEditingController` for the message. +- **Image Support**: The attachment action currently uses a mock image source and sends an image-backed placeholder message immediately instead of maintaining a local pre-send preview state. +- **Composer**: A single floating pill surface with small left/right/bottom inset so it visually floats above the page background. +- **Keyboard Behavior**: The floating composer must translate above the software keyboard on device instead of remaining fixed under it. +- **Landscape Handling**: In landscape or similarly short viewports, keyboard avoidance must keep the composer visible within the remaining viewport rather than lifting it beyond the visible content area. +- **Dynamic Placement**: Keyboard avoidance should be derived from the actual composer size and the currently visible viewport, not from a fixed estimated height. +- **Focus Behavior**: Repeated taps on the input surface should reliably reopen the software keyboard even after the keyboard was dismissed while focus remained on the field. +- **Tap Detection**: Keyboard reopening should not depend only on high-level gesture recognition for the editable region; the composer should respond reliably even when the text field consumes the gesture. +- **Keyboard Gap**: The floating offset above the keyboard should tighten while the keyboard is visible so the composer does not appear detached from it. +- **Portrait Keyboard Gap**: In portrait, the keyboard-visible gap should collapse to the minimum possible value so the composer feels attached to the keyboard rather than floating above it. +- **Orientation-Specific Gap**: Keyboard-visible spacing should be orientation-aware: near-flush in portrait, but allowed a small buffer in landscape so the composer remains comfortably legible in short viewports. +- **Portrait Inset Tuning**: Portrait keyboard placement may apply a small inset compensation so the composer aligns to the visible keyboard edge rather than to an over-reported system inset. +- **Portrait Tightness**: Portrait tuning should prefer the smallest comfortable gap above the keyboard and can be refined independently from landscape spacing. +- **Portrait Priority**: If portrait still feels visually detached, prefer reducing the gap further rather than reusing the landscape buffer. +- **Send Button**: A circular button within the input field that becomes visually dimmed when no input is present. Empty submissions are ignored by the send handler. +- **Copy**: Placeholder text, semantics labels, and action labels must come from shared localizations. +- **Suggestion Cards**: The four empty-state suggestion cards should keep a stable visual height across rows, even when labels wrap or text scaling is larger. +- **Suggestion Card Alignment**: Empty-state suggestion labels should read centered within each card rather than left-weighted. + +### 4. Session Controls +- **History Drawer**: The history drawer should remain a dedicated surface with readable active/inactive states. +- **Chat Menu**: The per-session overflow menu must use a surface and text treatment that preserves readable contrast in both light and dark mode. +- **Pin State Labeling**: The session overflow menu should reflect the current pin state of the session, showing `Unpin` for already pinned chats. +- **Pinned Ordering**: Pinned chats should remain grouped at the top of history even when new unpinned chats are created afterward. + +### 5. Animations +- **Slide-up**: Chat bubbles should enter with a subtle slide-up and fade-in. +- **Thinking Indicator**: A localized loading indicator accompanies AI response wait times. + +## Risks / Trade-offs + +- **Risk**: Keyboard covering the input bar on some Android devices. +- **Trade-off**: Using `AppScroll` vs a standard `ListView` - `ListView` is better for chat history with `reverse: false` and manual scrolling to bottom. +- **Risk**: The current attachment flow is still a mock and does not yet cover real file selection or pre-send preview behavior. +- **Trade-off**: Mock AI responses remain local placeholder content for now, but they should still be localized because they are visible product copy. diff --git a/openspec/changes/lms-ask-doubt/proposal.md b/openspec/changes/lms-ask-doubt/proposal.md new file mode 100644 index 00000000..6e01d132 --- /dev/null +++ b/openspec/changes/lms-ask-doubt/proposal.md @@ -0,0 +1,30 @@ +# Proposal: LMS Ask Doubt (AI Assistant) + +## Context +The AI Assistant hub serves as the central location for personalized student support. One of the core pillars of this experience is the ability for students to quickly "Ask a Doubt" and receive immediate, context-aware assistance. + +## Goals +- **Seamless Entry**: Trigger the doubt workflow from the AI Assistant landing page. +- **Dedicated Chat Route**: Provide a focused Ask Doubt chat environment for exploring doubts. +- **Attachment Placeholder**: Support a lightweight attachment affordance backed by mock image message behavior in the first sprint. +- **Quick Guidance**: Offer suggestion chips (e.g., "Explain Concept", "Solve Problem") to reduce friction for new users. + +## What Changes +1. **AIAssistant Navigation**: Update `AIAssistantPage` to navigate to the new `AskDoubtScreen`. +2. **New AskDoubtScreen**: Create a dedicated chat interface in `packages/ai_assistant` that is pushed from the AI Assistant hub. +3. **Chat UI System**: Implement specialized message bubbles for Student and AI roles, incorporating the current premium light-surface styling. +4. **Integrated Input Bar**: A sophisticated input system supporting text, a mock attachment action, and voice (optional placeholder). +5. **History Access**: A slide-out drawer or similar mechanism to access past doubts (as seen in reference). + +## Capabilities + +### New Capabilities +- `ai-ask-doubt`: A full-featured chat environment for students to interact with the AI study assistant. + +### Modified Capabilities +- `ai-assistant-hub`: Enhanced to support navigation and state persistence for active doubt sessions. + +## Impact +- `packages/ai_assistant`: Core feature implementation (Screens, Widgets, Providers). +- `packages/core`: Utilization of `AppRoute`, `AppHeader`, and refined `AppText` for chat bubbles. +- `packages/testpress`: Integration of the new route within the main shell. diff --git a/openspec/changes/lms-ask-doubt/specs/ai-ask-doubt/spec.md b/openspec/changes/lms-ask-doubt/specs/ai-ask-doubt/spec.md new file mode 100644 index 00000000..28431ab6 --- /dev/null +++ b/openspec/changes/lms-ask-doubt/specs/ai-ask-doubt/spec.md @@ -0,0 +1,104 @@ +# Specs: LMS Ask Doubt (AI Assistant) + +## ADDED Requirements + +### Requirement: AI Hub Navigation +The system SHALL navigate from the AI Assistant hub to a dedicated "Ask a Doubt" route. + +#### Scenario: User opens Ask a Doubt +- **WHEN** the user taps the "Ask a Doubt" card in the AI Assistant hub +- **THEN** the system SHALL transition to the `AskDoubtScreen` +- **AND** the transition SHALL be smooth (AppRoute default) +- **AND** the Ask Doubt route SHALL render within the existing app shell/navigation context used by the hub flow + +### Requirement: Empty State & Suggestions +The system SHALL provide guidance when no messages are present in the current session. + +#### Scenario: Initial empty state +- **WHEN** the `AskDoubtScreen` is opened for a new session +- **THEN** it SHALL display a localized empty-state greeting asking how the assistant can help +- **AND** it SHALL show 4 localized quick suggestion cards for concept explanation, problem solving, practice questions, and study tips +- **AND** the quick suggestion cards SHALL maintain consistent row heights when labels wrap or text scaling increases +- **AND** the quick suggestion card labels SHALL be visually centered within each tile +- **WHEN** a user taps a chip +- **THEN** the chip text SHALL be populated into the chat input field (or sent directly) + +### Requirement: Multimedia Chat Interface +The system SHALL allow users to send text and images to the AI assistant. + +#### Scenario: Sending a text doubt +- **WHEN** the user types a question and taps the "Send" (Up arrow) button +- **THEN** the message SHALL appear as a soft grey student bubble on the right +- **AND** the AI SHALL display a localized thinking indicator +- **AND** a response SHALL appear left-aligned without a surrounding bubble container + +#### Scenario: Attaching an image +- **WHEN** the user taps the "Plus" button +- **THEN** the system SHALL create a mock image-backed doubt using the current placeholder attachment flow +- **AND** the attachment flow MAY use a predefined image source instead of a real picker in the current sprint +- **WHEN** the mock attachment is sent +- **THEN** the image SHALL be displayed within the student's message bubble + +### Requirement: Visual Design & Feedback +The chat interface SHALL provide visual feedback for all interactive states. + +#### Scenario: Typing and Sending +- **WHEN** the input field is empty +- **THEN** the "Send" button SHALL appear visually dimmed +- **AND** empty send attempts SHALL have no effect +- **WHEN** the user is typing +- **THEN** the input SHALL update in real time +- **WHEN** the software keyboard opens on device +- **THEN** the composer SHALL remain visible within the viewport +- **AND** the composer SHALL stay closely aligned to the keyboard edge +- **AND** keyboard spacing MAY be tuned by orientation, with portrait appearing near-flush and landscape allowing a small buffer +- **AND** the composer position SHALL respond to the actual rendered composer height instead of a fixed height assumption +- **WHEN** the user taps the input surface again after dismissing the keyboard +- **THEN** the keyboard SHALL reopen reliably +- **AND** the reopen behavior SHALL remain reliable even when the editable child handles the tap interaction +- **WHEN** a message is sent +- **THEN** the keyboard SHALL remain open (standard chat behavior) +- **AND** the view SHALL auto-scroll to the latest message + +### Requirement: Localized and Token-Driven UI +The Ask Doubt implementation SHALL use shared localization resources and Cortex design tokens for user-facing UI. + +#### Scenario: Rendering Ask Doubt surfaces and copy +- **WHEN** the Ask Doubt screen renders headers, actions, placeholders, prompts, empty-state labels, or mock assistant responses +- **THEN** those strings SHALL come from shared app localizations +- **AND** visible colors for surfaces, text, overlays, and cursors SHALL come from the design system instead of hardcoded literals + +#### Scenario: Rendering session menu in dark mode +- **WHEN** the session overflow menu is opened in dark mode +- **THEN** the menu surface SHALL provide sufficient contrast with the page background +- **AND** all menu actions, not just destructive ones, SHALL remain clearly readable + +#### Scenario: Managing a pinned session +- **WHEN** the user opens the overflow menu for a session that is already pinned +- **THEN** the primary pin action SHALL read as `Unpin` +- **AND** pinned sessions SHALL remain above unpinned sessions in the history list even after new chats are created + +### Requirement: State Consistency & Race Condition Prevention +The system SHALL ensure that asynchronous AI responses are appended to the most recent session state. + +#### Scenario: User clears chat during AI thinking +- **WHEN** the AI is in "Thinking" mode following a user message +- **AND** the user clears the chat session (or modifies the message history) before the AI response is received +- **THEN** the subsequent AI response SHALL NOT revert the chat to the stale state captured before the delay +- **AND** the AI response SHALL be appended to the current state of the session if it still exists +### Requirement: Proper Resource Management +The Ask Doubt implementation SHALL manage lifecycle-dependent resources correctly to prevent memory leaks. + +#### Scenario: Using FocusNodes in interactive overlays +- **WHEN** an interactive field like the rename session dialog requires focus +- **THEN** the system SHALL manage the `FocusNode` lifecycle within a `StatefulWidget` or equivalent controller +- **AND** the `FocusNode` SHALL be explicitly disposed of when the parent widget is removed from the tree +- **AND** focus SHALL be requested programmatically when the overlay appears, rather than instantiating new nodes during build + +### Requirement: Robust Resource Identifiers +The system SHALL use sufficiently unique identifiers for all internal resources to prevent collisions. + +#### Scenario: Generating IDs for sessions and messages +- **WHEN** a new session or message (user/AI/image) is created +- **THEN** its `id` SHALL be generated with sufficient entropy to avoid collisions even when multiple items are created in rapid succession +- **AND** the generation logic SHALL incorporate microsecond-level precision combined with current state metadata (e.g., list length) to ensure uniqueness diff --git a/openspec/changes/lms-ask-doubt/tasks.md b/openspec/changes/lms-ask-doubt/tasks.md new file mode 100644 index 00000000..cc795054 --- /dev/null +++ b/openspec/changes/lms-ask-doubt/tasks.md @@ -0,0 +1,50 @@ +# Tasks: LMS Ask Doubt (AI Assistant) + +## 1. Setup and Navigation + +- [x] 1.1 Add `AskDoubtScreen` scaffold in `packages/ai_assistant/lib/screens/` +- [x] 1.2 Implement navigation route in `packages/testpress` (if required) or direct push in `AIAssistantPage` +- [x] 1.3 Update `AIQuickActionCard` in `AIAssistantPage` to trigger navigation to `AskDoubtScreen` + +## 2. UI Components Implementation + +- [x] 2.1 Implement `DoubtHeader`: Minimal header with Back button and Menu icon (as per reference) +- [x] 2.2 Implement `DoubtEmptyState`: "What can I help with?" title and `QuickSuggestionGrid` (2x2) +- [x] 2.3 Implement `MessageBubble`: Customizable widget for Student and AI roles with premium styling +- [x] 2.4 Implement `DoubtInputBar`: Full-width input with attachment (+) button and integrated Send (Up arrow) button + +## 3. State and Logic + +- [x] 3.1 Create `DoubtSessionProvider` (Riverpod) to manage the list of messages and "Thinking" state +- [x] 3.2 Implement mock response logic: Simulate AI delayed response when a message is sent +- [x] 3.3 Add mock attachment placeholder logic that injects an image-backed user message without full picker integration +- [x] 3.4 Implement auto-scroll behavior for the message list + +## 4. Polishing and Verification + +- [x] 4.1 Apply premium design tokens (gradients, custom shadows) to match the "AI Hub" aesthetic +- [x] 4.2 Verify Dark Mode compatibility and accessibility (AppSemantics) +- [x] 4.3 Add micro-animations for message entry and a localized loading indicator for thinking state +- [x] 4.4 Replace remaining hardcoded Ask Doubt UI strings and color literals with shared localizations and design tokens +- [x] 4.5 Align the Ask Doubt OpenSpec artifacts with the current floating light-surface implementation +- [x] 4.6 Split oversized Ask Doubt screen concerns into dedicated widgets so the screen stays focused on orchestration +- [x] 4.7 Keep the floating composer visible above the software keyboard on device +- [x] 4.8 Fix session menu contrast so all actions remain readable in dark mode +- [x] 4.9 Make repeated input taps reliably reopen the keyboard and tighten the composer gap above it +- [x] 4.10 Keep empty-state suggestion cards visually consistent under larger text scales and refine the keyboard lift spacing +- [x] 4.11 Make keyboard reopening robust even when the editable field consumes tap gestures +- [x] 4.12 Keep the composer visible in landscape when the keyboard occupies most of the viewport +- [x] 4.13 Base keyboard avoidance on the actual composer size so portrait and landscape use the same dynamic behavior +- [x] 4.14 Keep Ask Doubt as a dedicated pushed route within the existing shell while tuning its local inset handling +- [x] 4.15 Remove the remaining portrait keyboard gap so the composer sits nearly flush above the keyboard +- [x] 4.16 Make keyboard-visible spacing orientation-aware so portrait and landscape can use different gap rules +- [x] 4.17 Fine-tune portrait inset handling without changing landscape spacing +- [x] 4.18 Tighten the portrait-only keyboard gap further without changing landscape spacing +- [x] 4.19 Tighten the portrait-only keyboard gap again without changing landscape spacing +- [x] 4.20 Center the empty-state suggestion card labels within their tiles +- [x] 4.21 Remove the surrounding bubble surface from AI responses while keeping user messages styled as bubbles +- [x] 4.22 Make the history menu reflect pinned state and keep pinned chats above newly created unpinned chats +- [x] 4.23 Soften the student message bubble from near-black to a neutral grey surface +- [x] 4.24 Fix race condition in `DoubtSessionNotifier` during `sendMessage` and `addImageMessage` by using the latest state in async callbacks +- [x] 4.25 Fix memory leak in `AskDoubtOverlays` by moving `FocusNode` management to `_AskDoubtScreenState` and implementing proper disposal +- [x] 4.26 Implement more robust unique ID generation for sessions and messages using microsecond precision and session metadata diff --git a/packages/ai_assistant/lib/models/ai_models.dart b/packages/ai_assistant/lib/models/ai_models.dart index 8d1d7cf2..76b4c9eb 100644 --- a/packages/ai_assistant/lib/models/ai_models.dart +++ b/packages/ai_assistant/lib/models/ai_models.dart @@ -1,4 +1,3 @@ - class AIRecommendation { final String id; final String type; @@ -52,3 +51,51 @@ class AIActivity { this.status, }); } + +enum AIMessageRole { user, assistant } + +class AIMessage { + final String id; + final String content; + final AIMessageRole role; + final DateTime timestamp; + final String? imageUrl; + + const AIMessage({ + required this.id, + required this.content, + required this.role, + required this.timestamp, + this.imageUrl, + }); +} + +class AIChatSession { + final String id; + final String title; + final List messages; + final DateTime createdAt; + final bool isPinned; + + const AIChatSession({ + required this.id, + required this.title, + required this.messages, + required this.createdAt, + this.isPinned = false, + }); + + AIChatSession copyWith({ + String? title, + List? messages, + bool? isPinned, + }) { + return AIChatSession( + id: id, + title: title ?? this.title, + messages: messages ?? this.messages, + createdAt: createdAt, + isPinned: isPinned ?? this.isPinned, + ); + } +} diff --git a/packages/ai_assistant/lib/providers/doubt_session_provider.dart b/packages/ai_assistant/lib/providers/doubt_session_provider.dart new file mode 100644 index 00000000..cf9004fd --- /dev/null +++ b/packages/ai_assistant/lib/providers/doubt_session_provider.dart @@ -0,0 +1,262 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/ai_models.dart'; + +class DoubtSessionState { + final List history; + final String? activeSessionId; + final bool isThinking; + + DoubtSessionState({ + required this.history, + this.activeSessionId, + required this.isThinking, + }); + + AIChatSession? get activeSession { + final currentId = activeSessionId; + if (currentId == null) return null; + + // Using a manual loop to avoid any potential FirstWhere/Iterable issues + for (final session in history) { + if (session.id == currentId) return session; + } + return null; + } + + DoubtSessionState copyWith({ + List? history, + String? activeSessionId, + bool? isThinking, + }) { + return DoubtSessionState( + history: history ?? this.history, + activeSessionId: activeSessionId ?? this.activeSessionId, + isThinking: isThinking ?? this.isThinking, + ); + } + + static DoubtSessionState initial() { + return DoubtSessionState(history: const [], isThinking: false); + } +} + +class DoubtSessionNotifier extends StateNotifier { + DoubtSessionNotifier() : super(DoubtSessionState.initial()); + + List _sortHistory(List sessions) { + final sorted = [...sessions]; + sorted.sort((a, b) { + if (a.isPinned != b.isPinned) { + return a.isPinned ? -1 : 1; + } + return b.createdAt.compareTo(a.createdAt); + }); + return sorted; + } + + void createNewChat({required String newChatTitle}) { + // Re-use existing empty chat if it exists + for (final session in state.history) { + if (session.messages.isEmpty) { + state = state.copyWith(activeSessionId: session.id, isThinking: false); + return; + } + } + + final newSession = AIChatSession( + id: 'chat_${DateTime.now().microsecondsSinceEpoch}_${state.history.length}', + title: newChatTitle, + messages: const [], + createdAt: DateTime.now(), + ); + + state = state.copyWith( + history: _sortHistory([newSession, ...state.history]), + activeSessionId: newSession.id, + isThinking: false, + ); + } + + void selectSession(String sessionId) { + state = state.copyWith(activeSessionId: sessionId, isThinking: false); + } + + void sendMessage( + String content, { + required String newChatTitle, + required String defaultResponse, + String? assistantResponse, + }) { + if (state.activeSessionId == null) { + createNewChat(newChatTitle: newChatTitle); + } + + final activeSession = state.activeSession; + if (activeSession == null) return; + + final userMessage = AIMessage( + id: 'msg_u_${DateTime.now().microsecondsSinceEpoch}_${activeSession.messages.length}', + content: content, + role: AIMessageRole.user, + timestamp: DateTime.now(), + ); + + final updatedMessages = [...activeSession.messages, userMessage]; + String updatedTitle = activeSession.title; + if (activeSession.messages.isEmpty) { + updatedTitle = content.length > 30 + ? '${content.substring(0, 30)}...' + : content; + } + + final updatedSession = activeSession.copyWith( + messages: updatedMessages, + title: updatedTitle, + ); + + state = state.copyWith( + history: _sortHistory( + state.history + .map((s) => s.id == updatedSession.id ? updatedSession : s) + .toList(), + ), + isThinking: true, + ); + + Future.delayed(const Duration(seconds: 2), () { + final aiMessage = AIMessage( + id: 'msg_a_${DateTime.now().microsecondsSinceEpoch}', + content: assistantResponse ?? defaultResponse, + role: AIMessageRole.assistant, + timestamp: DateTime.now(), + ); + + state = state.copyWith( + history: _sortHistory( + state.history.map((s) { + if (s.id == updatedSession.id) { + return s.copyWith(messages: [...s.messages, aiMessage]); + } + return s; + }).toList(), + ), + isThinking: false, + ); + }); + } + + void addImageMessage( + String imageUrl, + String content, { + required String newChatTitle, + required String imageResponse, + }) { + if (state.activeSessionId == null) { + createNewChat(newChatTitle: newChatTitle); + } + + final activeSession = state.activeSession; + if (activeSession == null) return; + + final userMessage = AIMessage( + id: 'msg_img_${DateTime.now().microsecondsSinceEpoch}_${activeSession.messages.length}', + content: content, + role: AIMessageRole.user, + timestamp: DateTime.now(), + imageUrl: imageUrl, + ); + + final updatedTitle = activeSession.messages.isEmpty + ? (content.length > 30 ? '${content.substring(0, 30)}...' : content) + : activeSession.title; + + final updatedSession = activeSession.copyWith( + messages: [...activeSession.messages, userMessage], + title: updatedTitle, + ); + + state = state.copyWith( + history: _sortHistory( + state.history + .map((s) => s.id == updatedSession.id ? updatedSession : s) + .toList(), + ), + isThinking: true, + ); + + Future.delayed(const Duration(seconds: 2), () { + final aiMessage = AIMessage( + id: 'msg_a_img_${DateTime.now().microsecondsSinceEpoch}', + content: imageResponse, + role: AIMessageRole.assistant, + timestamp: DateTime.now(), + ); + + state = state.copyWith( + history: _sortHistory( + state.history.map((s) { + if (s.id == updatedSession.id) { + return s.copyWith(messages: [...s.messages, aiMessage]); + } + return s; + }).toList(), + ), + isThinking: false, + ); + }); + } + + void togglePinSession(String sessionId) { + state = state.copyWith( + history: _sortHistory( + state.history.map((s) { + if (s.id == sessionId) { + return s.copyWith(isPinned: !s.isPinned); + } + return s; + }).toList(), + ), + ); + } + + void renameSession(String sessionId, String newTitle) { + state = state.copyWith( + history: _sortHistory( + state.history + .map((s) => s.id == sessionId ? s.copyWith(title: newTitle) : s) + .toList(), + ), + ); + } + + void deleteSession(String sessionId) { + final newHistory = _sortHistory( + state.history.where((s) => s.id != sessionId).toList(), + ); + String? newActiveId = state.activeSessionId; + if (newActiveId == sessionId) { + newActiveId = newHistory.isNotEmpty ? newHistory.first.id : null; + } + state = state.copyWith(history: newHistory, activeSessionId: newActiveId); + } + + void clearSession() { + final activeSession = state.activeSession; + if (activeSession != null) { + final clearedSession = activeSession.copyWith(messages: const []); + state = state.copyWith( + history: _sortHistory( + state.history + .map((s) => s.id == clearedSession.id ? clearedSession : s) + .toList(), + ), + isThinking: false, + ); + } + } +} + +final doubtSessionProvider = + StateNotifierProvider((ref) { + return DoubtSessionNotifier(); + }); diff --git a/packages/ai_assistant/lib/screens/ai_assistant_page.dart b/packages/ai_assistant/lib/screens/ai_assistant_page.dart index bef0245f..edb48ee7 100644 --- a/packages/ai_assistant/lib/screens/ai_assistant_page.dart +++ b/packages/ai_assistant/lib/screens/ai_assistant_page.dart @@ -6,6 +6,7 @@ import '../widgets/ai_doubt_hero.dart'; import '../widgets/ai_recommendation_card.dart'; import '../widgets/ai_activity_item.dart'; import '../models/ai_models.dart'; +import 'ask_doubt_screen.dart'; class AIAssistantPage extends StatelessWidget { const AIAssistantPage({super.key}); @@ -32,6 +33,20 @@ class AIAssistantPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSectionTitle(design, l10n.aiAssistantQuickActions), + SizedBox(height: design.spacing.md), + AIQuickActionCard( + icon: LucideIcons.helpCircle, + title: l10n.aiAssistantAskDoubtTitle, + description: l10n.aiAssistantAskDoubtDesc, + iconColor: design.colors.accent2, + iconBackgroundColor: design.colors.accent2.withValues( + alpha: 0.1, + ), + onTap: () => Navigator.of( + context, + ).push(AppRoute(page: const AskDoubtScreen())), + ), SizedBox(height: design.spacing.md), AIQuickActionCard( icon: LucideIcons.fileText, diff --git a/packages/ai_assistant/lib/screens/ask_doubt_screen.dart b/packages/ai_assistant/lib/screens/ask_doubt_screen.dart new file mode 100644 index 00000000..0f0a8b2e --- /dev/null +++ b/packages/ai_assistant/lib/screens/ask_doubt_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:core/core.dart'; +import 'dart:math' as math; + +import '../widgets/ai_doubt_input_bar.dart'; +import '../widgets/ask_doubt_empty_state.dart'; +import '../widgets/ask_doubt_header.dart'; +import '../widgets/ask_doubt_history_drawer.dart'; +import '../widgets/ask_doubt_message_list.dart'; +import '../widgets/ask_doubt_overlays.dart'; +import '../providers/doubt_session_provider.dart'; + +class AskDoubtScreen extends ConsumerStatefulWidget { + const AskDoubtScreen({super.key}); + + @override + ConsumerState createState() => _AskDoubtScreenState(); +} + +class _AskDoubtScreenState extends ConsumerState { + static const _mockAttachmentImageUrl = 'https://picsum.photos/400/300'; + + final GlobalKey _composerKey = GlobalKey(); + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final TextEditingController _renameController = TextEditingController(); + final FocusNode _renameFocusNode = FocusNode(); + + bool _isDrawerOpen = false; + String? _menuSessionId; + Offset? _menuOffset; + String? _renamingSessionId; + double _composerHeight = 0; + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + _renameController.dispose(); + _renameFocusNode.dispose(); + super.dispose(); + } + + void _handleSend() { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final l10n = L10n.of(context); + ref + .read(doubtSessionProvider.notifier) + .sendMessage( + text, + newChatTitle: l10n.aiDoubtNewChat, + defaultResponse: l10n.aiDoubtMockResponseDefault, + ); + _textController.clear(); + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + void _updateComposerHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = _composerKey.currentContext; + if (context == null) return; + + final size = context.size; + if (size == null || size.height == _composerHeight) return; + + setState(() { + _composerHeight = size.height; + }); + }); + } + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + final sessionState = ref.watch(doubtSessionProvider); + final activeSession = sessionState.activeSession; + final messages = activeSession?.messages ?? []; + final isThinking = sessionState.isThinking; + _updateComposerHeight(); + + // Scroll to bottom when new messages arrive or thinking state changes + ref.listen(doubtSessionProvider, (_, __) => _scrollToBottom()); + + return Container( + color: design.colors.surface, + child: LayoutBuilder( + builder: (context, constraints) { + final viewInsets = MediaQuery.viewInsetsOf(context).bottom; + final isLandscape = constraints.maxWidth > constraints.maxHeight; + final composerHeight = _composerHeight > 0 + ? _composerHeight + : design.spacing.xxxl + design.spacing.md; + final effectiveKeyboardInset = viewInsets > 0 && !isLandscape + ? math.max(0.0, viewInsets - (design.spacing.xl + design.spacing.sm)) + : viewInsets; + final visibleBottom = math.max( + 0.0, + constraints.maxHeight - effectiveKeyboardInset, + ); + final composerGap = viewInsets > 0 + ? (isLandscape ? design.spacing.xs : 0.0) + : design.spacing.sm; + final composerTop = math.max( + 0.0, + visibleBottom - composerHeight - composerGap, + ); + final contentBottomPadding = + constraints.maxHeight - composerTop + design.spacing.sm; + + return Stack( + children: [ + Container( + color: design.colors.surface, + child: Column( + children: [ + AskDoubtHeader( + onBack: () => Navigator.of(context).pop(), + onOpenMenu: () => setState(() => _isDrawerOpen = true), + ), + Expanded( + child: messages.isEmpty && !isThinking + ? Padding( + padding: EdgeInsets.only( + bottom: contentBottomPadding, + ), + child: AskDoubtEmptyState( + onExplainConcept: () => _handleSuggestion( + l10n.aiDoubtSuggestionExplainPrompt, + response: l10n.aiDoubtMockResponseConcept, + ), + onSolveProblem: () => _handleSuggestion( + l10n.aiDoubtSuggestionSolvePrompt, + response: l10n.aiDoubtMockResponseSolve, + ), + onPracticeQuestions: () => _handleSuggestion( + l10n.aiDoubtSuggestionPracticePrompt, + response: l10n.aiDoubtMockResponsePractice, + ), + onStudyTips: () => _handleSuggestion( + l10n.aiDoubtSuggestionTipsPrompt, + response: l10n.aiDoubtMockResponseTips, + ), + ), + ) + : AskDoubtMessageList( + scrollController: _scrollController, + messages: messages, + isThinking: isThinking, + bottomPadding: contentBottomPadding, + ), + ), + ], + ), + ), + AnimatedPositioned( + duration: design.motion.normal, + curve: design.motion.easeOut, + left: 0, + right: 0, + top: composerTop, + child: KeyedSubtree( + key: _composerKey, + child: AIDoubtInputBar( + controller: _textController, + onSend: _handleSend, + leftSafeArea: false, + rightSafeArea: false, + onAttach: () { + ref.read(doubtSessionProvider.notifier).addImageMessage( + _mockAttachmentImageUrl, + l10n.aiDoubtImagePrompt, + newChatTitle: l10n.aiDoubtNewChat, + imageResponse: l10n.aiDoubtImageResponse, + ); + }, + ), + ), + ), + AskDoubtHistoryDrawer( + isOpen: _isDrawerOpen, + sessionState: sessionState, + onDismiss: () => setState(() => _isDrawerOpen = false), + onNewChat: () { + ref + .read(doubtSessionProvider.notifier) + .createNewChat(newChatTitle: l10n.aiDoubtNewChat); + setState(() => _isDrawerOpen = false); + }, + onSelectSession: (sessionId) { + ref + .read(doubtSessionProvider.notifier) + .selectSession(sessionId); + setState(() => _isDrawerOpen = false); + }, + onOpenSessionMenu: (sessionId, globalPosition) { + setState(() { + _menuSessionId = sessionId; + _menuOffset = globalPosition; + }); + }, + ), + AskDoubtOverlays( + menuSessionId: _menuSessionId, + menuOffset: _menuOffset, + renamingSessionId: _renamingSessionId, + sessionState: sessionState, + renameController: _renameController, + renameFocusNode: _renameFocusNode, + onDismissMenu: () => setState(() => _menuSessionId = null), + onDismissRename: () => setState(() => _renamingSessionId = null), + onTogglePin: (sessionId) { + ref + .read(doubtSessionProvider.notifier) + .togglePinSession(sessionId); + setState(() => _menuSessionId = null); + }, + onDelete: (sessionId) { + ref.read(doubtSessionProvider.notifier).deleteSession(sessionId); + setState(() => _menuSessionId = null); + }, + onStartRename: (sessionId, title) { + _renameController.text = title; + setState(() { + _renamingSessionId = sessionId; + _menuSessionId = null; + }); + _renameFocusNode.requestFocus(); + }, + onSubmitRename: (sessionId, title) { + if (title.isNotEmpty) { + ref + .read(doubtSessionProvider.notifier) + .renameSession(sessionId, title); + } + setState(() => _renamingSessionId = null); + }, + ), + ], + ); + }, + ), + ); + } + + void _handleSuggestion(String text, {required String response}) { + final l10n = L10n.of(context); + ref + .read(doubtSessionProvider.notifier) + .sendMessage( + text, + newChatTitle: l10n.aiDoubtNewChat, + defaultResponse: l10n.aiDoubtMockResponseDefault, + assistantResponse: response, + ); + _scrollToBottom(); + } +} diff --git a/packages/ai_assistant/lib/widgets/ai_doubt_input_bar.dart b/packages/ai_assistant/lib/widgets/ai_doubt_input_bar.dart new file mode 100644 index 00000000..3e10be26 --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ai_doubt_input_bar.dart @@ -0,0 +1,204 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:core/core.dart'; + +class AIDoubtInputBar extends StatefulWidget { + final TextEditingController controller; + final VoidCallback onSend; + final VoidCallback onAttach; + + final bool leftSafeArea; + final bool rightSafeArea; + + const AIDoubtInputBar({ + super.key, + required this.controller, + required this.onSend, + required this.onAttach, + this.leftSafeArea = true, + this.rightSafeArea = true, + }); + + @override + State createState() => _AIDoubtInputBarState(); +} + +class _AIDoubtInputBarState extends State { + bool _hasText = false; + final GlobalKey _editableTextKey = + GlobalKey(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_handleTextChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_handleTextChanged); + _focusNode.dispose(); + super.dispose(); + } + + void _handleTextChanged() { + final hasText = widget.controller.text.trim().isNotEmpty; + if (hasText != _hasText) { + setState(() { + _hasText = hasText; + }); + } + } + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + final keyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; + final screenSize = MediaQuery.sizeOf(context); + final isLandscape = screenSize.width > screenSize.height; + + return Container( + padding: EdgeInsets.only( + left: design.spacing.sm, + right: design.spacing.sm, + top: 0, + bottom: keyboardVisible + ? (isLandscape ? design.spacing.xs : 0) + : design.spacing.sm, + ), + decoration: BoxDecoration( + color: design.colors.overlay.withValues(alpha: 0), + ), + child: SafeArea( + top: false, + bottom: false, + left: widget.leftSafeArea, + right: widget.rightSafeArea, + child: Container( + padding: EdgeInsets.all(design.spacing.xs), + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: design.radius.pill, + border: Border.all( + color: design.colors.border.withValues(alpha: 0.8), + ), + boxShadow: design.shadows.floating, + ), + child: Row( + children: [ + // Attachment Button + GestureDetector( + onTap: widget.onAttach, + child: AppSemantics.button( + label: l10n.aiDoubtAttachAction, + child: Container( + padding: EdgeInsets.all(design.spacing.sm), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues( + alpha: 0.5, + ), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.plus, + color: design.colors.textPrimary, + size: design.iconSize.action, + ), + ), + ), + ), + SizedBox(width: design.spacing.sm), + + // TextField (Neutral replacement) + Expanded( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _showKeyboard(), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (!_hasText) + AppText.body( + l10n.aiDoubtPlaceholder, + color: design.colors.textTertiary, + ), + EditableText( + key: _editableTextKey, + controller: widget.controller, + focusNode: _focusNode, + cursorColor: design.colors.primary, + backgroundCursorColor: design.colors.textSecondary, + style: design.typography.body.copyWith( + color: design.colors.textPrimary, + height: 1.2, + ), + maxLines: 4, + minLines: 1, + onSubmitted: (_) => widget.onSend(), + ), + ], + ), + ), + ), + + // Voice Button (Placeholder) + AppSemantics.button( + label: l10n.aiDoubtVoiceAction, + child: Padding( + padding: EdgeInsets.all(design.spacing.sm), + child: Icon( + LucideIcons.mic, + color: design.colors.textTertiary, + size: design.iconSize.action, + ), + ), + ), + + // Send Button + GestureDetector( + onTap: widget.onSend, + child: AppSemantics.button( + label: l10n.aiDoubtSendAction, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.all(design.spacing.sm), + decoration: BoxDecoration( + color: _hasText + ? design.colors.textPrimary + : design.colors.textTertiary.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.arrowUp, + color: _hasText + ? design.colors.textInverse + : design.colors.textPrimary.withValues(alpha: 0.4), + size: design.iconSize.action, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showKeyboard() { + final editableTextState = _editableTextKey.currentState; + if (editableTextState != null) { + editableTextState.requestKeyboard(); + return; + } + + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + return; + } + + SystemChannels.textInput.invokeMethod('TextInput.show'); + } +} diff --git a/packages/ai_assistant/lib/widgets/ai_message_bubble.dart b/packages/ai_assistant/lib/widgets/ai_message_bubble.dart new file mode 100644 index 00000000..64115642 --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ai_message_bubble.dart @@ -0,0 +1,178 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; +import '../models/ai_models.dart'; + +class AIMessageBubble extends StatelessWidget { + final AIMessage message; + + const AIMessageBubble({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final isUser = message.role == AIMessageRole.user; + final userBubbleColor = design.isDark + ? design.colors.surfaceVariant + : design.colors.surfaceVariant; + final imageWidth = math.min( + MediaQuery.of(context).size.width * 0.64, + design.layout.maxDrawerWidth, + ); + + return Padding( + padding: EdgeInsets.only(bottom: design.spacing.md), + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (message.imageUrl != null) ...[ + ClipRRect( + borderRadius: design.radius.card, + child: Image.network( + message.imageUrl!, + width: imageWidth, + fit: BoxFit.cover, + ), + ), + SizedBox(height: design.spacing.xs), + ], + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + child: isUser + ? Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.sm, + ), + decoration: BoxDecoration( + color: userBubbleColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(design.radius.lg), + topRight: Radius.circular(design.radius.lg), + bottomLeft: Radius.circular(design.radius.lg), + bottomRight: Radius.circular( + design.radius.sm, + ), + ), + ), + child: AppText.body( + message.content, + color: design.colors.textPrimary, + ), + ) + : AppText.body( + message.content, + color: design.colors.textPrimary, + ), + ), + _buildActions(context, design, isUser), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildActions(BuildContext context, DesignConfig design, bool isUser) { + return Padding( + padding: EdgeInsets.only( + top: design.spacing.sm, + left: isUser ? 0 : design.spacing.sm, + right: isUser ? design.spacing.sm : 0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MessageAction( + icon: LucideIcons.copy, + design: design, + isUser: isUser, + ), + if (isUser) ...[ + _MessageAction( + icon: LucideIcons.pencil, + design: design, + isUser: isUser, + ), + ] else ...[ + _MessageAction( + icon: LucideIcons.thumbsUp, + design: design, + isUser: isUser, + ), + _MessageAction( + icon: LucideIcons.thumbsDown, + design: design, + isUser: isUser, + ), + _MessageAction( + icon: LucideIcons.volume2, + design: design, + isUser: isUser, + ), + _MessageAction( + icon: LucideIcons.share2, + design: design, + isUser: isUser, + ), + _MessageAction( + icon: LucideIcons.moreHorizontal, + design: design, + isUser: isUser, + ), + ], + ], + ), + ); + } +} + +class _MessageAction extends StatelessWidget { + final IconData icon; + final DesignConfig design; + final bool isUser; + + const _MessageAction({ + required this.icon, + required this.design, + required this.isUser, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + right: isUser ? 0 : design.spacing.md, + left: isUser ? design.spacing.md : 0, + ), + child: GestureDetector( + onTap: () {}, // No functionality for now + child: Icon( + icon, + size: design.iconSize.sm, + color: design.colors.textSecondary, + ), + ), + ); + } +} diff --git a/packages/ai_assistant/lib/widgets/ask_doubt_empty_state.dart b/packages/ai_assistant/lib/widgets/ask_doubt_empty_state.dart new file mode 100644 index 00000000..04adcbed --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ask_doubt_empty_state.dart @@ -0,0 +1,143 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +class AskDoubtEmptyState extends StatelessWidget { + const AskDoubtEmptyState({ + super.key, + required this.onExplainConcept, + required this.onSolveProblem, + required this.onPracticeQuestions, + required this.onStudyTips, + }); + + final VoidCallback onExplainConcept; + final VoidCallback onSolveProblem; + final VoidCallback onPracticeQuestions; + final VoidCallback onStudyTips; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + return SingleChildScrollView( + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: design.spacing.xl * 2), + AppText.xl2( + l10n.aiDoubtEmptyTitle, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: design.spacing.xl), + Column( + children: [ + _SuggestionRow( + leading: _SuggestionCard( + icon: LucideIcons.bookOpen, + label: l10n.aiDoubtSuggestionExplainLabel, + iconColor: design.colors.accent4, + onTap: onExplainConcept, + ), + trailing: _SuggestionCard( + icon: LucideIcons.calculator, + label: l10n.aiDoubtSuggestionSolveLabel, + iconColor: design.colors.accent2, + onTap: onSolveProblem, + ), + ), + SizedBox(height: design.spacing.sm), + _SuggestionRow( + leading: _SuggestionCard( + icon: LucideIcons.fileQuestion, + label: l10n.aiDoubtSuggestionPracticeLabel, + iconColor: design.colors.accent1, + onTap: onPracticeQuestions, + ), + trailing: _SuggestionCard( + icon: LucideIcons.lightbulb, + label: l10n.aiDoubtSuggestionTipsLabel, + iconColor: design.colors.accent3, + onTap: onStudyTips, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _SuggestionCard extends StatelessWidget { + const _SuggestionCard({ + required this.icon, + required this.label, + required this.iconColor, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color iconColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final minimumHeight = + design.spacing.xxxl + design.spacing.xl + design.spacing.sm; + + return ConstrainedBox( + constraints: BoxConstraints(minHeight: minimumHeight), + child: AppCard( + onTap: onTap, + showShadow: true, + padding: EdgeInsets.all(design.spacing.md), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, color: iconColor, size: design.iconSize.action), + SizedBox(height: design.spacing.sm), + AppText.bodySmall( + label, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +class _SuggestionRow extends StatelessWidget { + const _SuggestionRow({required this.leading, required this.trailing}); + + final Widget leading; + final Widget trailing; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: leading), + SizedBox(width: design.spacing.sm), + Expanded(child: trailing), + ], + ), + ); + } +} diff --git a/packages/ai_assistant/lib/widgets/ask_doubt_header.dart b/packages/ai_assistant/lib/widgets/ask_doubt_header.dart new file mode 100644 index 00000000..8cbf19c2 --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ask_doubt_header.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +class AskDoubtHeader extends StatelessWidget { + const AskDoubtHeader({ + super.key, + required this.onBack, + required this.onOpenMenu, + }); + + final VoidCallback onBack; + final VoidCallback onOpenMenu; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + return AppHeader( + title: '', + leftSafeArea: false, + rightSafeArea: false, + horizontalPadding: design.spacing.sm, + leading: GestureDetector( + onTap: onBack, + child: AppSemantics.button( + label: l10n.curriculumBackButton, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.arrowLeft, + color: design.colors.textPrimary, + size: design.iconSize.action, + ), + SizedBox(width: design.spacing.xs), + AppText.labelBold( + l10n.curriculumBackButton, + color: design.colors.textPrimary, + ), + ], + ), + ), + ), + actions: [ + GestureDetector( + onTap: onOpenMenu, + child: AppSemantics.button( + label: l10n.drawerMenuTitle, + child: Icon( + LucideIcons.menu, + color: design.colors.textPrimary, + size: design.iconSize.action, + ), + ), + ), + ], + ); + } +} diff --git a/packages/ai_assistant/lib/widgets/ask_doubt_history_drawer.dart b/packages/ai_assistant/lib/widgets/ask_doubt_history_drawer.dart new file mode 100644 index 00000000..623bc3e8 --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ask_doubt_history_drawer.dart @@ -0,0 +1,206 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +import '../models/ai_models.dart'; +import '../providers/doubt_session_provider.dart'; + +class AskDoubtHistoryDrawer extends StatelessWidget { + const AskDoubtHistoryDrawer({ + super.key, + required this.isOpen, + required this.sessionState, + required this.onDismiss, + required this.onNewChat, + required this.onSelectSession, + required this.onOpenSessionMenu, + }); + + final bool isOpen; + final DoubtSessionState sessionState; + final VoidCallback onDismiss; + final VoidCallback onNewChat; + final ValueChanged onSelectSession; + final void Function(String sessionId, Offset globalPosition) + onOpenSessionMenu; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isOpen + ? Stack( + children: [ + GestureDetector( + onTap: onDismiss, + child: Container(color: design.colors.overlay), + ), + TweenAnimationBuilder( + tween: Tween(begin: const Offset(1, 0), end: Offset.zero), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + builder: (context, offset, child) { + return Align( + alignment: Alignment.centerRight, + child: FractionalTranslation( + translation: offset, + child: child, + ), + ); + }, + child: Container( + width: design.layout.drawerWidth, + decoration: BoxDecoration( + color: design.colors.surface, + border: Border( + left: BorderSide(color: design.colors.border), + ), + ), + child: SafeArea( + left: false, + right: false, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(design.spacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.title(l10n.chapterStatusHistory), + GestureDetector( + onTap: onDismiss, + child: Icon( + LucideIcons.x, + size: design.iconSize.action, + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + ), + child: GestureDetector( + onTap: onNewChat, + child: Container( + padding: EdgeInsets.all(design.spacing.sm), + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: BorderRadius.circular( + design.radius.lg, + ), + border: Border.all( + color: design.colors.border, + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.plus, + size: design.iconSize.action, + ), + SizedBox(width: design.spacing.sm), + AppText.labelBold(l10n.aiDoubtNewChat), + ], + ), + ), + ), + ), + SizedBox(height: design.spacing.md), + Expanded( + child: ListView.builder( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + ), + itemCount: sessionState.history.length, + itemBuilder: (context, index) { + final session = sessionState.history[index]; + final isActive = + session.id == sessionState.activeSessionId; + return _HistoryRow( + session: session, + isActive: isActive, + onTap: () => onSelectSession(session.id), + onMenuTapDown: (details) => onOpenSessionMenu( + session.id, + details.globalPosition, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ], + ) + : const SizedBox.shrink(), + ); + } +} + +class _HistoryRow extends StatelessWidget { + const _HistoryRow({ + required this.session, + required this.isActive, + required this.onTap, + required this.onMenuTapDown, + }); + + final AIChatSession session; + final bool isActive; + final VoidCallback onTap; + final GestureTapDownCallback onMenuTapDown; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + + return GestureDetector( + onTap: onTap, + child: Container( + margin: EdgeInsets.only(bottom: design.spacing.xs), + padding: EdgeInsets.all(design.spacing.sm), + decoration: BoxDecoration( + color: isActive ? design.colors.surfaceVariant : null, + borderRadius: BorderRadius.circular(design.radius.md), + ), + child: Row( + children: [ + Icon( + session.isPinned ? LucideIcons.pin : LucideIcons.messageSquare, + size: design.iconSize.sm, + color: session.isPinned + ? design.colors.primary + : design.colors.textSecondary, + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: AppText.bodySmall( + session.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: isActive + ? design.colors.textPrimary + : design.colors.textSecondary, + ), + ), + GestureDetector( + onTapDown: onMenuTapDown, + child: Icon( + LucideIcons.moreHorizontal, + size: design.iconSize.sm, + color: design.colors.textTertiary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/ai_assistant/lib/widgets/ask_doubt_message_list.dart b/packages/ai_assistant/lib/widgets/ask_doubt_message_list.dart new file mode 100644 index 00000000..553b4300 --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ask_doubt_message_list.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +import '../models/ai_models.dart'; +import 'ai_message_bubble.dart'; + +class AskDoubtMessageList extends StatelessWidget { + const AskDoubtMessageList({ + super.key, + required this.scrollController, + required this.messages, + required this.isThinking, + required this.bottomPadding, + }); + + final ScrollController scrollController; + final List messages; + final bool isThinking; + final double bottomPadding; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + return ListView.builder( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + design.spacing.md, + design.spacing.md, + design.spacing.md, + bottomPadding, + ), + itemCount: messages.length + (isThinking ? 1 : 0), + itemBuilder: (context, index) { + if (index == messages.length) { + return Padding( + padding: EdgeInsets.only(bottom: design.spacing.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AppLoadingIndicator(), + SizedBox(width: design.spacing.sm), + AppText.caption( + l10n.aiDoubtThinking, + color: design.colors.textSecondary, + ), + ], + ), + ); + } + + final message = messages[index]; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 10 * (1 - value)), + child: child, + ), + ); + }, + child: AIMessageBubble(message: message), + ); + }, + ); + } +} diff --git a/packages/ai_assistant/lib/widgets/ask_doubt_overlays.dart b/packages/ai_assistant/lib/widgets/ask_doubt_overlays.dart new file mode 100644 index 00000000..f649f4da --- /dev/null +++ b/packages/ai_assistant/lib/widgets/ask_doubt_overlays.dart @@ -0,0 +1,230 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +import '../models/ai_models.dart'; +import '../providers/doubt_session_provider.dart'; + +class AskDoubtOverlays extends StatelessWidget { + const AskDoubtOverlays({ + super.key, + required this.menuSessionId, + required this.menuOffset, + required this.renamingSessionId, + required this.sessionState, + required this.renameController, + required this.renameFocusNode, + required this.onDismissMenu, + required this.onDismissRename, + required this.onTogglePin, + required this.onDelete, + required this.onStartRename, + required this.onSubmitRename, + }); + + final String? menuSessionId; + final Offset? menuOffset; + final String? renamingSessionId; + final DoubtSessionState sessionState; + final TextEditingController renameController; + final FocusNode renameFocusNode; + final VoidCallback onDismissMenu; + final VoidCallback onDismissRename; + final ValueChanged onTogglePin; + final ValueChanged onDelete; + final void Function(String sessionId, String title) onStartRename; + final void Function(String sessionId, String title) onSubmitRename; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + final currentSession = _findMenuSession(); + final menuSurfaceColor = design.isDark + ? design.colors.surfaceVariant + : design.colors.card; + + return Stack( + children: [ + if (menuSessionId != null && menuOffset != null) ...[ + GestureDetector( + onTap: onDismissMenu, + child: Container(color: design.colors.overlay.withValues(alpha: 0)), + ), + Positioned( + right: + MediaQuery.of(context).size.width - + menuOffset!.dx + + design.spacing.xs, + top: menuOffset!.dy - design.spacing.lg, + child: Container( + width: design.layout.drawerWidth * 0.5, + padding: EdgeInsets.symmetric(vertical: design.spacing.xs), + decoration: BoxDecoration( + color: menuSurfaceColor, + borderRadius: design.radius.dialog, + boxShadow: design.shadows.floating, + border: Border.all(color: design.colors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _MenuItem( + icon: LucideIcons.pin, + label: currentSession?.isPinned == true + ? l10n.aiDoubtUnpin + : l10n.aiDoubtPin, + onTap: () => onTogglePin(menuSessionId!), + ), + _MenuItem( + icon: LucideIcons.pencil, + label: l10n.aiDoubtRename, + onTap: () { + final session = sessionState.history.firstWhere( + (s) => s.id == menuSessionId, + ); + onStartRename(session.id, session.title); + }, + ), + _MenuItem( + icon: LucideIcons.share2, + label: l10n.certificatesShare, + onTap: onDismissMenu, + ), + _MenuItem( + icon: LucideIcons.trash2, + label: l10n.aiDoubtDelete, + isDestructive: true, + onTap: () => onDelete(menuSessionId!), + ), + ], + ), + ), + ), + ], + if (renamingSessionId != null) ...[ + GestureDetector( + onTap: onDismissRename, + child: Container(color: design.colors.overlay), + ), + Center( + child: Container( + width: + design.layout.drawerWidth + + design.spacing.md + + design.spacing.xs, + padding: EdgeInsets.all(design.spacing.md), + decoration: BoxDecoration( + color: design.colors.surface, + borderRadius: design.radius.card, + boxShadow: design.shadows.floating, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.title(l10n.aiDoubtRenameTitle), + SizedBox(height: design.spacing.md), + Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.sm, + vertical: design.spacing.xs, + ), + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: BorderRadius.circular(design.radius.sm), + border: Border.all(color: design.colors.border), + ), + child: EditableText( + controller: renameController, + focusNode: renameFocusNode, + cursorColor: design.colors.primary, + backgroundCursorColor: design.colors.textPrimary, + style: design.typography.body.copyWith( + color: design.colors.textPrimary, + ), + ), + ), + SizedBox(height: design.spacing.lg), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: onDismissRename, + child: AppText.labelBold( + l10n.labelCancel, + color: design.colors.textSecondary, + ), + ), + SizedBox(width: design.spacing.lg), + GestureDetector( + onTap: () => onSubmitRename( + renamingSessionId!, + renameController.text, + ), + child: AppText.labelBold( + l10n.editProfileSave, + color: design.colors.primary, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ], + ); + } + + AIChatSession? _findMenuSession() { + final sessionId = menuSessionId; + if (sessionId == null) return null; + + for (final session in sessionState.history) { + if (session.id == sessionId) return session; + } + + return null; + } +} + +class _MenuItem extends StatelessWidget { + const _MenuItem({ + required this.icon, + required this.label, + required this.onTap, + this.isDestructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool isDestructive; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final foregroundColor = isDestructive + ? design.colors.error + : design.colors.textPrimary; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.sm, + vertical: design.spacing.sm, + ), + color: design.colors.overlay.withValues(alpha: 0), + child: Row( + children: [ + Icon(icon, size: design.iconSize.action, color: foregroundColor), + SizedBox(width: design.spacing.sm), + AppText.bodySmall(label, color: foregroundColor), + ], + ), + ), + ); + } +} diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 99520f86..ebe343a5 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -2384,6 +2384,180 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'AI powered by your learning progress and exam performance'** String get aiAssistantPoweredBy; + + /// No description provided for @aiDoubtPlaceholder. + /// + /// In en, this message translates to: + /// **'Ask anything'** + String get aiDoubtPlaceholder; + + /// No description provided for @aiDoubtAttachAction. + /// + /// In en, this message translates to: + /// **'Attach'** + String get aiDoubtAttachAction; + + /// No description provided for @aiDoubtCameraAction. + /// + /// In en, this message translates to: + /// **'Take Photo'** + String get aiDoubtCameraAction; + + /// No description provided for @aiDoubtGalleryAction. + /// + /// In en, this message translates to: + /// **'Choose from Gallery'** + String get aiDoubtGalleryAction; + + /// No description provided for @aiDoubtVoiceAction. + /// + /// In en, this message translates to: + /// **'Voice'** + String get aiDoubtVoiceAction; + + /// No description provided for @aiDoubtSendAction. + /// + /// In en, this message translates to: + /// **'Send'** + String get aiDoubtSendAction; + + /// No description provided for @aiDoubtNewChat. + /// + /// In en, this message translates to: + /// **'New Chat'** + String get aiDoubtNewChat; + + /// No description provided for @aiDoubtPin. + /// + /// In en, this message translates to: + /// **'Pin'** + String get aiDoubtPin; + + /// No description provided for @aiDoubtUnpin. + /// + /// In en, this message translates to: + /// **'Unpin'** + String get aiDoubtUnpin; + + /// No description provided for @aiDoubtRename. + /// + /// In en, this message translates to: + /// **'Rename'** + String get aiDoubtRename; + + /// No description provided for @aiDoubtDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get aiDoubtDelete; + + /// No description provided for @aiDoubtRenameTitle. + /// + /// In en, this message translates to: + /// **'Rename Chat'** + String get aiDoubtRenameTitle; + + /// No description provided for @aiDoubtEmptyTitle. + /// + /// In en, this message translates to: + /// **'What can I help with?'** + String get aiDoubtEmptyTitle; + + /// No description provided for @aiDoubtSuggestionExplainLabel. + /// + /// In en, this message translates to: + /// **'Explain concept'** + String get aiDoubtSuggestionExplainLabel; + + /// No description provided for @aiDoubtSuggestionExplainPrompt. + /// + /// In en, this message translates to: + /// **'Explain this concept step by step'** + String get aiDoubtSuggestionExplainPrompt; + + /// No description provided for @aiDoubtSuggestionSolveLabel. + /// + /// In en, this message translates to: + /// **'Solve problem'** + String get aiDoubtSuggestionSolveLabel; + + /// No description provided for @aiDoubtSuggestionSolvePrompt. + /// + /// In en, this message translates to: + /// **'Solve this problem step by step'** + String get aiDoubtSuggestionSolvePrompt; + + /// No description provided for @aiDoubtSuggestionPracticeLabel. + /// + /// In en, this message translates to: + /// **'Practice questions'** + String get aiDoubtSuggestionPracticeLabel; + + /// No description provided for @aiDoubtSuggestionPracticePrompt. + /// + /// In en, this message translates to: + /// **'Show me similar practice questions'** + String get aiDoubtSuggestionPracticePrompt; + + /// No description provided for @aiDoubtSuggestionTipsLabel. + /// + /// In en, this message translates to: + /// **'Study tips'** + String get aiDoubtSuggestionTipsLabel; + + /// No description provided for @aiDoubtSuggestionTipsPrompt. + /// + /// In en, this message translates to: + /// **'Give me tips to understand this better'** + String get aiDoubtSuggestionTipsPrompt; + + /// No description provided for @aiDoubtThinking. + /// + /// In en, this message translates to: + /// **'AI is thinking...'** + String get aiDoubtThinking; + + /// No description provided for @aiDoubtImagePrompt. + /// + /// In en, this message translates to: + /// **'Analyze this picture and explain it:'** + String get aiDoubtImagePrompt; + + /// No description provided for @aiDoubtImageResponse. + /// + /// In en, this message translates to: + /// **'I see the image you uploaded. Based on the problem shown, here is the solution...'** + String get aiDoubtImageResponse; + + /// No description provided for @aiDoubtMockResponseConcept. + /// + /// In en, this message translates to: + /// **'Sure! This concept refers to the fundamental principle where...'** + String get aiDoubtMockResponseConcept; + + /// No description provided for @aiDoubtMockResponseSolve. + /// + /// In en, this message translates to: + /// **'To solve this, first identify the variables. Step 1: ...'** + String get aiDoubtMockResponseSolve; + + /// No description provided for @aiDoubtMockResponsePractice. + /// + /// In en, this message translates to: + /// **'Here are a few similar practice questions you can try next...'** + String get aiDoubtMockResponsePractice; + + /// No description provided for @aiDoubtMockResponseTips. + /// + /// In en, this message translates to: + /// **'Try breaking this into smaller steps and revising the core idea first...'** + String get aiDoubtMockResponseTips; + + /// No description provided for @aiDoubtMockResponseDefault. + /// + /// In en, this message translates to: + /// **'That is a great question. Let me help you with that...'** + String get aiDoubtMockResponseDefault; } class _AppLocalizationsDelegate diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index b5312b39..7f782760 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -1263,4 +1263,97 @@ class AppLocalizationsAr extends AppLocalizations { @override String get aiAssistantPoweredBy => 'الذكاء الاصطناعي مدعوم بتقدمك في التعلم وأدائك في الامتحان'; + + @override + String get aiDoubtPlaceholder => 'اسأل أي شيء'; + + @override + String get aiDoubtAttachAction => 'إرفاق'; + + @override + String get aiDoubtCameraAction => 'التقاط صورة'; + + @override + String get aiDoubtGalleryAction => 'اختيار من المعرض'; + + @override + String get aiDoubtVoiceAction => 'صوت'; + + @override + String get aiDoubtSendAction => 'إرسال'; + + @override + String get aiDoubtNewChat => 'محادثة جديدة'; + + @override + String get aiDoubtPin => 'تثبيت'; + + @override + String get aiDoubtUnpin => 'إلغاء التثبيت'; + + @override + String get aiDoubtRename => 'إعادة التسمية'; + + @override + String get aiDoubtDelete => 'حذف'; + + @override + String get aiDoubtRenameTitle => 'إعادة تسمية المحادثة'; + + @override + String get aiDoubtEmptyTitle => 'كيف يمكنني مساعدتك؟'; + + @override + String get aiDoubtSuggestionExplainLabel => 'اشرح المفهوم'; + + @override + String get aiDoubtSuggestionExplainPrompt => 'اشرح هذا المفهوم خطوة بخطوة'; + + @override + String get aiDoubtSuggestionSolveLabel => 'حل المسألة'; + + @override + String get aiDoubtSuggestionSolvePrompt => 'حل هذه المسألة خطوة بخطوة'; + + @override + String get aiDoubtSuggestionPracticeLabel => 'أسئلة تدريبية'; + + @override + String get aiDoubtSuggestionPracticePrompt => 'أرني أسئلة تدريبية مشابهة'; + + @override + String get aiDoubtSuggestionTipsLabel => 'نصائح للدراسة'; + + @override + String get aiDoubtSuggestionTipsPrompt => 'أعطني نصائح لفهم هذا بشكل أفضل'; + + @override + String get aiDoubtThinking => 'يفكر الذكاء الاصطناعي...'; + + @override + String get aiDoubtImagePrompt => 'حلّل هذه الصورة واشرحها:'; + + @override + String get aiDoubtImageResponse => + 'أرى الصورة التي رفعتها. بناءً على المسألة الظاهرة، إليك الحل...'; + + @override + String get aiDoubtMockResponseConcept => + 'بالتأكيد! يشير هذا المفهوم إلى المبدأ الأساسي الذي ينص على...'; + + @override + String get aiDoubtMockResponseSolve => + 'لحل هذا، حدّد المتغيرات أولًا. الخطوة 1: ...'; + + @override + String get aiDoubtMockResponsePractice => + 'إليك بعض الأسئلة التدريبية المشابهة التي يمكنك تجربتها بعد ذلك...'; + + @override + String get aiDoubtMockResponseTips => + 'حاول تقسيم هذا إلى خطوات أصغر وراجع الفكرة الأساسية أولًا...'; + + @override + String get aiDoubtMockResponseDefault => + 'هذا سؤال ممتاز. دعني أساعدك في ذلك...'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 8230b64d..8b1e18e3 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -1265,4 +1265,100 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aiAssistantPoweredBy => 'AI powered by your learning progress and exam performance'; + + @override + String get aiDoubtPlaceholder => 'Ask anything'; + + @override + String get aiDoubtAttachAction => 'Attach'; + + @override + String get aiDoubtCameraAction => 'Take Photo'; + + @override + String get aiDoubtGalleryAction => 'Choose from Gallery'; + + @override + String get aiDoubtVoiceAction => 'Voice'; + + @override + String get aiDoubtSendAction => 'Send'; + + @override + String get aiDoubtNewChat => 'New Chat'; + + @override + String get aiDoubtPin => 'Pin'; + + @override + String get aiDoubtUnpin => 'Unpin'; + + @override + String get aiDoubtRename => 'Rename'; + + @override + String get aiDoubtDelete => 'Delete'; + + @override + String get aiDoubtRenameTitle => 'Rename Chat'; + + @override + String get aiDoubtEmptyTitle => 'What can I help with?'; + + @override + String get aiDoubtSuggestionExplainLabel => 'Explain concept'; + + @override + String get aiDoubtSuggestionExplainPrompt => + 'Explain this concept step by step'; + + @override + String get aiDoubtSuggestionSolveLabel => 'Solve problem'; + + @override + String get aiDoubtSuggestionSolvePrompt => 'Solve this problem step by step'; + + @override + String get aiDoubtSuggestionPracticeLabel => 'Practice questions'; + + @override + String get aiDoubtSuggestionPracticePrompt => + 'Show me similar practice questions'; + + @override + String get aiDoubtSuggestionTipsLabel => 'Study tips'; + + @override + String get aiDoubtSuggestionTipsPrompt => + 'Give me tips to understand this better'; + + @override + String get aiDoubtThinking => 'AI is thinking...'; + + @override + String get aiDoubtImagePrompt => 'Analyze this picture and explain it:'; + + @override + String get aiDoubtImageResponse => + 'I see the image you uploaded. Based on the problem shown, here is the solution...'; + + @override + String get aiDoubtMockResponseConcept => + 'Sure! This concept refers to the fundamental principle where...'; + + @override + String get aiDoubtMockResponseSolve => + 'To solve this, first identify the variables. Step 1: ...'; + + @override + String get aiDoubtMockResponsePractice => + 'Here are a few similar practice questions you can try next...'; + + @override + String get aiDoubtMockResponseTips => + 'Try breaking this into smaller steps and revising the core idea first...'; + + @override + String get aiDoubtMockResponseDefault => + 'That is a great question. Let me help you with that...'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index b0924206..20c48777 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -1277,4 +1277,101 @@ class AppLocalizationsMl extends AppLocalizations { @override String get aiAssistantPoweredBy => 'നിങ്ങളുടെ പഠന പുരോഗതിയെയും പരീക്ഷാ പ്രകടനത്തെയും അടിസ്ഥാനമാക്കി പ്രവർത്തിക്കുന്ന AI'; + + @override + String get aiDoubtPlaceholder => 'എന്തും ചോദിക്കൂ'; + + @override + String get aiDoubtAttachAction => 'അറ്റാച്ച് ചെയ്യുക'; + + @override + String get aiDoubtCameraAction => 'Take Photo'; + + @override + String get aiDoubtGalleryAction => 'Choose from Gallery'; + + @override + String get aiDoubtVoiceAction => 'വോയ്സ്'; + + @override + String get aiDoubtSendAction => 'അയയ്ക്കുക'; + + @override + String get aiDoubtNewChat => 'പുതിയ ചാറ്റ്'; + + @override + String get aiDoubtPin => 'പിൻ ചെയ്യുക'; + + @override + String get aiDoubtUnpin => 'അൺപിൻ ചെയ്യുക'; + + @override + String get aiDoubtRename => 'പേരുമാറ്റുക'; + + @override + String get aiDoubtDelete => 'ഡിലീറ്റ് ചെയ്യുക'; + + @override + String get aiDoubtRenameTitle => 'ചാറ്റിന് പേരുമാറ്റുക'; + + @override + String get aiDoubtEmptyTitle => 'എനിക്ക് എന്ത് സഹായിക്കാം?'; + + @override + String get aiDoubtSuggestionExplainLabel => 'കോൺസെപ്റ്റ് വിശദീകരിക്കുക'; + + @override + String get aiDoubtSuggestionExplainPrompt => + 'ഈ കോൺസെപ്റ്റ് ഘട്ടംഘട്ടമായി വിശദീകരിക്കുക'; + + @override + String get aiDoubtSuggestionSolveLabel => 'പ്രശ്നം പരിഹരിക്കുക'; + + @override + String get aiDoubtSuggestionSolvePrompt => + 'ഈ പ്രശ്നം ഘട്ടംഘട്ടമായി പരിഹരിക്കുക'; + + @override + String get aiDoubtSuggestionPracticeLabel => 'പ്രാക്ടീസ് ചോദ്യങ്ങൾ'; + + @override + String get aiDoubtSuggestionPracticePrompt => + 'ഇതിന് സമാനമായ പ്രാക്ടീസ് ചോദ്യങ്ങൾ കാണിക്കുക'; + + @override + String get aiDoubtSuggestionTipsLabel => 'പഠന നിർദ്ദേശങ്ങൾ'; + + @override + String get aiDoubtSuggestionTipsPrompt => + 'ഇത് നന്നായി മനസ്സിലാക്കാൻ ചില നിർദ്ദേശങ്ങൾ തരൂ'; + + @override + String get aiDoubtThinking => 'AI ആലോചിക്കുന്നു...'; + + @override + String get aiDoubtImagePrompt => 'ഈ ചിത്രം വിശകലനം ചെയ്ത് വിശദീകരിക്കുക:'; + + @override + String get aiDoubtImageResponse => + 'നിങ്ങൾ അപ്‌ലോഡ് ചെയ്ത ചിത്രം ഞാൻ കണ്ടു. കാണിച്ചിരിക്കുന്ന പ്രശ്നത്തെ അടിസ്ഥാനമാക്കി, ഇതാ പരിഹാരം...'; + + @override + String get aiDoubtMockResponseConcept => + 'ശരി! ഈ കോൺസെപ്റ്റ് സൂചിപ്പിക്കുന്നത് അടിസ്ഥാന തത്വത്തെയാണ്, അതിൽ...'; + + @override + String get aiDoubtMockResponseSolve => + 'ഇത് പരിഹരിക്കാൻ ആദ്യം വേരിയബിളുകൾ കണ്ടെത്തുക. ഘട്ടം 1: ...'; + + @override + String get aiDoubtMockResponsePractice => + 'അടുത്തതായി നിങ്ങൾ പരീക്ഷിക്കാനാകുന്ന ചില സമാന പ്രാക്ടീസ് ചോദ്യങ്ങൾ ഇതാ...'; + + @override + String get aiDoubtMockResponseTips => + 'ഇത് ചെറിയ ഘട്ടങ്ങളാക്കി തിരിച്ച് അടിസ്ഥാന ആശയം ആദ്യം ആവർത്തിച്ച് നോക്കൂ...'; + + @override + String get aiDoubtMockResponseDefault => + 'ഇത് വളരെ നല്ലൊരു ചോദ്യം ആണ്. അതിൽ ഞാൻ നിങ്ങളെ സഹായിക്കാം...'; } diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 21f22bf6..9278128f 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -426,5 +426,32 @@ "aiAssistantStatusAnswered": "تمت الإجابة", "aiAssistantStatusProcessing": "جاري المعالجة", "aiAssistantStatusRevisit": "إعادة زيارة", - "aiAssistantPoweredBy": "الذكاء الاصطناعي مدعوم بتقدمك في التعلم وأدائك في الامتحان" + "aiAssistantPoweredBy": "الذكاء الاصطناعي مدعوم بتقدمك في التعلم وأدائك في الامتحان", + "aiDoubtPlaceholder": "اسأل أي شيء", + "aiDoubtAttachAction": "إرفاق", + "aiDoubtVoiceAction": "صوت", + "aiDoubtSendAction": "إرسال", + "aiDoubtNewChat": "محادثة جديدة", + "aiDoubtPin": "تثبيت", + "aiDoubtUnpin": "إلغاء التثبيت", + "aiDoubtRename": "إعادة التسمية", + "aiDoubtDelete": "حذف", + "aiDoubtRenameTitle": "إعادة تسمية المحادثة", + "aiDoubtEmptyTitle": "كيف يمكنني مساعدتك؟", + "aiDoubtSuggestionExplainLabel": "اشرح المفهوم", + "aiDoubtSuggestionExplainPrompt": "اشرح هذا المفهوم خطوة بخطوة", + "aiDoubtSuggestionSolveLabel": "حل المسألة", + "aiDoubtSuggestionSolvePrompt": "حل هذه المسألة خطوة بخطوة", + "aiDoubtSuggestionPracticeLabel": "أسئلة تدريبية", + "aiDoubtSuggestionPracticePrompt": "أرني أسئلة تدريبية مشابهة", + "aiDoubtSuggestionTipsLabel": "نصائح للدراسة", + "aiDoubtSuggestionTipsPrompt": "أعطني نصائح لفهم هذا بشكل أفضل", + "aiDoubtThinking": "يفكر الذكاء الاصطناعي...", + "aiDoubtImagePrompt": "حلّل هذه الصورة واشرحها:", + "aiDoubtImageResponse": "أرى الصورة التي رفعتها. بناءً على المسألة الظاهرة، إليك الحل...", + "aiDoubtMockResponseConcept": "بالتأكيد! يشير هذا المفهوم إلى المبدأ الأساسي الذي ينص على...", + "aiDoubtMockResponseSolve": "لحل هذا، حدّد المتغيرات أولًا. الخطوة 1: ...", + "aiDoubtMockResponsePractice": "إليك بعض الأسئلة التدريبية المشابهة التي يمكنك تجربتها بعد ذلك...", + "aiDoubtMockResponseTips": "حاول تقسيم هذا إلى خطوات أصغر وراجع الفكرة الأساسية أولًا...", + "aiDoubtMockResponseDefault": "هذا سؤال ممتاز. دعني أساعدك في ذلك..." } diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index 45d75d12..04111432 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -619,5 +619,34 @@ "aiAssistantStatusAnswered": "Answered", "aiAssistantStatusProcessing": "Processing", "aiAssistantStatusRevisit": "Revisit", - "aiAssistantPoweredBy": "AI powered by your learning progress and exam performance" + "aiAssistantPoweredBy": "AI powered by your learning progress and exam performance", + "aiDoubtPlaceholder": "Ask anything", + "aiDoubtAttachAction": "Attach", + "aiDoubtCameraAction": "Take Photo", + "aiDoubtGalleryAction": "Choose from Gallery", + "aiDoubtVoiceAction": "Voice", + "aiDoubtSendAction": "Send", + "aiDoubtNewChat": "New Chat", + "aiDoubtPin": "Pin", + "aiDoubtUnpin": "Unpin", + "aiDoubtRename": "Rename", + "aiDoubtDelete": "Delete", + "aiDoubtRenameTitle": "Rename Chat", + "aiDoubtEmptyTitle": "What can I help with?", + "aiDoubtSuggestionExplainLabel": "Explain concept", + "aiDoubtSuggestionExplainPrompt": "Explain this concept step by step", + "aiDoubtSuggestionSolveLabel": "Solve problem", + "aiDoubtSuggestionSolvePrompt": "Solve this problem step by step", + "aiDoubtSuggestionPracticeLabel": "Practice questions", + "aiDoubtSuggestionPracticePrompt": "Show me similar practice questions", + "aiDoubtSuggestionTipsLabel": "Study tips", + "aiDoubtSuggestionTipsPrompt": "Give me tips to understand this better", + "aiDoubtThinking": "AI is thinking...", + "aiDoubtImagePrompt": "Analyze this picture and explain it:", + "aiDoubtImageResponse": "I see the image you uploaded. Based on the problem shown, here is the solution...", + "aiDoubtMockResponseConcept": "Sure! This concept refers to the fundamental principle where...", + "aiDoubtMockResponseSolve": "To solve this, first identify the variables. Step 1: ...", + "aiDoubtMockResponsePractice": "Here are a few similar practice questions you can try next...", + "aiDoubtMockResponseTips": "Try breaking this into smaller steps and revising the core idea first...", + "aiDoubtMockResponseDefault": "That is a great question. Let me help you with that..." } diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index c4077ecc..27aa466b 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -426,5 +426,34 @@ "aiAssistantStatusAnswered": "മറുപടി നൽകി", "aiAssistantStatusProcessing": "നടന്നുകൊണ്ടിരിക്കുന്നു", "aiAssistantStatusRevisit": "വീണ്ടും സന്ദർശിക്കുക", - "aiAssistantPoweredBy": "നിങ്ങളുടെ പഠന പുരോഗതിയെയും പരീക്ഷാ പ്രകടനത്തെയും അടിസ്ഥാനമാക്കി പ്രവർത്തിക്കുന്ന AI" + "aiAssistantPoweredBy": "നിങ്ങളുടെ പഠന പുരോഗതിയെയും പരീക്ഷാ പ്രകടനത്തെയും അടിസ്ഥാനമാക്കി പ്രവർത്തിക്കുന്ന AI", + "aiDoubtPlaceholder": "എന്തും ചോദിക്കൂ", + "aiDoubtAttachAction": "അറ്റാച്ച് ചെയ്യുക", + "aiDoubtCameraAction": "ഫോട്ടോ എടുക്കുക", + "aiDoubtGalleryAction": "ഗാലറിയിൽ നിന്ന് തിരഞ്ഞെടുക്കുക", + "aiDoubtVoiceAction": "വോയ്സ്", + "aiDoubtSendAction": "അയയ്ക്കുക", + "aiDoubtNewChat": "പുതിയ ചാറ്റ്", + "aiDoubtPin": "പിൻ ചെയ്യുക", + "aiDoubtUnpin": "അൺപിൻ ചെയ്യുക", + "aiDoubtRename": "പേരുമാറ്റുക", + "aiDoubtDelete": "ഡിലീറ്റ് ചെയ്യുക", + "aiDoubtRenameTitle": "ചാറ്റിന് പേരുമാറ്റുക", + "aiDoubtEmptyTitle": "എനിക്ക് എന്ത് സഹായിക്കാം?", + "aiDoubtSuggestionExplainLabel": "കോൺസെപ്റ്റ് വിശദീകരിക്കുക", + "aiDoubtSuggestionExplainPrompt": "ഈ കോൺസെപ്റ്റ് ഘട്ടംഘട്ടമായി വിശദീകരിക്കുക", + "aiDoubtSuggestionSolveLabel": "പ്രശ്നം പരിഹരിക്കുക", + "aiDoubtSuggestionSolvePrompt": "ഈ പ്രശ്നം ഘട്ടംഘട്ടമായി പരിഹരിക്കുക", + "aiDoubtSuggestionPracticeLabel": "പ്രാക്ടീസ് ചോദ്യങ്ങൾ", + "aiDoubtSuggestionPracticePrompt": "ഇതിന് സമാനമായ പ്രാക്ടീസ് ചോദ്യങ്ങൾ കാണിക്കുക", + "aiDoubtSuggestionTipsLabel": "പഠന നിർദ്ദേശങ്ങൾ", + "aiDoubtSuggestionTipsPrompt": "ഇത് നന്നായി മനസ്സിലാക്കാൻ ചില നിർദ്ദേശങ്ങൾ തരൂ", + "aiDoubtThinking": "AI ആലോചിക്കുന്നു...", + "aiDoubtImagePrompt": "ഈ ചിത്രം വിശകലനം ചെയ്ത് വിശദീകരിക്കുക:", + "aiDoubtImageResponse": "നിങ്ങൾ അപ്‌ലോഡ് ചെയ്ത ചിത്രം ഞാൻ കണ്ടു. കാണിച്ചിരിക്കുന്ന പ്രശ്നത്തെ അടിസ്ഥാനമാക്കി, ഇതാ പരിഹാരം...", + "aiDoubtMockResponseConcept": "ശരി! ഈ കോൺസെപ്റ്റ് സൂചിപ്പിക്കുന്നത് അടിസ്ഥാന തത്വത്തെയാണ്, അതിൽ...", + "aiDoubtMockResponseSolve": "ഇത് പരിഹരിക്കാൻ ആദ്യം വേരിയബിളുകൾ കണ്ടെത്തുക. ഘട്ടം 1: ...", + "aiDoubtMockResponsePractice": "അടുത്തതായി നിങ്ങൾ പരീക്ഷിക്കാനാകുന്ന ചില സമാന പ്രാക്ടീസ് ചോദ്യങ്ങൾ ഇതാ...", + "aiDoubtMockResponseTips": "ഇത് ചെറിയ ഘട്ടങ്ങളാക്കി തിരിച്ച് അടിസ്ഥാന ആശയം ആദ്യം ആവർത്തിച്ച് നോക്കൂ...", + "aiDoubtMockResponseDefault": "ഇത് വളരെ നല്ലൊരു ചോദ്യം ആണ്. അതിൽ ഞാൻ നിങ്ങളെ സഹായിക്കാം..." } diff --git a/packages/core/lib/widgets/app_card.dart b/packages/core/lib/widgets/app_card.dart index 3c963d9c..38990a4f 100644 --- a/packages/core/lib/widgets/app_card.dart +++ b/packages/core/lib/widgets/app_card.dart @@ -14,6 +14,7 @@ class AppCard extends StatelessWidget { this.showBorder = true, this.showShadow = false, this.showFloatingShadow = false, + this.color, }); final Widget child; @@ -22,18 +23,17 @@ class AppCard extends StatelessWidget { final bool showBorder; final bool showShadow; final bool showFloatingShadow; + final Color? color; @override Widget build(BuildContext context) { final design = Design.of(context); - // Singular separation logic: premium design avoids border + shadow stacking. - // If shadow is requested, we suppress the border unless explicitly forced. final effectiveShowBorder = showBorder; final cardContent = Container( padding: padding ?? EdgeInsets.all(design.spacing.cardPadding), decoration: BoxDecoration( - color: design.colors.card, + color: color ?? design.colors.card, borderRadius: design.radius.card, border: effectiveShowBorder ? Border.all(color: design.colors.border, width: 1) diff --git a/packages/core/lib/widgets/app_header.dart b/packages/core/lib/widgets/app_header.dart index 4f3d492e..30f60c23 100644 --- a/packages/core/lib/widgets/app_header.dart +++ b/packages/core/lib/widgets/app_header.dart @@ -14,12 +14,20 @@ class AppHeader extends StatelessWidget { this.subtitle, this.leading, this.actions, + this.horizontalPadding, + this.leftSafeArea = true, + this.rightSafeArea = true, + this.topSafeArea = true, }); final String title; final String? subtitle; final Widget? leading; final List? actions; + final double? horizontalPadding; + final bool leftSafeArea; + final bool rightSafeArea; + final bool topSafeArea; @override Widget build(BuildContext context) { @@ -32,12 +40,15 @@ class AppHeader extends StatelessWidget { ), ), child: SafeArea( + top: topSafeArea, + left: leftSafeArea, + right: rightSafeArea, bottom: false, child: Padding( padding: EdgeInsetsDirectional.fromSTEB( - design.spacing.screenPadding, + horizontalPadding ?? design.spacing.screenPadding, design.spacing.md, - design.spacing.screenPadding, + horizontalPadding ?? design.spacing.screenPadding, design.spacing.md, ), child: Row( diff --git a/packages/profile/pubspec.lock b/packages/profile/pubspec.lock index 909222bf..784ad2f9 100644 --- a/packages/profile/pubspec.lock +++ b/packages/profile/pubspec.lock @@ -448,10 +448,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -749,10 +749,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timing: dependency: transitive description: