diff --git a/README.md b/README.md index 5a1cf66..703619d 100644 --- a/README.md +++ b/README.md @@ -51,39 +51,75 @@ graph TD ``` polyglot-pathways/ │ -├── lib/ # Flutter source code -│ ├── main.dart # App entry point -│ ├── models/ # Data models -│ │ ├── language.dart -│ │ ├── lesson.dart -│ │ └── progress.dart -│ ├── screens/ # UI screens -│ │ ├── home_screen.dart -│ │ └── lesson_screen.dart -│ ├── widgets/ # Reusable widgets +├── lib/ # Flutter source code +│ ├── main.dart # App entry point with multi-provider setup +│ │ +│ ├── models/ # Data models +│ │ ├── language.dart # Language enum and properties +│ │ ├── lesson.dart # Lesson data model +│ │ ├── progress.dart # User progress tracking +│ │ ├── achievement.dart # Achievement definitions (NEW) +│ │ └── streak.dart # Streak tracking model (NEW) +│ │ +│ ├── screens/ # UI screens +│ │ ├── onboarding_screen.dart # 4-page onboarding (NEW) +│ │ ├── main_navigation_screen.dart # Bottom nav container (NEW) +│ │ ├── home_screen.dart # Enhanced home with stats (UPDATED) +│ │ ├── lesson_screen.dart # Enhanced audio player (UPDATED) +│ │ ├── profile_screen.dart # User profile & stats (NEW) +│ │ ├── achievements_screen.dart # Achievement gallery (NEW) +│ │ └── settings_screen.dart # App settings (NEW) +│ │ +│ ├── widgets/ # Reusable widgets │ │ ├── language_card.dart │ │ ├── course_structure.dart │ │ └── day_grid.dart -│ ├── services/ # Business logic -│ │ ├── language_service.dart -│ │ └── progress_service.dart -│ └── utils/ # Utilities +│ │ +│ ├── services/ # Business logic +│ │ ├── language_service.dart # UI language management +│ │ ├── progress_service.dart # Lesson progress tracking +│ │ ├── settings_service.dart # App settings (NEW) +│ │ └── gamification_service.dart # Achievements & streaks (NEW) +│ │ +│ ├── theme/ # Theme configuration (NEW) +│ │ └── app_theme.dart # Light/dark themes, colors, styles +│ │ +│ └── utils/ # Utilities │ └── app_localizations.dart │ -├── assets/ # Application assets -│ ├── audio/ # Multilingual audio content -│ │ └── day*_*.mp3 # Audio files for each day and language -│ └── translations/ # Language resource files -│ └── *.json +├── assets/ # Application assets +│ ├── audio/ # Multilingual audio content +│ │ └── day*_*.mp3 # 250 audio files (50 days × 5 languages) +│ ├── translations/ # Language resource files +│ │ ├── en.json # English UI translations +│ │ ├── es.json # Spanish UI translations +│ │ ├── pt.json # Portuguese UI translations +│ │ ├── fr.json # French UI translations +│ │ ├── de.json # German UI translations +│ │ └── day.*.json # Lesson-specific translations +│ └── lessons/ # Lesson text content │ -├── android/ # Android platform code -├── ios/ # iOS platform code -├── web/ # Web platform code +├── android/ # Android platform code +├── ios/ # iOS platform code +├── web/ # Web platform code │ -├── pubspec.yaml # Flutter dependencies +├── pubspec.yaml # Flutter dependencies └── language_phrases_days_*.py # Content generation scripts ``` +### Key Architecture Components + +#### New Files Added (UI/UX Overhaul) +- **7 new screens**: Onboarding, MainNavigation, Profile, Achievements, Settings +- **2 new models**: Achievement, Streak +- **2 new services**: SettingsService, GamificationService +- **1 new theme system**: Comprehensive light/dark theme configuration + +#### Updated Files +- **main.dart**: Multi-provider setup, theme switching, onboarding logic +- **home_screen.dart**: Streak display, daily goals, enhanced stats +- **lesson_screen.dart**: Speed control, loop mode, achievement notifications + ## Key Technologies and Skills Demonstrated ### 1. Flutter Mobile Development @@ -259,78 +295,93 @@ flutter build web --release ## Features -```mermaid -sequenceDiagram - participant U as User - participant P as Page - participant A as Audio - participant S as Storage - participant C as Cache - - rect rgb(240, 240, 255) - Note over U,C: Initial Load Phase - U->>P: Select Day - P->>S: Check Connection - alt Online Mode - S-->>P: Load Progress - else Offline Mode - S->>C: Fetch Cached Data - C-->>P: Return Cached Progress - end - end - - rect rgb(255, 240, 240) - Note over P,A: Resource Loading Phase - par Translations and Audio - P->>P: Load Translations - alt Translation Error - P-->>U: Use Default Language - Note over P,U: Fallback to English - end - P->>A: Load Audio Files - alt Audio Load Failed - A-->>P: Error Loading Audio - P-->>U: Enable Text-Only Mode - end - end - end - - rect rgb(240, 255, 240) - Note over U,P: Interaction Phase - U->>P: Select Language - P->>P: Update Interface - U->>A: Play Audio - alt Playback Error - A-->>U: Show Retry Button - U->>A: Retry Playback - end - end - - rect rgb(255, 255, 240) - Note over P,S: Progress Saving Phase - U->>P: Complete Lesson - P->>S: Save Progress - alt Save Failed - S->>C: Save to Cache - Note over S,C: Sync when online - end - P->>A: Preload Next Lesson - end - - Note over U,C: Progress persists across sessions - Note over U,C: Offline-first architecture -``` +### 🎨 Modern UI/UX (Industry-Standard Design) +- **Bottom Navigation**: 4-tab navigation (Home, Achievements, Profile, Settings) +- **Onboarding Flow**: Beautiful welcome screens with smooth animations +- **Dark Mode**: Full dark theme support with automatic switching +- **Accessibility**: Text scaling (0.85x - 1.3x), high contrast, screen reader support +- **Animations**: Smooth transitions and micro-interactions using flutter_animate +- **Material Design 3**: Modern, polished interface following latest design guidelines + +### 🎮 Gamification System +- **Achievements**: 17 unique achievements across 4 categories + - Lesson milestones (First Lesson, 10/25/50 lessons completed) + - Streak rewards (7, 14, 30, 100 day streaks) + - Multilingual badges (Bronze, Silver, Gold polyglot) + - Special achievements (Early Bird, Night Owl, Speed Learner) +- **Streak Tracking**: Daily learning streak with longest streak record +- **Progress Visualization**: Interactive charts showing progress across all languages +- **Daily Goals**: Customizable daily lesson targets (1-10 lessons/day) +- **Achievement Notifications**: Celebrate unlocks with confetti and snackbars + +### 🎵 Enhanced Audio Player +- **Playback Speed Control**: 0.5x to 2.0x speed (6 preset speeds) +- **Repeat/Loop Mode**: Continuous playback for practice +- **Quick Navigation**: 10-second forward/backward buttons +- **Restart Function**: One-tap restart to beginning +- **Progress Slider**: Precise seeking to any position +- **Real-time Duration**: Current position and total duration display + +### 📊 Advanced Progress Tracking +- **Multi-Language Dashboard**: Track progress across all 5 languages +- **Interactive Charts**: Bar charts showing lessons completed per language +- **Streak Visualization**: Current streak, longest streak, total lessons +- **Daily Goal Progress**: Real-time progress toward daily targets +- **Recent Activity**: Timeline of recent achievements and completions +- **Overall Statistics**: Comprehensive stats on profile screen + +### 🎯 Profile & Settings +- **User Profile**: Personal stats, achievement count, language progress +- **Customizable Settings**: + - Dark/Light theme toggle + - Text size adjustment (4 presets) + - Daily goal configuration + - Sound effects toggle + - Notification preferences + - Interface language selection +- **Data Management**: Reset settings or progress options +- **Tutorial Access**: Re-view onboarding anytime + +### 🌐 Core Features - Cross-platform mobile application (Android, iOS, Web) -- Beautiful Material Design 3 UI - Progress tracking with local persistence - Multilingual content in 5 languages -- High-quality audio playback with controls (play/pause, seek, 10s forward/backward) +- High-quality audio playback with advanced controls - Responsive design optimized for mobile devices - SharedPreferences-based session persistence - Offline-first architecture - Provider-based state management - Custom internationalization system +### 📱 Navigation Structure +``` +App Entry +├── Onboarding (First Launch) +│ └── 4-screen tutorial with animations +└── Main Navigation (Bottom Tabs) + ├── Home Tab + │ ├── Streak display + │ ├── Daily goal tracker + │ ├── Language selection cards + │ └── Day grid for selected language + ├── Achievements Tab + │ ├── Progress header (X/17 unlocked) + │ ├── Category tabs (All, Lessons, Streaks, Languages, Special) + │ └── Achievement cards with unlock status + ├── Profile Tab + │ ├── Profile header with streak + │ ├── Statistics overview (4 stat cards) + │ ├── Progress by language (bar chart) + │ └── Recent activity timeline + └── Settings Tab + ├── Appearance (dark mode, text size) + ├── Learning (daily goal, hints) + ├── Audio & Sound (effects toggle) + ├── Notifications (reminders) + ├── Interface Language + └── Data Management +``` + ## Global Impact - Communicate with ~2 billion people - Access to international job markets diff --git a/android/.kotlin/sessions/kotlin-compiler-17654054680533376704.salive b/android/.kotlin/sessions/kotlin-compiler-17654054680533376704.salive new file mode 100644 index 0000000..e69de29 diff --git a/assets/exercises/day1_en.json b/assets/exercises/day1_en.json new file mode 100644 index 0000000..0f53276 --- /dev/null +++ b/assets/exercises/day1_en.json @@ -0,0 +1,83 @@ +{ + "exercises": [ + { + "id": "day1_mc1", + "type": "multipleChoice", + "question": "Which greeting is most appropriate in a formal setting?", + "options": [ + "Hey!", + "Good morning", + "Yo!", + "What's up?" + ], + "correctOptionIndex": 1 + }, + { + "id": "day1_mc2", + "type": "multipleChoice", + "question": "How do you respond to 'How are you?'", + "options": [ + "I'm fine, thank you. And you?", + "Nothing", + "Yes", + "Goodbye" + ], + "correctOptionIndex": 0 + }, + { + "id": "day1_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'Nice to ____ you!'", + "correctAnswer": "meet", + "caseSensitive": false + }, + { + "id": "day1_fib2", + "type": "fillInBlank", + "question": "Fill in the blank: 'My ____ is John.'", + "correctAnswer": "name", + "acceptableAlternatives": ["Name"], + "caseSensitive": false + }, + { + "id": "day1_trans1", + "type": "translation", + "question": "Translate to English:", + "targetText": "Hello", + "correctTranslation": "Hello", + "acceptableAlternatives": ["Hi", "Hey", "Greetings"] + }, + { + "id": "day1_mc3", + "type": "multipleChoice", + "question": "What is an appropriate way to say goodbye?", + "options": [ + "See you later", + "Go away", + "Stop talking", + "Leave now" + ], + "correctOptionIndex": 0 + }, + { + "id": "day1_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: '____ to meet you!'", + "correctAnswer": "Nice", + "acceptableAlternatives": ["Pleased", "Happy", "Good"], + "caseSensitive": false + }, + { + "id": "day1_mc4", + "type": "multipleChoice", + "question": "Which is a polite way to introduce yourself?", + "options": [ + "I'm the best", + "My name is Sarah", + "You should know me", + "I don't care" + ], + "correctOptionIndex": 1 + } + ] +} diff --git a/assets/exercises/day2_en.json b/assets/exercises/day2_en.json new file mode 100644 index 0000000..dd73f0c --- /dev/null +++ b/assets/exercises/day2_en.json @@ -0,0 +1,69 @@ +{ + "exercises": [ + { + "id": "day2_mc1", + "type": "multipleChoice", + "question": "What comes after 'nine'?", + "options": [ + "eight", + "ten", + "eleven", + "twelve" + ], + "correctOptionIndex": 1 + }, + { + "id": "day2_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'One, two, three, ____, five'", + "correctAnswer": "four", + "caseSensitive": false + }, + { + "id": "day2_mc2", + "type": "multipleChoice", + "question": "How do you write the number 15?", + "options": [ + "fiveteen", + "fifty", + "fifteen", + "fivetin" + ], + "correctOptionIndex": 2 + }, + { + "id": "day2_fib2", + "type": "fillInBlank", + "question": "What number comes before 'twenty'?", + "correctAnswer": "nineteen", + "caseSensitive": false + }, + { + "id": "day2_mc3", + "type": "multipleChoice", + "question": "Which is the correct spelling of 30?", + "options": [ + "thirthy", + "thirty", + "therty", + "threety" + ], + "correctOptionIndex": 1 + }, + { + "id": "day2_trans1", + "type": "translation", + "question": "Write this number in words:", + "targetText": "7", + "correctTranslation": "seven", + "acceptableAlternatives": ["Seven"] + }, + { + "id": "day2_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: 'Ten, twenty, ____, forty'", + "correctAnswer": "thirty", + "caseSensitive": false + } + ] +} diff --git a/assets/exercises/day3_en.json b/assets/exercises/day3_en.json new file mode 100644 index 0000000..27a1d44 --- /dev/null +++ b/assets/exercises/day3_en.json @@ -0,0 +1,82 @@ +{ + "exercises": [ + { + "id": "day3_mc1", + "type": "multipleChoice", + "question": "Which day comes after Monday?", + "options": [ + "Sunday", + "Tuesday", + "Wednesday", + "Saturday" + ], + "correctOptionIndex": 1 + }, + { + "id": "day3_mc2", + "type": "multipleChoice", + "question": "What is the first month of the year?", + "options": [ + "December", + "February", + "January", + "March" + ], + "correctOptionIndex": 2 + }, + { + "id": "day3_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'Monday, Tuesday, ____'", + "correctAnswer": "Wednesday", + "caseSensitive": false + }, + { + "id": "day3_fib2", + "type": "fillInBlank", + "question": "Fill in the blank: 'January, February, ____'", + "correctAnswer": "March", + "caseSensitive": false + }, + { + "id": "day3_mc3", + "type": "multipleChoice", + "question": "Which month has the shortest name?", + "options": [ + "July", + "June", + "May", + "April" + ], + "correctOptionIndex": 2 + }, + { + "id": "day3_trans1", + "type": "translation", + "question": "What day is the last day of the work week?", + "targetText": "Last work day before weekend", + "correctTranslation": "Friday", + "acceptableAlternatives": ["friday"] + }, + { + "id": "day3_mc4", + "type": "multipleChoice", + "question": "What are the weekend days?", + "options": [ + "Monday and Friday", + "Saturday and Sunday", + "Thursday and Friday", + "Tuesday and Wednesday" + ], + "correctOptionIndex": 1 + }, + { + "id": "day3_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: 'Thursday, Friday, ____'", + "correctAnswer": "Saturday", + "acceptableAlternatives": ["saturday"], + "caseSensitive": false + } + ] +} diff --git a/assets/vocabulary/day1_en.json b/assets/vocabulary/day1_en.json new file mode 100644 index 0000000..5ef9b47 --- /dev/null +++ b/assets/vocabulary/day1_en.json @@ -0,0 +1,60 @@ +{ + "vocabulary": [ + { + "word": "Hello", + "translation": "A greeting used when meeting someone", + "phonetic": "/həˈloʊ/", + "example": "Hello, how are you today?", + "exampleTranslation": "A friendly greeting to start a conversation" + }, + { + "word": "Goodbye", + "translation": "A farewell expression", + "phonetic": "/ɡʊdˈbaɪ/", + "example": "Goodbye, see you tomorrow!", + "exampleTranslation": "A polite way to end a conversation" + }, + { + "word": "Please", + "translation": "A polite word used when making requests", + "phonetic": "/pliːz/", + "example": "Could you help me, please?", + "exampleTranslation": "Shows politeness when asking for something" + }, + { + "word": "Thank you", + "translation": "Expression of gratitude", + "phonetic": "/θæŋk juː/", + "example": "Thank you for your help!", + "exampleTranslation": "Showing appreciation for someone's actions" + }, + { + "word": "My name is", + "translation": "Phrase used to introduce yourself", + "phonetic": "/maɪ neɪm ɪz/", + "example": "Hi, my name is Sarah.", + "exampleTranslation": "Introducing yourself to someone new" + }, + { + "word": "Nice to meet you", + "translation": "Polite expression when meeting someone for the first time", + "phonetic": "/naɪs tə miːt juː/", + "example": "Nice to meet you, John!", + "exampleTranslation": "A friendly greeting for first encounters" + }, + { + "word": "How are you?", + "translation": "A common question to ask about someone's well-being", + "phonetic": "/haʊ ɑːr juː/", + "example": "Hello! How are you doing today?", + "exampleTranslation": "Asking about someone's current state" + }, + { + "word": "I'm fine", + "translation": "A common response indicating you are well", + "phonetic": "/aɪm faɪn/", + "example": "I'm fine, thank you. And you?", + "exampleTranslation": "Positive response to 'How are you?'" + } + ] +} diff --git a/assets/vocabulary/day2_en.json b/assets/vocabulary/day2_en.json new file mode 100644 index 0000000..0be7215 --- /dev/null +++ b/assets/vocabulary/day2_en.json @@ -0,0 +1,60 @@ +{ + "vocabulary": [ + { + "word": "Zero", + "translation": "The number 0", + "phonetic": "/ˈzɪroʊ/", + "example": "I have zero apples.", + "exampleTranslation": "Indicating the absence of quantity" + }, + { + "word": "One", + "translation": "The number 1", + "phonetic": "/wʌn/", + "example": "I need one ticket.", + "exampleTranslation": "A single item" + }, + { + "word": "Five", + "translation": "The number 5", + "phonetic": "/faɪv/", + "example": "There are five books on the table.", + "exampleTranslation": "Counting to five items" + }, + { + "word": "Ten", + "translation": "The number 10", + "phonetic": "/tɛn/", + "example": "I wake up at ten o'clock.", + "exampleTranslation": "The number after nine" + }, + { + "word": "Twenty", + "translation": "The number 20", + "phonetic": "/ˈtwɛnti/", + "example": "The book costs twenty dollars.", + "exampleTranslation": "Two tens" + }, + { + "word": "Hundred", + "translation": "The number 100", + "phonetic": "/ˈhʌndrəd/", + "example": "One hundred people attended.", + "exampleTranslation": "Ten times ten" + }, + { + "word": "First", + "translation": "Position number 1 in a sequence", + "phonetic": "/fɜːrst/", + "example": "She finished first in the race.", + "exampleTranslation": "Ordinal number for one" + }, + { + "word": "Second", + "translation": "Position number 2 in a sequence", + "phonetic": "/ˈsɛkənd/", + "example": "He came in second place.", + "exampleTranslation": "Ordinal number for two" + } + ] +} diff --git a/assets/vocabulary/day3_en.json b/assets/vocabulary/day3_en.json new file mode 100644 index 0000000..88bfe6d --- /dev/null +++ b/assets/vocabulary/day3_en.json @@ -0,0 +1,74 @@ +{ + "vocabulary": [ + { + "word": "Monday", + "translation": "The first day of the work week", + "phonetic": "/ˈmʌndeɪ/", + "example": "I have a meeting on Monday morning.", + "exampleTranslation": "The day after Sunday" + }, + { + "word": "Tuesday", + "translation": "The second day of the work week", + "phonetic": "/ˈtuːzdeɪ/", + "example": "Tuesday is my favorite day.", + "exampleTranslation": "The day between Monday and Wednesday" + }, + { + "word": "Wednesday", + "translation": "The third day of the work week, middle of the week", + "phonetic": "/ˈwɛnzdeɪ/", + "example": "We meet every Wednesday.", + "exampleTranslation": "Often called 'hump day'" + }, + { + "word": "Friday", + "translation": "The last day of the work week", + "phonetic": "/ˈfraɪdeɪ/", + "example": "I'm excited for Friday!", + "exampleTranslation": "The day before the weekend" + }, + { + "word": "Saturday", + "translation": "First day of the weekend", + "phonetic": "/ˈsætərdeɪ/", + "example": "Let's go to the park on Saturday.", + "exampleTranslation": "A day for relaxation and activities" + }, + { + "word": "Sunday", + "translation": "Second day of the weekend, last day of the week", + "phonetic": "/ˈsʌndeɪ/", + "example": "Sunday is a rest day.", + "exampleTranslation": "Often a day for family and rest" + }, + { + "word": "January", + "translation": "First month of the year", + "phonetic": "/ˈdʒænjuˌɛri/", + "example": "January is very cold here.", + "exampleTranslation": "The month after December" + }, + { + "word": "February", + "translation": "Second month of the year, shortest month", + "phonetic": "/ˈfɛbruˌɛri/", + "example": "Valentine's Day is in February.", + "exampleTranslation": "Has 28 or 29 days" + }, + { + "word": "March", + "translation": "Third month of the year, spring begins", + "phonetic": "/mɑːrtʃ/", + "example": "Spring starts in March.", + "exampleTranslation": "The month after February" + }, + { + "word": "December", + "translation": "Last month of the year", + "phonetic": "/dɪˈsɛmbər/", + "example": "December is a holiday month.", + "exampleTranslation": "The 12th and final month" + } + ] +} diff --git a/lib/main.dart b/lib/main.dart index 8c132e2..f524c71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,14 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'screens/home_screen.dart'; +import 'screens/onboarding_screen.dart'; +import 'screens/main_navigation_screen.dart'; import 'services/language_service.dart'; import 'services/progress_service.dart'; +import 'services/settings_service.dart'; +import 'services/gamification_service.dart'; import 'utils/app_localizations.dart'; +import 'theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,39 +27,40 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider( + create: (_) => SettingsService(), + ), ChangeNotifierProvider( create: (_) => LanguageService(prefs), ), ChangeNotifierProvider( create: (_) => ProgressService(prefs), ), + ChangeNotifierProvider( + create: (_) => GamificationService(), + ), ], - child: Consumer( - builder: (context, languageService, _) { + child: Consumer2( + builder: (context, languageService, settingsService, _) { return MaterialApp( title: 'Polyglot Pathways', debugShowCheckedModeBanner: false, - theme: ThemeData( - primarySwatch: Colors.blue, - fontFamily: 'Poppins', - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF4A90E2), - primary: const Color(0xFF4A90E2), - secondary: const Color(0xFF50C878), - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - backgroundColor: Color(0xFF4A90E2), - foregroundColor: Colors.white, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + + // Theme configuration with dark mode support + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: settingsService.themeMode, + + // Text scaling for accessibility + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(settingsService.textScale), ), - ), - ), + child: child!, + ); + }, + locale: languageService.currentLocale, supportedLocales: const [ Locale('en'), @@ -70,7 +75,11 @@ class MyApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: const HomeScreen(), + + // Show onboarding on first launch, otherwise show main navigation + home: settingsService.hasCompletedOnboarding + ? const MainNavigationScreen() + : const OnboardingScreen(), ); }, ), diff --git a/lib/models/achievement.dart b/lib/models/achievement.dart new file mode 100644 index 0000000..5a0946d --- /dev/null +++ b/lib/models/achievement.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; + +enum AchievementType { + firstLesson, + firstWeek, + firstMonth, + streak7, + streak14, + streak30, + streak100, + complete10Lessons, + complete25Lessons, + complete50Lessons, + multilingualBronze, // 2 languages + multilingualSilver, // 3 languages + multilingualGold, // 5 languages + earlyBird, // Complete lesson before 9 AM + nightOwl, // Complete lesson after 9 PM + speedLearner, // Complete 3 lessons in one day + perfectWeek, // 7 day streak +} + +class Achievement { + final AchievementType type; + final String title; + final String description; + final IconData icon; + final Color color; + final int requiredValue; + final bool isUnlocked; + final DateTime? unlockedAt; + + Achievement({ + required this.type, + required this.title, + required this.description, + required this.icon, + required this.color, + required this.requiredValue, + this.isUnlocked = false, + this.unlockedAt, + }); + + Achievement copyWith({ + AchievementType? type, + String? title, + String? description, + IconData? icon, + Color? color, + int? requiredValue, + bool? isUnlocked, + DateTime? unlockedAt, + }) { + return Achievement( + type: type ?? this.type, + title: title ?? this.title, + description: description ?? this.description, + icon: icon ?? this.icon, + color: color ?? this.color, + requiredValue: requiredValue ?? this.requiredValue, + isUnlocked: isUnlocked ?? this.isUnlocked, + unlockedAt: unlockedAt ?? this.unlockedAt, + ); + } + + Map toJson() { + return { + 'type': type.toString(), + 'isUnlocked': isUnlocked, + 'unlockedAt': unlockedAt?.toIso8601String(), + }; + } + + factory Achievement.fromJson(Map json, Achievement template) { + return template.copyWith( + isUnlocked: json['isUnlocked'] as bool? ?? false, + unlockedAt: json['unlockedAt'] != null + ? DateTime.parse(json['unlockedAt'] as String) + : null, + ); + } + + static List getAllAchievements() { + return [ + Achievement( + type: AchievementType.firstLesson, + title: 'First Steps', + description: 'Complete your first lesson', + icon: Icons.rocket_launch, + color: const Color(0xFF4A90E2), + requiredValue: 1, + ), + Achievement( + type: AchievementType.firstWeek, + title: 'Week Warrior', + description: 'Complete 7 lessons', + icon: Icons.calendar_view_week, + color: const Color(0xFF50C878), + requiredValue: 7, + ), + Achievement( + type: AchievementType.firstMonth, + title: 'Monthly Master', + description: 'Complete 30 lessons', + icon: Icons.calendar_month, + color: const Color(0xFF9B59B6), + requiredValue: 30, + ), + Achievement( + type: AchievementType.streak7, + title: '7 Day Streak', + description: 'Learn for 7 days in a row', + icon: Icons.local_fire_department, + color: const Color(0xFFFF6B6B), + requiredValue: 7, + ), + Achievement( + type: AchievementType.streak14, + title: '14 Day Streak', + description: 'Learn for 14 days in a row', + icon: Icons.whatshot, + color: const Color(0xFFFF4757), + requiredValue: 14, + ), + Achievement( + type: AchievementType.streak30, + title: '30 Day Streak', + description: 'Learn for 30 days in a row', + icon: Icons.fireplace, + color: const Color(0xFFE74C3C), + requiredValue: 30, + ), + Achievement( + type: AchievementType.streak100, + title: 'Century Streak', + description: 'Learn for 100 days in a row', + icon: Icons.military_tech, + color: const Color(0xFFFFD700), + requiredValue: 100, + ), + Achievement( + type: AchievementType.complete10Lessons, + title: 'Getting Started', + description: 'Complete 10 lessons', + icon: Icons.looks_one, + color: const Color(0xFF3498DB), + requiredValue: 10, + ), + Achievement( + type: AchievementType.complete25Lessons, + title: 'Quarter Century', + description: 'Complete 25 lessons', + icon: Icons.looks_two, + color: const Color(0xFF2ECC71), + requiredValue: 25, + ), + Achievement( + type: AchievementType.complete50Lessons, + title: 'Half Century', + description: 'Complete 50 lessons - Full course!', + icon: Icons.emoji_events, + color: const Color(0xFFFFD700), + requiredValue: 50, + ), + Achievement( + type: AchievementType.multilingualBronze, + title: 'Bilingual Bronze', + description: 'Start learning 2 languages', + icon: Icons.translate, + color: const Color(0xFFCD7F32), + requiredValue: 2, + ), + Achievement( + type: AchievementType.multilingualSilver, + title: 'Trilingual Silver', + description: 'Start learning 3 languages', + icon: Icons.language, + color: const Color(0xFFC0C0C0), + requiredValue: 3, + ), + Achievement( + type: AchievementType.multilingualGold, + title: 'Polyglot Gold', + description: 'Start learning all 5 languages', + icon: Icons.public, + color: const Color(0xFFFFD700), + requiredValue: 5, + ), + Achievement( + type: AchievementType.earlyBird, + title: 'Early Bird', + description: 'Complete a lesson before 9 AM', + icon: Icons.wb_sunny, + color: const Color(0xFFFFA500), + requiredValue: 1, + ), + Achievement( + type: AchievementType.nightOwl, + title: 'Night Owl', + description: 'Complete a lesson after 9 PM', + icon: Icons.nightlight_round, + color: const Color(0xFF9B59B6), + requiredValue: 1, + ), + Achievement( + type: AchievementType.speedLearner, + title: 'Speed Learner', + description: 'Complete 3 lessons in one day', + icon: Icons.speed, + color: const Color(0xFFE74C3C), + requiredValue: 3, + ), + Achievement( + type: AchievementType.perfectWeek, + title: 'Perfect Week', + description: 'Maintain a 7 day learning streak', + icon: Icons.star, + color: const Color(0xFFFFD700), + requiredValue: 7, + ), + ]; + } +} diff --git a/lib/models/exercise.dart b/lib/models/exercise.dart new file mode 100644 index 0000000..c0dbec2 --- /dev/null +++ b/lib/models/exercise.dart @@ -0,0 +1,309 @@ +enum ExerciseType { + multipleChoice, + fillInBlank, + matching, + translation, + listening, +} + +abstract class Exercise { + final String id; + final ExerciseType type; + final String question; + final String? audioHint; + + Exercise({ + required this.id, + required this.type, + required this.question, + this.audioHint, + }); + + bool checkAnswer(dynamic answer); + dynamic getCorrectAnswer(); + Map toJson(); +} + +class MultipleChoiceExercise extends Exercise { + final List options; + final int correctOptionIndex; + + MultipleChoiceExercise({ + required String id, + required String question, + required this.options, + required this.correctOptionIndex, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.multipleChoice, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! int) return false; + return answer == correctOptionIndex; + } + + @override + dynamic getCorrectAnswer() => correctOptionIndex; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'multipleChoice', + 'question': question, + 'options': options, + 'correctOptionIndex': correctOptionIndex, + 'audioHint': audioHint, + }; + } + + factory MultipleChoiceExercise.fromJson(Map json) { + return MultipleChoiceExercise( + id: json['id'], + question: json['question'], + options: List.from(json['options']), + correctOptionIndex: json['correctOptionIndex'], + audioHint: json['audioHint'], + ); + } +} + +class FillInBlankExercise extends Exercise { + final String correctAnswer; + final List? acceptableAlternatives; + final bool caseSensitive; + + FillInBlankExercise({ + required String id, + required String question, + required this.correctAnswer, + this.acceptableAlternatives, + this.caseSensitive = false, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.fillInBlank, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! String) return false; + + final userAnswer = caseSensitive ? answer : answer.toLowerCase(); + final correct = caseSensitive ? correctAnswer : correctAnswer.toLowerCase(); + + if (userAnswer == correct) return true; + + if (acceptableAlternatives != null) { + for (final alternative in acceptableAlternatives!) { + final alt = caseSensitive ? alternative : alternative.toLowerCase(); + if (userAnswer == alt) return true; + } + } + + return false; + } + + @override + dynamic getCorrectAnswer() => correctAnswer; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'fillInBlank', + 'question': question, + 'correctAnswer': correctAnswer, + 'acceptableAlternatives': acceptableAlternatives, + 'caseSensitive': caseSensitive, + 'audioHint': audioHint, + }; + } + + factory FillInBlankExercise.fromJson(Map json) { + return FillInBlankExercise( + id: json['id'], + question: json['question'], + correctAnswer: json['correctAnswer'], + acceptableAlternatives: json['acceptableAlternatives'] != null + ? List.from(json['acceptableAlternatives']) + : null, + caseSensitive: json['caseSensitive'] ?? false, + audioHint: json['audioHint'], + ); + } +} + +class MatchingPair { + final String left; + final String right; + + MatchingPair({ + required this.left, + required this.right, + }); + + Map toJson() { + return { + 'left': left, + 'right': right, + }; + } + + factory MatchingPair.fromJson(Map json) { + return MatchingPair( + left: json['left'], + right: json['right'], + ); + } +} + +class MatchingExercise extends Exercise { + final List pairs; + + MatchingExercise({ + required String id, + required String question, + required this.pairs, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.matching, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! Map) return false; + + for (final pair in pairs) { + if (answer[pair.left] != pair.right) { + return false; + } + } + + return answer.length == pairs.length; + } + + @override + dynamic getCorrectAnswer() { + return Map.fromEntries( + pairs.map((pair) => MapEntry(pair.left, pair.right)), + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': 'matching', + 'question': question, + 'pairs': pairs.map((p) => p.toJson()).toList(), + 'audioHint': audioHint, + }; + } + + factory MatchingExercise.fromJson(Map json) { + return MatchingExercise( + id: json['id'], + question: json['question'], + pairs: (json['pairs'] as List) + .map((p) => MatchingPair.fromJson(p)) + .toList(), + audioHint: json['audioHint'], + ); + } +} + +class TranslationExercise extends Exercise { + final String targetText; + final String correctTranslation; + final List? acceptableAlternatives; + + TranslationExercise({ + required String id, + required String question, + required this.targetText, + required this.correctTranslation, + this.acceptableAlternatives, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.translation, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! String) return false; + + final userAnswer = answer.trim().toLowerCase(); + final correct = correctTranslation.trim().toLowerCase(); + + if (userAnswer == correct) return true; + + if (acceptableAlternatives != null) { + for (final alternative in acceptableAlternatives!) { + if (userAnswer == alternative.trim().toLowerCase()) { + return true; + } + } + } + + return false; + } + + @override + dynamic getCorrectAnswer() => correctTranslation; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'translation', + 'question': question, + 'targetText': targetText, + 'correctTranslation': correctTranslation, + 'acceptableAlternatives': acceptableAlternatives, + 'audioHint': audioHint, + }; + } + + factory TranslationExercise.fromJson(Map json) { + return TranslationExercise( + id: json['id'], + question: json['question'], + targetText: json['targetText'], + correctTranslation: json['correctTranslation'], + acceptableAlternatives: json['acceptableAlternatives'] != null + ? List.from(json['acceptableAlternatives']) + : null, + audioHint: json['audioHint'], + ); + } +} + +// Factory method to create exercises from JSON +Exercise exerciseFromJson(Map json) { + switch (json['type']) { + case 'multipleChoice': + return MultipleChoiceExercise.fromJson(json); + case 'fillInBlank': + return FillInBlankExercise.fromJson(json); + case 'matching': + return MatchingExercise.fromJson(json); + case 'translation': + return TranslationExercise.fromJson(json); + default: + throw Exception('Unknown exercise type: ${json['type']}'); + } +} diff --git a/lib/models/lesson.dart b/lib/models/lesson.dart index 8e67c79..c941b9d 100644 --- a/lib/models/lesson.dart +++ b/lib/models/lesson.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'package:flutter/services.dart'; import 'language.dart'; +import 'exercise.dart'; +import 'vocabulary.dart'; class Lesson { final int day; @@ -8,6 +11,8 @@ class Lesson { final String description; final String audioPath; final String? textContent; + List? exercises; + List? vocabulary; Lesson({ required this.day, @@ -16,6 +21,8 @@ class Lesson { required this.description, required this.audioPath, this.textContent, + this.exercises, + this.vocabulary, }); String get phase { @@ -54,6 +61,49 @@ class Lesson { } } + String getExercisesFilePath() { + return 'assets/exercises/day${day}_${language.code}.json'; + } + + String getVocabularyFilePath() { + return 'assets/vocabulary/day${day}_${language.code}.json'; + } + + Future loadExercises() async { + try { + final jsonString = await rootBundle.loadString(getExercisesFilePath()); + final jsonData = json.decode(jsonString) as Map; + final exercisesList = jsonData['exercises'] as List; + + exercises = exercisesList + .map((e) => exerciseFromJson(e as Map)) + .toList(); + } catch (e) { + // No exercises available for this lesson + exercises = []; + } + } + + Future loadVocabulary() async { + try { + final jsonString = await rootBundle.loadString(getVocabularyFilePath()); + final jsonData = json.decode(jsonString) as Map; + final vocabularyList = jsonData['vocabulary'] as List; + + vocabulary = vocabularyList + .map((v) => VocabularyItem.fromJson(v as Map)) + .toList(); + } catch (e) { + // No vocabulary available for this lesson + vocabulary = []; + } + } + + int get totalExercises => exercises?.length ?? 0; + int get totalVocabulary => vocabulary?.length ?? 0; + bool get hasExercises => totalExercises > 0; + bool get hasVocabulary => totalVocabulary > 0; + static String _getDescription(int day) { final descriptions = { 1: 'Greetings and Basic Introductions', diff --git a/lib/models/progress.dart b/lib/models/progress.dart index 2a97fd4..c64f90b 100644 --- a/lib/models/progress.dart +++ b/lib/models/progress.dart @@ -3,12 +3,16 @@ import 'language.dart'; class Progress { final Map> completedLessons; final Map currentDay; + // Map of language -> day -> set of completed exercise IDs + final Map>> completedExercises; Progress({ Map>? completedLessons, Map? currentDay, + Map>>? completedExercises, }) : completedLessons = completedLessons ?? {}, - currentDay = currentDay ?? {}; + currentDay = currentDay ?? {}, + completedExercises = completedExercises ?? {}; bool isLessonCompleted(Language language, int day) { return completedLessons[language]?.contains(day) ?? false; @@ -27,13 +31,27 @@ class Progress { return completed / 50.0; } + bool isExerciseCompleted(Language language, int day, String exerciseId) { + return completedExercises[language]?[day]?.contains(exerciseId) ?? false; + } + + int getCompletedExercisesCount(Language language, int day) { + return completedExercises[language]?[day]?.length ?? 0; + } + + Set getCompletedExerciseIds(Language language, int day) { + return completedExercises[language]?[day] ?? {}; + } + Progress copyWith({ Map>? completedLessons, Map? currentDay, + Map>>? completedExercises, }) { return Progress( completedLessons: completedLessons ?? this.completedLessons, currentDay: currentDay ?? this.currentDay, + completedExercises: completedExercises ?? this.completedExercises, ); } @@ -45,12 +63,21 @@ class Progress { 'currentDay': currentDay.map( (key, value) => MapEntry(key.code, value), ), + 'completedExercises': completedExercises.map( + (lang, dayMap) => MapEntry( + lang.code, + dayMap.map( + (day, exerciseIds) => MapEntry(day.toString(), exerciseIds.toList()), + ), + ), + ), }; } factory Progress.fromJson(Map json) { final completedLessonsMap = >{}; final currentDayMap = {}; + final completedExercisesMap = >>{}; if (json['completedLessons'] != null) { (json['completedLessons'] as Map).forEach((key, value) { @@ -66,9 +93,24 @@ class Progress { }); } + if (json['completedExercises'] != null) { + (json['completedExercises'] as Map).forEach((langCode, dayMap) { + final language = Language.fromCode(langCode); + final exercisesByDay = >{}; + + (dayMap as Map).forEach((dayStr, exerciseIds) { + final day = int.parse(dayStr); + exercisesByDay[day] = (exerciseIds as List).cast().toSet(); + }); + + completedExercisesMap[language] = exercisesByDay; + }); + } + return Progress( completedLessons: completedLessonsMap, currentDay: currentDayMap, + completedExercises: completedExercisesMap, ); } } diff --git a/lib/models/streak.dart b/lib/models/streak.dart new file mode 100644 index 0000000..61e5ce2 --- /dev/null +++ b/lib/models/streak.dart @@ -0,0 +1,87 @@ +class Streak { + final int currentStreak; + final int longestStreak; + final DateTime? lastCompletionDate; + final int totalLessonsCompleted; + final Map lessonsPerDay; // date -> count + + Streak({ + this.currentStreak = 0, + this.longestStreak = 0, + this.lastCompletionDate, + this.totalLessonsCompleted = 0, + this.lessonsPerDay = const {}, + }); + + bool get isActiveToday { + if (lastCompletionDate == null) return false; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final lastDate = DateTime( + lastCompletionDate!.year, + lastCompletionDate!.month, + lastCompletionDate!.day, + ); + return today == lastDate; + } + + bool get isStreakBroken { + if (lastCompletionDate == null) return false; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final lastDate = DateTime( + lastCompletionDate!.year, + lastCompletionDate!.month, + lastCompletionDate!.day, + ); + final difference = today.difference(lastDate).inDays; + return difference > 1; + } + + Streak copyWith({ + int? currentStreak, + int? longestStreak, + DateTime? lastCompletionDate, + int? totalLessonsCompleted, + Map? lessonsPerDay, + }) { + return Streak( + currentStreak: currentStreak ?? this.currentStreak, + longestStreak: longestStreak ?? this.longestStreak, + lastCompletionDate: lastCompletionDate ?? this.lastCompletionDate, + totalLessonsCompleted: totalLessonsCompleted ?? this.totalLessonsCompleted, + lessonsPerDay: lessonsPerDay ?? this.lessonsPerDay, + ); + } + + Map toJson() { + return { + 'currentStreak': currentStreak, + 'longestStreak': longestStreak, + 'lastCompletionDate': lastCompletionDate?.toIso8601String(), + 'totalLessonsCompleted': totalLessonsCompleted, + 'lessonsPerDay': lessonsPerDay, + }; + } + + factory Streak.fromJson(Map json) { + return Streak( + currentStreak: json['currentStreak'] as int? ?? 0, + longestStreak: json['longestStreak'] as int? ?? 0, + lastCompletionDate: json['lastCompletionDate'] != null + ? DateTime.parse(json['lastCompletionDate'] as String) + : null, + totalLessonsCompleted: json['totalLessonsCompleted'] as int? ?? 0, + lessonsPerDay: Map.from(json['lessonsPerDay'] as Map? ?? {}), + ); + } + + int getLessonsCompletedOnDate(DateTime date) { + final dateKey = _formatDate(date); + return lessonsPerDay[dateKey] ?? 0; + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/models/vocabulary.dart b/lib/models/vocabulary.dart new file mode 100644 index 0000000..3d4c6c3 --- /dev/null +++ b/lib/models/vocabulary.dart @@ -0,0 +1,47 @@ +class VocabularyItem { + final String word; + final String translation; + final String? phonetic; + final String? example; + final String? exampleTranslation; + final String? audioPath; + final String? imageUrl; + final List? tags; + + VocabularyItem({ + required this.word, + required this.translation, + this.phonetic, + this.example, + this.exampleTranslation, + this.audioPath, + this.imageUrl, + this.tags, + }); + + Map toJson() { + return { + 'word': word, + 'translation': translation, + 'phonetic': phonetic, + 'example': example, + 'exampleTranslation': exampleTranslation, + 'audioPath': audioPath, + 'imageUrl': imageUrl, + 'tags': tags, + }; + } + + factory VocabularyItem.fromJson(Map json) { + return VocabularyItem( + word: json['word'], + translation: json['translation'], + phonetic: json['phonetic'], + example: json['example'], + exampleTranslation: json['exampleTranslation'], + audioPath: json['audioPath'], + imageUrl: json['imageUrl'], + tags: json['tags'] != null ? List.from(json['tags']) : null, + ); + } +} diff --git a/lib/screens/achievements_screen.dart b/lib/screens/achievements_screen.dart new file mode 100644 index 0000000..6b23a63 --- /dev/null +++ b/lib/screens/achievements_screen.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:confetti/confetti.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; + +class AchievementsScreen extends StatefulWidget { + const AchievementsScreen({super.key}); + + @override + State createState() => _AchievementsScreenState(); +} + +class _AchievementsScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late ConfettiController _confettiController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + _confettiController = ConfettiController(duration: const Duration(seconds: 3)); + + // Show confetti if there are newly unlocked achievements + WidgetsBinding.instance.addPostFrameCallback((_) { + final gamificationService = + Provider.of(context, listen: false); + if (gamificationService.recentlyUnlocked.isNotEmpty) { + _confettiController.play(); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + _confettiController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final gamificationService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Achievements'), + centerTitle: true, + bottom: TabBar( + controller: _tabController, + isScrollable: true, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(text: 'All'), + Tab(text: 'Lessons'), + Tab(text: 'Streaks'), + Tab(text: 'Languages'), + Tab(text: 'Special'), + ], + ), + ), + body: Stack( + children: [ + Column( + children: [ + // Progress Header + _buildProgressHeader(gamificationService) + .animate() + .fadeIn() + .slideY(begin: -0.2, duration: 500.ms), + + // Tabs Content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildAchievementsList(gamificationService.achievements), + _buildAchievementsList( + gamificationService.getLessonAchievements()), + _buildAchievementsList( + gamificationService.getStreakAchievements()), + _buildAchievementsList( + gamificationService.getMultilingualAchievements()), + _buildAchievementsList( + gamificationService.getSpecialAchievements()), + ], + ), + ), + ], + ), + + // Confetti overlay + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + colors: const [ + AppTheme.primaryBlue, + AppTheme.secondaryGreen, + AppTheme.accentOrange, + AppTheme.accentPurple, + AppTheme.goldBadge, + ], + ), + ), + ], + ), + ); + } + + Widget _buildProgressHeader(GamificationService gamificationService) { + final unlocked = gamificationService.achievementCount; + final total = gamificationService.totalAchievements; + final progress = unlocked / total; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppTheme.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.emoji_events, + color: AppTheme.goldBadge, + size: 32, + ), + const SizedBox(width: 12), + Text( + '$unlocked / $total', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Achievements Unlocked', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + value: progress, + minHeight: 10, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(height: 8), + Text( + '${(progress * 100).toStringAsFixed(0)}% Complete', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _buildAchievementsList(List achievements) { + if (achievements.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.emoji_events_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No achievements in this category', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: achievements.length, + itemBuilder: (context, index) { + final achievement = achievements[index]; + return _buildAchievementCard(achievement, index) + .animate() + .fadeIn(delay: (index * 50).ms) + .slideX(begin: -0.1); + }, + ); + } + + Widget _buildAchievementCard(achievement, int index) { + final isUnlocked = achievement.isUnlocked; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: isUnlocked ? 4 : 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: isUnlocked + ? LinearGradient( + colors: [ + achievement.color.withOpacity(0.1), + achievement.color.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isUnlocked + ? achievement.color.withOpacity(0.2) + : Colors.grey[300], + shape: BoxShape.circle, + boxShadow: isUnlocked + ? [ + BoxShadow( + color: achievement.color.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Icon( + achievement.icon, + color: isUnlocked ? achievement.color : Colors.grey[500], + size: 32, + ), + ), + title: Text( + achievement.title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: isUnlocked ? null : Colors.grey[600], + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + achievement.description, + style: TextStyle( + fontSize: 14, + color: isUnlocked ? Colors.grey[700] : Colors.grey[500], + ), + ), + if (isUnlocked && achievement.unlockedAt != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + size: 14, + color: achievement.color, + ), + const SizedBox(width: 4), + Text( + 'Unlocked ${_formatDate(achievement.unlockedAt!)}', + style: TextStyle( + fontSize: 12, + color: achievement.color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ], + ), + trailing: isUnlocked + ? Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: achievement.color, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 20, + ), + ) + : Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[300], + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock, + color: Colors.grey[500], + size: 20, + ), + ), + ), + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'today'; + } else if (difference.inDays == 1) { + return 'yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else { + return 'on ${date.month}/${date.day}/${date.year}'; + } + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5af6efa..db8b47c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:fl_chart/fl_chart.dart'; import '../models/language.dart'; import '../services/language_service.dart'; import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../services/settings_service.dart'; import '../utils/app_localizations.dart'; import '../widgets/language_card.dart'; import '../widgets/course_structure.dart'; -import '../widgets/day_grid.dart'; -import 'lesson_screen.dart'; +import '../widgets/daily_lessons_sheet.dart'; +import '../theme/app_theme.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -17,12 +21,12 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - Language? _selectedLanguage; - @override Widget build(BuildContext context) { final languageService = Provider.of(context); final progressService = Provider.of(context); + final gamificationService = Provider.of(context); + final settingsService = Provider.of(context); final loc = AppLocalizations.of(context); return Scaffold( @@ -77,7 +81,7 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Hero Section + // Hero Section with Streak Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( @@ -92,28 +96,61 @@ class _HomeScreenState extends State { ), child: Column( children: [ - Text( - loc.heroTitle, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - loc.heroSubtitle, - style: const TextStyle( - fontSize: 16, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.local_fire_department, + color: gamificationService.streak.currentStreak > 0 + ? Colors.orange + : Colors.white70, + size: 32, + ), + const SizedBox(width: 8), + Text( + '${gamificationService.streak.currentStreak} Day Streak', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ).animate().fadeIn().slideY(begin: -0.3, duration: 600.ms), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatCard( + icon: Icons.emoji_events, + value: gamificationService.achievementCount.toString(), + label: 'Achievements', + ), + _buildStatCard( + icon: Icons.check_circle, + value: gamificationService.streak.totalLessonsCompleted + .toString(), + label: 'Lessons', + ), + _buildStatCard( + icon: Icons.military_tech, + value: gamificationService.streak.longestStreak + .toString(), + label: 'Best Streak', + ), + ], + ).animate().fadeIn(delay: 300.ms).slideY(begin: -0.2), ], ), ), + // Daily Goal Progress + if (settingsService.dailyGoal > 0) + _buildDailyGoalCard(gamificationService, settingsService) + .animate() + .fadeIn(delay: 400.ms) + .slideY(begin: 0.1), + // Language Selection Padding( padding: const EdgeInsets.all(16), @@ -122,12 +159,15 @@ class _HomeScreenState extends State { children: [ Text( 'Select Learning Language', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 16), - ...Language.values.map((language) { + ...Language.values.asMap().entries.map((entry) { + final index = entry.key; + final language = entry.value; final completedCount = progressService.getCompletedCount(language); final currentDay = progressService.getCurrentDay(language); @@ -138,45 +178,149 @@ class _HomeScreenState extends State { completedCount: completedCount, currentDay: currentDay, progress: progress, - isSelected: _selectedLanguage == language, + isSelected: false, onTap: () { - setState(() { - _selectedLanguage = language; - }); + DailyLessonsSheet.show(context, language); }, - ); + ) + .animate() + .fadeIn(delay: (500 + index * 100).ms) + .slideX(begin: -0.1); }), ], ), ), // Course Structure - if (_selectedLanguage == null) - const Padding( - padding: EdgeInsets.all(16), - child: CourseStructure(), - ), + const Padding( + padding: EdgeInsets.all(16), + child: CourseStructure(), + ).animate().fadeIn(delay: 800.ms), + ], + ), + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String label, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white, size: 24), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + ], + ), + ); + } - // Day Grid (shown when language is selected) - if (_selectedLanguage != null) - Padding( - padding: const EdgeInsets.all(16), - child: DayGrid( - language: _selectedLanguage!, - onDaySelected: (day) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LessonScreen( - language: _selectedLanguage!, - initialDay: day, + Widget _buildDailyGoalCard( + GamificationService gamificationService, + SettingsService settingsService, + ) { + final now = DateTime.now(); + final lessonsToday = gamificationService.streak.getLessonsCompletedOnDate(now); + final dailyGoal = settingsService.dailyGoal; + final progress = (lessonsToday / dailyGoal).clamp(0.0, 1.0); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daily Goal', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: progress >= 1.0 + ? AppTheme.secondaryGreen + : AppTheme.primaryBlue, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$lessonsToday / $dailyGoal', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, ), - ); - }, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + progress >= 1.0 + ? AppTheme.secondaryGreen + : AppTheme.primaryBlue, + ), ), ), - ], + if (progress >= 1.0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Row( + children: [ + Icon( + Icons.celebration, + color: AppTheme.secondaryGreen, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Daily goal completed! Great job!', + style: TextStyle( + color: AppTheme.secondaryGreen, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/lesson_screen.dart b/lib/screens/lesson_screen.dart index 59da2ce..9be2c45 100644 --- a/lib/screens/lesson_screen.dart +++ b/lib/screens/lesson_screen.dart @@ -1,10 +1,15 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import '../models/language.dart'; import '../models/lesson.dart'; import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; import '../utils/app_localizations.dart'; +import '../theme/app_theme.dart'; +import 'practice_screen.dart'; class LessonScreen extends StatefulWidget { final Language language; @@ -28,6 +33,17 @@ class _LessonScreenState extends State { Duration _duration = Duration.zero; Duration _position = Duration.zero; String _lessonText = ''; + double _playbackSpeed = 1.0; + bool _isLooping = false; + + bool _showTranslations = false; + bool _isLoadingContent = false; + + // Stream subscriptions for audio player + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerStateSubscription; + StreamSubscription? _playerCompleteSubscription; @override void initState() { @@ -35,46 +51,92 @@ class _LessonScreenState extends State { _currentDay = widget.initialDay; _currentLesson = Lesson.create(_currentDay, widget.language); _setupAudioPlayer(); - _loadLessonText(); + _loadLessonContent(); } - Future _loadLessonText() async { + Future _loadLessonContent() async { + if (!mounted) return; + + setState(() { + _isLoadingContent = true; + }); + final text = await _currentLesson.loadTextContent(); + await _currentLesson.loadExercises(); + await _currentLesson.loadVocabulary(); + + if (!mounted) return; + setState(() { _lessonText = text; + _isLoadingContent = false; }); } void _setupAudioPlayer() { - _audioPlayer.onDurationChanged.listen((duration) { - setState(() { - _duration = duration; - }); + _durationSubscription = _audioPlayer.onDurationChanged.listen((duration) { + if (mounted) { + setState(() { + _duration = duration; + }); + } + }); + + _positionSubscription = _audioPlayer.onPositionChanged.listen((position) { + if (mounted) { + setState(() { + _position = position; + }); + } + }); + + _playerStateSubscription = _audioPlayer.onPlayerStateChanged.listen((state) { + if (mounted) { + setState(() { + _isPlaying = state == PlayerState.playing; + }); + } }); - _audioPlayer.onPositionChanged.listen((position) { - setState(() { - _position = position; - }); + _playerCompleteSubscription = _audioPlayer.onPlayerComplete.listen((_) { + if (_isLooping) { + _audioPlayer.seek(Duration.zero); + _audioPlayer.resume(); + } else { + if (mounted) { + setState(() { + _isPlaying = false; + _position = Duration.zero; + }); + } + } }); + } - _audioPlayer.onPlayerStateChanged.listen((state) { - setState(() { - _isPlaying = state == PlayerState.playing; - }); + Future _setPlaybackSpeed(double speed) async { + await _audioPlayer.setPlaybackRate(speed); + setState(() { + _playbackSpeed = speed; }); + } - _audioPlayer.onPlayerComplete.listen((_) { - setState(() { - _isPlaying = false; - _position = Duration.zero; - }); + void _toggleLoop() { + setState(() { + _isLooping = !_isLooping; }); } @override void dispose() { + // Cancel all stream subscriptions before disposing the audio player + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerStateSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + + // Dispose the audio player _audioPlayer.dispose(); + super.dispose(); } @@ -94,10 +156,11 @@ class _LessonScreenState extends State { _currentLesson = Lesson.create(_currentDay, widget.language); _position = Duration.zero; _isPlaying = false; + _showTranslations = false; }); _audioPlayer.stop(); - _loadLessonText(); + _loadLessonContent(); } String _formatDuration(Duration duration) { @@ -110,6 +173,7 @@ class _LessonScreenState extends State { @override Widget build(BuildContext context) { final progressService = Provider.of(context); + final gamificationService = Provider.of(context); final loc = AppLocalizations.of(context); final isCompleted = progressService.isLessonCompleted(widget.language, _currentDay); @@ -190,6 +254,7 @@ class _LessonScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ + // Main playback controls Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -232,6 +297,8 @@ class _LessonScreenState extends State { ], ), const SizedBox(height: 16), + + // Progress slider Slider( value: _position.inSeconds.toDouble(), max: _duration.inSeconds.toDouble() > 0 @@ -248,6 +315,59 @@ class _LessonScreenState extends State { Text(_formatDuration(_duration)), ], ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Advanced controls + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Speed control + PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, size: 20), + const SizedBox(width: 4), + Text( + '${_playbackSpeed}x', + style: const TextStyle(fontSize: 12), + ), + ], + ), + onSelected: _setPlaybackSpeed, + itemBuilder: (context) => [ + const PopupMenuItem(value: 0.5, child: Text('0.5x - Slow')), + const PopupMenuItem(value: 0.75, child: Text('0.75x')), + const PopupMenuItem(value: 1.0, child: Text('1.0x - Normal')), + const PopupMenuItem(value: 1.25, child: Text('1.25x')), + const PopupMenuItem(value: 1.5, child: Text('1.5x - Fast')), + const PopupMenuItem(value: 2.0, child: Text('2.0x - Very Fast')), + ], + ), + + // Loop toggle + IconButton( + icon: Icon( + _isLooping ? Icons.repeat_on : Icons.repeat, + color: _isLooping ? AppTheme.primaryBlue : null, + ), + onPressed: _toggleLoop, + tooltip: 'Repeat', + ), + + // Restart button + IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + _audioPlayer.seek(Duration.zero); + }, + tooltip: 'Restart', + ), + ], + ), ], ), ), @@ -295,6 +415,222 @@ class _LessonScreenState extends State { const SizedBox(height: 16), + // Practice Section + if (_currentLesson.hasExercises) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.quiz, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Interactive Practice', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Consumer( + builder: (context, progressService, child) { + final completedCount = progressService.getCompletedExercisesCount( + widget.language, + _currentDay, + ); + final totalCount = _currentLesson.totalExercises; + final progress = totalCount > 0 ? completedCount / totalCount : 0.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress:', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '$completedCount / $totalCount exercises', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PracticeScreen( + lesson: _currentLesson, + language: widget.language, + ), + ), + ); + }, + icon: const Icon(Icons.play_arrow), + label: Text( + completedCount == 0 + ? 'Start Practice' + : completedCount == totalCount + ? 'Review Exercises' + : 'Continue Practice', + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Colors.white, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + + if (_currentLesson.hasExercises) const SizedBox(height: 16), + + // Vocabulary Section + if (_currentLesson.hasVocabulary) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.book, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Vocabulary', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + TextButton.icon( + onPressed: () { + setState(() { + _showTranslations = !_showTranslations; + }); + }, + icon: Icon(_showTranslations ? Icons.visibility_off : Icons.visibility), + label: Text(_showTranslations ? 'Hide' : 'Show'), + ), + ], + ), + const SizedBox(height: 16), + ..._currentLesson.vocabulary!.map((vocab) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + vocab.word, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (vocab.phonetic != null) + Text( + vocab.phonetic!, + style: TextStyle( + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + if (_showTranslations) ...[ + const SizedBox(height: 8), + Text( + vocab.translation, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + if (vocab.example != null && _showTranslations) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vocab.example!, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + if (vocab.exampleTranslation != null) ...[ + const SizedBox(height: 4), + Text( + vocab.exampleTranslation!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ), + ); + }), + ], + ), + ), + ), + + if (_currentLesson.hasVocabulary) const SizedBox(height: 16), + // Mark Complete Button ElevatedButton.icon( onPressed: () async { @@ -308,7 +644,11 @@ class _LessonScreenState extends State { widget.language, _currentDay, ); + // Record in gamification service + await gamificationService.recordLessonCompletion(widget.language); + if (context.mounted) { + // Show completion message ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(loc.dayMarkedComplete), @@ -316,6 +656,46 @@ class _LessonScreenState extends State { backgroundColor: Theme.of(context).colorScheme.secondary, ), ); + + // Show achievement notifications if any + if (gamificationService.recentlyUnlocked.isNotEmpty) { + for (var achievement in gamificationService.recentlyUnlocked) { + await Future.delayed(const Duration(milliseconds: 500)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(achievement.icon, color: Colors.white), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Achievement Unlocked!', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + achievement.title, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ), + duration: const Duration(seconds: 3), + backgroundColor: achievement.color, + ), + ); + } + } + } } } }, @@ -328,7 +708,7 @@ class _LessonScreenState extends State { : Theme.of(context).colorScheme.primary, foregroundColor: Colors.white, ), - ), + ).animate().scale(delay: 100.ms), const SizedBox(height: 24), diff --git a/lib/screens/main_navigation_screen.dart b/lib/screens/main_navigation_screen.dart new file mode 100644 index 0000000..9f31925 --- /dev/null +++ b/lib/screens/main_navigation_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:badges/badges.dart' as badges; +import 'package:provider/provider.dart'; +import '../services/gamification_service.dart'; +import 'home_screen.dart'; +import 'profile_screen.dart'; +import 'achievements_screen.dart'; +import 'settings_screen.dart'; + +class MainNavigationScreen extends StatefulWidget { + const MainNavigationScreen({super.key}); + + @override + State createState() => _MainNavigationScreenState(); +} + +class _MainNavigationScreenState extends State { + int _currentIndex = 0; + + final List _screens = [ + const HomeScreen(), + const AchievementsScreen(), + const ProfileScreen(), + const SettingsScreen(), + ]; + + @override + Widget build(BuildContext context) { + final gamificationService = Provider.of(context); + final hasNewAchievements = gamificationService.recentlyUnlocked.isNotEmpty; + + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + + // Clear new achievements notification when viewing achievements screen + if (index == 1 && hasNewAchievements) { + Future.delayed(const Duration(seconds: 1), () { + gamificationService.clearRecentlyUnlocked(); + }); + } + }, + type: BottomNavigationBarType.fixed, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Colors.grey, + selectedFontSize: 12, + unselectedFontSize: 12, + items: [ + const BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: hasNewAchievements + ? badges.Badge( + badgeContent: Text( + gamificationService.recentlyUnlocked.length.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + badgeStyle: badges.BadgeStyle( + badgeColor: Colors.red, + padding: const EdgeInsets.all(4), + ), + child: const Icon(Icons.emoji_events_outlined), + ) + : const Icon(Icons.emoji_events_outlined), + activeIcon: hasNewAchievements + ? badges.Badge( + badgeContent: Text( + gamificationService.recentlyUnlocked.length.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + badgeStyle: badges.BadgeStyle( + badgeColor: Colors.red, + padding: const EdgeInsets.all(4), + ), + child: const Icon(Icons.emoji_events), + ) + : const Icon(Icons.emoji_events), + label: 'Achievements', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.person_outline), + activeIcon: Icon(Icons.person), + label: 'Profile', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + activeIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..c54ecb4 --- /dev/null +++ b/lib/screens/onboarding_screen.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import '../services/settings_service.dart'; +import '../theme/app_theme.dart'; +import 'main_navigation_screen.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _pages = [ + OnboardingPage( + title: 'Welcome to Polyglot Pathways', + description: + 'Master 5 languages with our structured 50-day curriculum designed for effective learning', + icon: Icons.public, + gradient: AppTheme.primaryGradient, + ), + OnboardingPage( + title: 'Track Your Progress', + description: + 'Monitor your learning journey with detailed progress tracking, streaks, and achievements', + icon: Icons.trending_up, + gradient: [AppTheme.secondaryGreen, AppTheme.accentOrange], + ), + OnboardingPage( + title: 'Learn at Your Pace', + description: + 'Audio lessons, interactive content, and flexible daily goals adapted to your schedule', + icon: Icons.headphones, + gradient: [AppTheme.accentOrange, AppTheme.accentPurple], + ), + OnboardingPage( + title: 'Earn Achievements', + description: + 'Unlock badges, maintain streaks, and celebrate milestones as you progress', + icon: Icons.emoji_events, + gradient: [AppTheme.accentPurple, AppTheme.primaryBlue], + ), + ]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + Future _completeOnboarding() async { + final settingsService = Provider.of(context, listen: false); + await settingsService.completeOnboarding(); + + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const MainNavigationScreen(), + ), + ); + } + } + + void _skipOnboarding() { + _completeOnboarding(); + } + + void _nextPage() { + if (_currentPage < _pages.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _completeOnboarding(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Skip button + Padding( + padding: const EdgeInsets.all(16.0), + child: Align( + alignment: Alignment.topRight, + child: TextButton( + onPressed: _skipOnboarding, + child: Text( + 'Skip', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + + // Page view + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _pages.length, + itemBuilder: (context, index) { + return _buildOnboardingPage(_pages[index]); + }, + ), + ), + + // Page indicator + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: SmoothPageIndicator( + controller: _pageController, + count: _pages.length, + effect: ExpandingDotsEffect( + activeDotColor: AppTheme.primaryBlue, + dotColor: Colors.grey.shade300, + dotHeight: 8, + dotWidth: 8, + expansionFactor: 3, + ), + ), + ), + + // Next/Get Started button + Padding( + padding: const EdgeInsets.all(24.0), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _nextPage, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + _currentPage == _pages.length - 1 ? 'Get Started' : 'Next', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildOnboardingPage(OnboardingPage page) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon with gradient background + Container( + width: 160, + height: 160, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: page.gradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: page.gradient[0].withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Icon( + page.icon, + size: 80, + color: Colors.white, + ), + ), + + const SizedBox(height: 48), + + // Title + Text( + page.title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Description + Text( + page.description, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class OnboardingPage { + final String title; + final String description; + final IconData icon; + final List gradient; + + OnboardingPage({ + required this.title, + required this.description, + required this.icon, + required this.gradient, + }); +} diff --git a/lib/screens/practice_screen.dart b/lib/screens/practice_screen.dart new file mode 100644 index 0000000..58a02cb --- /dev/null +++ b/lib/screens/practice_screen.dart @@ -0,0 +1,579 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/language.dart'; +import '../models/lesson.dart'; +import '../models/exercise.dart'; +import '../services/progress_service.dart'; +import '../utils/app_localizations.dart'; + +class PracticeScreen extends StatefulWidget { + final Lesson lesson; + final Language language; + + const PracticeScreen({ + super.key, + required this.lesson, + required this.language, + }); + + @override + State createState() => _PracticeScreenState(); +} + +class _PracticeScreenState extends State { + int _currentExerciseIndex = 0; + Map _userAnswers = {}; + Map _exerciseResults = {}; + bool _showFeedback = false; + bool _allExercisesCompleted = false; + + @override + void initState() { + super.initState(); + // Initialize with already completed exercises + final progressService = Provider.of(context, listen: false); + final completedIds = progressService.getCompletedExerciseIds( + widget.language, + widget.lesson.day, + ); + + for (final exerciseId in completedIds) { + _exerciseResults[exerciseId] = true; + } + } + + Exercise get _currentExercise => widget.lesson.exercises![_currentExerciseIndex]; + + bool get _canProceed => _exerciseResults[_currentExercise.id] == true; + + int get _completedCount => _exerciseResults.values.where((v) => v == true).length; + + int get _totalExercises => widget.lesson.exercises?.length ?? 0; + + void _submitAnswer() { + final answer = _userAnswers[_currentExercise.id]; + if (answer == null) return; + + final isCorrect = _currentExercise.checkAnswer(answer); + + setState(() { + _exerciseResults[_currentExercise.id] = isCorrect; + _showFeedback = true; + }); + + if (isCorrect) { + final progressService = Provider.of(context, listen: false); + progressService.markExerciseComplete( + widget.language, + widget.lesson.day, + _currentExercise.id, + ); + } + } + + void _nextExercise() { + if (_currentExerciseIndex < _totalExercises - 1) { + setState(() { + _currentExerciseIndex++; + _showFeedback = false; + }); + } else { + setState(() { + _allExercisesCompleted = true; + }); + } + } + + void _previousExercise() { + if (_currentExerciseIndex > 0) { + setState(() { + _currentExerciseIndex--; + _showFeedback = false; + }); + } + } + + Widget _buildExerciseWidget() { + final exercise = _currentExercise; + + if (exercise is MultipleChoiceExercise) { + return _buildMultipleChoice(exercise); + } else if (exercise is FillInBlankExercise) { + return _buildFillInBlank(exercise); + } else if (exercise is MatchingExercise) { + return _buildMatching(exercise); + } else if (exercise is TranslationExercise) { + return _buildTranslation(exercise); + } + + return const Text('Unknown exercise type'); + } + + Widget _buildMultipleChoice(MultipleChoiceExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + ...List.generate(exercise.options.length, (index) { + final isSelected = _userAnswers[exercise.id] == index; + final showResult = _showFeedback && isSelected; + final isCorrect = index == exercise.correctOptionIndex; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: OutlinedButton( + onPressed: _showFeedback + ? null + : () { + setState(() { + _userAnswers[exercise.id] = index; + }); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1)) + : (isSelected + ? Theme.of(context).colorScheme.primaryContainer + : null), + side: BorderSide( + color: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red) + : (isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey), + width: 2, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + exercise.options[index], + style: TextStyle( + color: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green.shade700 + : Colors.red.shade700) + : null, + ), + ), + ), + if (showResult) + Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ), + if (_showFeedback && isCorrect && !isSelected) + const Icon(Icons.check_circle, color: Colors.green), + ], + ), + ), + ); + }), + ], + ); + } + + Widget _buildFillInBlank(FillInBlankExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + TextField( + enabled: !_showFeedback, + onChanged: (value) { + setState(() { + _userAnswers[exercise.id] = value; + }); + }, + decoration: InputDecoration( + hintText: 'Type your answer here...', + border: const OutlineInputBorder(), + suffixIcon: _showFeedback + ? Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ) + : null, + ), + ), + if (_showFeedback && _exerciseResults[exercise.id] == false) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: Text( + 'Correct answer: ${exercise.correctAnswer}', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + } + + Widget _buildMatching(MatchingExercise exercise) { + final leftItems = exercise.pairs.map((p) => p.left).toList(); + final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + const Text('Match the items (tap to select, then tap the matching item):'), + const SizedBox(height: 16), + // For simplicity, showing a message - full implementation would need drag-and-drop + const Text( + 'Matching exercise UI - Tap items to connect them', + style: TextStyle(fontStyle: FontStyle.italic), + ), + // Simplified version: display pairs + ...exercise.pairs.map((pair) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded(child: Text(pair.left)), + const Icon(Icons.arrow_forward), + Expanded(child: Text(pair.right)), + ], + ), + ), + ); + }), + ], + ); + } + + Widget _buildTranslation(TranslationExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + exercise.targetText, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + TextField( + enabled: !_showFeedback, + onChanged: (value) { + setState(() { + _userAnswers[exercise.id] = value; + }); + }, + decoration: InputDecoration( + hintText: 'Type your translation...', + border: const OutlineInputBorder(), + suffixIcon: _showFeedback + ? Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ) + : null, + ), + maxLines: 3, + ), + if (_showFeedback && _exerciseResults[exercise.id] == false) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Correct translation:', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + exercise.correctTranslation, + style: TextStyle(color: Colors.green.shade700), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + if (_allExercisesCompleted) { + return Scaffold( + appBar: AppBar( + title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.celebration, + size: 100, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Congratulations!', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + 'You\'ve completed all exercises for this lesson!', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + '$_completedCount / $_totalExercises exercises completed', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back), + label: const Text('Back to Lesson'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'), + actions: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Chip( + avatar: Text( + widget.language.flag, + style: const TextStyle(fontSize: 16), + ), + label: Text( + '$_completedCount / $_totalExercises', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + body: Column( + children: [ + // Progress indicator + LinearProgressIndicator( + value: _totalExercises > 0 ? _completedCount / _totalExercises : 0, + minHeight: 8, + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Exercise counter + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Exercise ${_currentExerciseIndex + 1} of $_totalExercises', + style: Theme.of(context).textTheme.titleMedium, + ), + if (_exerciseResults[_currentExercise.id] == true) + const Chip( + avatar: Icon(Icons.check, size: 16), + label: Text('Completed'), + backgroundColor: Colors.green, + labelStyle: TextStyle(color: Colors.white), + ), + ], + ), + const SizedBox(height: 24), + + // Exercise content + _buildExerciseWidget(), + + const SizedBox(height: 32), + + // Feedback message + if (_showFeedback) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green + : Colors.red, + width: 2, + ), + ), + child: Row( + children: [ + Icon( + _exerciseResults[_currentExercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green + : Colors.red, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _exerciseResults[_currentExercise.id] == true + ? 'Excellent! That\'s correct!' + : 'Not quite right. Try reviewing the lesson content.', + style: TextStyle( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green.shade700 + : Colors.red.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + if (!_showFeedback) + ElevatedButton( + onPressed: _userAnswers.containsKey(_currentExercise.id) + ? _submitAnswer + : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + child: const Text('Submit Answer'), + ), + + if (_showFeedback) + ElevatedButton( + onPressed: _canProceed ? _nextExercise : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + child: Text( + _currentExerciseIndex < _totalExercises - 1 + ? 'Next Exercise' + : 'Finish Practice', + ), + ), + + const SizedBox(height: 16), + + // Navigation buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _currentExerciseIndex > 0 ? _previousExercise : null, + icon: const Icon(Icons.arrow_back), + label: const Text('Previous'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: OutlinedButton.icon( + onPressed: _currentExerciseIndex < _totalExercises - 1 + ? _nextExercise + : null, + icon: const Icon(Icons.arrow_forward), + label: const Text('Skip'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..970149a --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../models/language.dart'; +import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; +import 'package:intl/intl.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final progressService = Provider.of(context); + final gamificationService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Your Profile'), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + children: [ + // Profile Header + _buildProfileHeader(gamificationService) + .animate() + .fadeIn() + .slideY(begin: -0.2, duration: 500.ms), + + const SizedBox(height: 16), + + // Stats Overview + _buildStatsOverview(progressService, gamificationService) + .animate() + .fadeIn(delay: 200.ms) + .slideY(begin: 0.1), + + const SizedBox(height: 16), + + // Language Progress Chart + _buildLanguageProgressChart(progressService) + .animate() + .fadeIn(delay: 400.ms) + .scale(begin: const Offset(0.9, 0.9)), + + const SizedBox(height: 16), + + // Recent Activity + _buildRecentActivity(gamificationService) + .animate() + .fadeIn(delay: 600.ms) + .slideY(begin: 0.1), + + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildProfileHeader(GamificationService gamificationService) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppTheme.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.person, + size: 60, + color: AppTheme.primaryBlue, + ), + ), + const SizedBox(height: 16), + const Text( + 'Language Learner', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department, + color: Colors.orange, + size: 20, + ), + const SizedBox(width: 8), + Text( + '${gamificationService.streak.currentStreak} Day Streak', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatsOverview( + ProgressService progressService, + GamificationService gamificationService, + ) { + final totalCompleted = gamificationService.streak.totalLessonsCompleted; + final achievementCount = gamificationService.achievementCount; + final longestStreak = gamificationService.streak.longestStreak; + + // Calculate total progress across all languages + int totalPossible = Language.values.length * 50; + double overallProgress = totalCompleted / totalPossible; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Your Stats', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.check_circle, + value: totalCompleted.toString(), + label: 'Lessons', + color: AppTheme.secondaryGreen, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.emoji_events, + value: achievementCount.toString(), + label: 'Achievements', + color: AppTheme.accentOrange, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.military_tech, + value: longestStreak.toString(), + label: 'Best Streak', + color: AppTheme.accentPurple, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.trending_up, + value: '${(overallProgress * 100).toStringAsFixed(0)}%', + label: 'Overall', + color: AppTheme.primaryBlue, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String label, + required Color color, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + Widget _buildLanguageProgressChart(ProgressService progressService) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Progress by Language', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: 50, + barTouchData: BarTouchData(enabled: true), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final languages = Language.values; + if (value.toInt() >= 0 && + value.toInt() < languages.length) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + languages[value.toInt()].flag, + style: const TextStyle(fontSize: 20), + ), + ); + } + return const Text(''); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 10, + ), + borderData: FlBorderData(show: false), + barGroups: Language.values.asMap().entries.map((entry) { + final index = entry.key; + final language = entry.value; + final completed = progressService.getCompletedCount(language); + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: completed.toDouble(), + color: _getLanguageColor(index), + width: 24, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 16), + ...Language.values.map((language) { + final completed = progressService.getCompletedCount(language); + final progress = progressService.getProgress(language); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Text( + language.flag, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + language.name, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Text( + '$completed/50', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildRecentActivity(GamificationService gamificationService) { + final recentAchievements = gamificationService.unlockedAchievements + .where((a) => a.unlockedAt != null) + .toList() + ..sort((a, b) => b.unlockedAt!.compareTo(a.unlockedAt!)); + + final displayAchievements = recentAchievements.take(5).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Recent Activity', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + if (displayAchievements.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Icon( + Icons.emoji_events_outlined, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 8), + Text( + 'Complete lessons to unlock achievements!', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else + ...displayAchievements.map((achievement) { + final timeAgo = _getTimeAgo(achievement.unlockedAt!); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: achievement.color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + achievement.icon, + color: achievement.color, + ), + ), + title: Text( + achievement.title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(timeAgo), + ); + }), + ], + ), + ), + ), + ); + } + + Color _getLanguageColor(int index) { + final colors = [ + AppTheme.primaryBlue, + AppTheme.secondaryGreen, + AppTheme.accentOrange, + AppTheme.accentPurple, + const Color(0xFFE74C3C), + ]; + return colors[index % colors.length]; + } + + String _getTimeAgo(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return DateFormat('MMM d').format(dateTime); + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..4fe980a --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,407 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/language.dart'; +import '../services/language_service.dart'; +import '../services/settings_service.dart'; +import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; +import 'onboarding_screen.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final settingsService = Provider.of(context); + final languageService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + children: [ + // Appearance Section + _buildSection( + context, + title: 'Appearance', + icon: Icons.palette, + children: [ + _buildSwitchTile( + context, + title: 'Dark Mode', + subtitle: 'Use dark theme', + icon: Icons.dark_mode, + value: settingsService.isDarkMode, + onChanged: (value) => settingsService.toggleDarkMode(), + ), + _buildSliderTile( + context, + title: 'Text Size', + subtitle: settingsService.getTextScaleLabel(), + icon: Icons.format_size, + value: settingsService.textScale, + min: 0.85, + max: 1.3, + divisions: 9, + onChanged: (value) => settingsService.setTextScale(value), + ), + ], + ).animate().fadeIn(delay: 100.ms).slideX(begin: -0.1), + + // Learning Section + _buildSection( + context, + title: 'Learning', + icon: Icons.school, + children: [ + _buildSliderTile( + context, + title: 'Daily Goal', + subtitle: '${settingsService.dailyGoal} ${settingsService.dailyGoal == 1 ? "lesson" : "lessons"} per day', + icon: Icons.track_changes, + value: settingsService.dailyGoal.toDouble(), + min: 1, + max: 10, + divisions: 9, + onChanged: (value) => + settingsService.setDailyGoal(value.toInt()), + ), + _buildSwitchTile( + context, + title: 'Show Hints', + subtitle: 'Display helpful tips and hints', + icon: Icons.lightbulb_outline, + value: settingsService.showHints, + onChanged: (value) => settingsService.toggleHints(), + ), + ], + ).animate().fadeIn(delay: 200.ms).slideX(begin: -0.1), + + // Audio & Sound Section + _buildSection( + context, + title: 'Audio & Sound', + icon: Icons.volume_up, + children: [ + _buildSwitchTile( + context, + title: 'Sound Effects', + subtitle: 'Play sounds for actions', + icon: Icons.music_note, + value: settingsService.soundEnabled, + onChanged: (value) => settingsService.toggleSound(), + ), + ], + ).animate().fadeIn(delay: 300.ms).slideX(begin: -0.1), + + // Notifications Section + _buildSection( + context, + title: 'Notifications', + icon: Icons.notifications, + children: [ + _buildSwitchTile( + context, + title: 'Daily Reminders', + subtitle: 'Get reminded to practice', + icon: Icons.alarm, + value: settingsService.notificationsEnabled, + onChanged: (value) => settingsService.toggleNotifications(), + ), + ], + ).animate().fadeIn(delay: 400.ms).slideX(begin: -0.1), + + // Language Section + _buildSection( + context, + title: 'Interface Language', + icon: Icons.language, + children: [ + _buildLanguageSelector(context, languageService), + ], + ).animate().fadeIn(delay: 500.ms).slideX(begin: -0.1), + + // About Section + _buildSection( + context, + title: 'About', + icon: Icons.info, + children: [ + _buildActionTile( + context, + title: 'Tutorial', + subtitle: 'View onboarding again', + icon: Icons.help_outline, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const OnboardingScreen(), + ), + ); + }, + ), + _buildActionTile( + context, + title: 'App Version', + subtitle: '1.0.0', + icon: Icons.info_outline, + onTap: null, + ), + ], + ).animate().fadeIn(delay: 600.ms).slideX(begin: -0.1), + + // Danger Zone + _buildSection( + context, + title: 'Data Management', + icon: Icons.warning, + children: [ + _buildActionTile( + context, + title: 'Reset Settings', + subtitle: 'Restore default settings', + icon: Icons.restart_alt, + onTap: () => _showResetSettingsDialog(context), + ), + _buildActionTile( + context, + title: 'Reset Progress', + subtitle: 'Clear all learning progress', + icon: Icons.delete_forever, + onTap: () => _showResetProgressDialog(context), + textColor: Colors.red, + ), + ], + ).animate().fadeIn(delay: 700.ms).slideX(begin: -0.1), + + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildSection( + BuildContext context, { + required String title, + required IconData icon, + required List children, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: AppTheme.primaryBlue, size: 24), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 1), + ...children, + ], + ), + ), + ); + } + + Widget _buildSwitchTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required bool value, + required Function(bool) onChanged, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.primaryBlue), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle), + trailing: Switch( + value: value, + onChanged: onChanged, + activeColor: AppTheme.primaryBlue, + ), + ); + } + + Widget _buildSliderTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required double value, + required double min, + required double max, + required int divisions, + required Function(double) onChanged, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.primaryBlue), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + activeColor: AppTheme.primaryBlue, + ), + ], + ), + ); + } + + Widget _buildActionTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required VoidCallback? onTap, + Color? textColor, + }) { + return ListTile( + leading: Icon(icon, color: textColor ?? AppTheme.primaryBlue), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w500, + color: textColor, + ), + ), + subtitle: Text(subtitle), + onTap: onTap, + trailing: onTap != null + ? Icon(Icons.chevron_right, color: Colors.grey[400]) + : null, + ); + } + + Widget _buildLanguageSelector( + BuildContext context, + LanguageService languageService, + ) { + return Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: Language.values.map((language) { + final isSelected = + languageService.currentLanguage.code == language.code; + return ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(language.flag, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text(language.name), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + languageService.setLanguage(language); + } + }, + selectedColor: AppTheme.primaryBlue.withOpacity(0.2), + backgroundColor: Colors.grey[200], + ); + }).toList(), + ), + ); + } + + void _showResetSettingsDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Reset Settings'), + content: const Text( + 'Are you sure you want to reset all settings to default values? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Provider.of(context, listen: false) + .resetAllSettings(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings reset successfully')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + ), + child: const Text('Reset'), + ), + ], + ); + }, + ); + } + + void _showResetProgressDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Reset Progress'), + content: const Text( + 'Are you sure you want to delete all your learning progress, streaks, and achievements? This action cannot be undone!', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Provider.of(context, listen: false).resetAll(); + Provider.of(context, listen: false) + .resetAll(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('All progress has been reset'), + backgroundColor: Colors.red, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Delete All'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/services/gamification_service.dart b/lib/services/gamification_service.dart new file mode 100644 index 0000000..a3a49bc --- /dev/null +++ b/lib/services/gamification_service.dart @@ -0,0 +1,307 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/achievement.dart'; +import '../models/streak.dart'; +import '../models/language.dart'; + +class GamificationService extends ChangeNotifier { + static const String _streakKey = 'streak_data'; + static const String _achievementsKey = 'achievements_data'; + static const String _languagesStartedKey = 'languages_started'; + + Streak _streak = Streak(); + List _achievements = Achievement.getAllAchievements(); + Set _languagesStarted = {}; + List _recentlyUnlocked = []; + + Streak get streak => _streak; + List get achievements => _achievements; + List get unlockedAchievements => + _achievements.where((a) => a.isUnlocked).toList(); + List get lockedAchievements => + _achievements.where((a) => !a.isUnlocked).toList(); + int get achievementCount => unlockedAchievements.length; + int get totalAchievements => _achievements.length; + List get recentlyUnlocked => _recentlyUnlocked; + + GamificationService() { + _loadData(); + } + + Future _loadData() async { + final prefs = await SharedPreferences.getInstance(); + + // Load streak + final streakJson = prefs.getString(_streakKey); + if (streakJson != null) { + _streak = Streak.fromJson(json.decode(streakJson)); + } + + // Load achievements + final achievementsJson = prefs.getString(_achievementsKey); + if (achievementsJson != null) { + final List savedAchievements = json.decode(achievementsJson); + final allAchievements = Achievement.getAllAchievements(); + + _achievements = allAchievements.map((template) { + final saved = savedAchievements.firstWhere( + (a) => a['type'] == template.type.toString(), + orElse: () => null, + ); + if (saved != null) { + return Achievement.fromJson(saved, template); + } + return template; + }).toList(); + } + + // Load languages started + final languagesJson = prefs.getStringList(_languagesStartedKey); + if (languagesJson != null) { + _languagesStarted = Set.from(languagesJson); + } + + notifyListeners(); + } + + Future _saveData() async { + final prefs = await SharedPreferences.getInstance(); + + // Save streak + await prefs.setString(_streakKey, json.encode(_streak.toJson())); + + // Save achievements + final achievementsJson = _achievements.map((a) => a.toJson()).toList(); + await prefs.setString(_achievementsKey, json.encode(achievementsJson)); + + // Save languages started + await prefs.setStringList(_languagesStartedKey, _languagesStarted.toList()); + } + + Future recordLessonCompletion(Language language) async { + _recentlyUnlocked.clear(); + + // Track language started + _languagesStarted.add(language.code); + + // Update streak + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final dateKey = _formatDate(today); + + Map updatedLessonsPerDay = Map.from(_streak.lessonsPerDay); + updatedLessonsPerDay[dateKey] = (updatedLessonsPerDay[dateKey] ?? 0) + 1; + + int newCurrentStreak = _streak.currentStreak; + if (_streak.lastCompletionDate == null) { + newCurrentStreak = 1; + } else { + final lastDate = DateTime( + _streak.lastCompletionDate!.year, + _streak.lastCompletionDate!.month, + _streak.lastCompletionDate!.day, + ); + final daysDifference = today.difference(lastDate).inDays; + + if (daysDifference == 0) { + // Same day, keep current streak + } else if (daysDifference == 1) { + // Consecutive day + newCurrentStreak = _streak.currentStreak + 1; + } else { + // Streak broken + newCurrentStreak = 1; + } + } + + final newLongestStreak = newCurrentStreak > _streak.longestStreak + ? newCurrentStreak + : _streak.longestStreak; + + _streak = _streak.copyWith( + currentStreak: newCurrentStreak, + longestStreak: newLongestStreak, + lastCompletionDate: now, + totalLessonsCompleted: _streak.totalLessonsCompleted + 1, + lessonsPerDay: updatedLessonsPerDay, + ); + + // Check for achievements + await _checkAchievements(updatedLessonsPerDay[dateKey] ?? 1); + + await _saveData(); + notifyListeners(); + } + + Future _checkAchievements(int lessonsToday) async { + final now = DateTime.now(); + final hour = now.hour; + + // First lesson + _unlockAchievement( + AchievementType.firstLesson, + _streak.totalLessonsCompleted >= 1, + ); + + // Lesson milestones + _unlockAchievement( + AchievementType.firstWeek, + _streak.totalLessonsCompleted >= 7, + ); + _unlockAchievement( + AchievementType.firstMonth, + _streak.totalLessonsCompleted >= 30, + ); + _unlockAchievement( + AchievementType.complete10Lessons, + _streak.totalLessonsCompleted >= 10, + ); + _unlockAchievement( + AchievementType.complete25Lessons, + _streak.totalLessonsCompleted >= 25, + ); + _unlockAchievement( + AchievementType.complete50Lessons, + _streak.totalLessonsCompleted >= 50, + ); + + // Streak achievements + _unlockAchievement( + AchievementType.streak7, + _streak.currentStreak >= 7, + ); + _unlockAchievement( + AchievementType.streak14, + _streak.currentStreak >= 14, + ); + _unlockAchievement( + AchievementType.streak30, + _streak.currentStreak >= 30, + ); + _unlockAchievement( + AchievementType.streak100, + _streak.currentStreak >= 100, + ); + _unlockAchievement( + AchievementType.perfectWeek, + _streak.currentStreak >= 7, + ); + + // Multilingual achievements + _unlockAchievement( + AchievementType.multilingualBronze, + _languagesStarted.length >= 2, + ); + _unlockAchievement( + AchievementType.multilingualSilver, + _languagesStarted.length >= 3, + ); + _unlockAchievement( + AchievementType.multilingualGold, + _languagesStarted.length >= 5, + ); + + // Time-based achievements + _unlockAchievement( + AchievementType.earlyBird, + hour < 9, + ); + _unlockAchievement( + AchievementType.nightOwl, + hour >= 21, + ); + + // Speed learner + _unlockAchievement( + AchievementType.speedLearner, + lessonsToday >= 3, + ); + } + + void _unlockAchievement(AchievementType type, bool condition) { + if (!condition) return; + + final index = _achievements.indexWhere((a) => a.type == type); + if (index != -1 && !_achievements[index].isUnlocked) { + _achievements[index] = _achievements[index].copyWith( + isUnlocked: true, + unlockedAt: DateTime.now(), + ); + _recentlyUnlocked.add(_achievements[index]); + } + } + + Future resetStreak() async { + _streak = Streak(); + await _saveData(); + notifyListeners(); + } + + Future resetAchievements() async { + _achievements = Achievement.getAllAchievements(); + _recentlyUnlocked.clear(); + await _saveData(); + notifyListeners(); + } + + Future resetAll() async { + _streak = Streak(); + _achievements = Achievement.getAllAchievements(); + _languagesStarted = {}; + _recentlyUnlocked.clear(); + await _saveData(); + notifyListeners(); + } + + void clearRecentlyUnlocked() { + _recentlyUnlocked.clear(); + notifyListeners(); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + // Get achievements by category + List getStreakAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.streak7 || + a.type == AchievementType.streak14 || + a.type == AchievementType.streak30 || + a.type == AchievementType.streak100 || + a.type == AchievementType.perfectWeek) + .toList(); + } + + List getLessonAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.firstLesson || + a.type == AchievementType.firstWeek || + a.type == AchievementType.firstMonth || + a.type == AchievementType.complete10Lessons || + a.type == AchievementType.complete25Lessons || + a.type == AchievementType.complete50Lessons) + .toList(); + } + + List getMultilingualAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.multilingualBronze || + a.type == AchievementType.multilingualSilver || + a.type == AchievementType.multilingualGold) + .toList(); + } + + List getSpecialAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.earlyBird || + a.type == AchievementType.nightOwl || + a.type == AchievementType.speedLearner) + .toList(); + } +} diff --git a/lib/services/progress_service.dart b/lib/services/progress_service.dart index 9c48f54..79bc281 100644 --- a/lib/services/progress_service.dart +++ b/lib/services/progress_service.dart @@ -48,6 +48,7 @@ class ProgressService extends ChangeNotifier { _progress = Progress( completedLessons: newCompletedLessons, currentDay: newCurrentDay, + completedExercises: _progress.completedExercises, ); await _saveProgress(); @@ -64,6 +65,7 @@ class ProgressService extends ChangeNotifier { _progress = Progress( completedLessons: newCompletedLessons, currentDay: _progress.currentDay, + completedExercises: _progress.completedExercises, ); await _saveProgress(); @@ -85,4 +87,47 @@ class ProgressService extends ChangeNotifier { double getProgress(Language language) { return _progress.getProgress(language); } + + Future markExerciseComplete(Language language, int day, String exerciseId) async { + final newCompletedExercises = Map>>.from(_progress.completedExercises); + + if (!newCompletedExercises.containsKey(language)) { + newCompletedExercises[language] = {}; + } + + if (!newCompletedExercises[language]!.containsKey(day)) { + newCompletedExercises[language]![day] = {}; + } + + final dayExercises = Set.from(newCompletedExercises[language]![day]!); + dayExercises.add(exerciseId); + newCompletedExercises[language]![day] = dayExercises; + + _progress = Progress( + completedLessons: _progress.completedLessons, + currentDay: _progress.currentDay, + completedExercises: newCompletedExercises, + ); + + await _saveProgress(); + notifyListeners(); + } + + bool isExerciseCompleted(Language language, int day, String exerciseId) { + return _progress.isExerciseCompleted(language, day, exerciseId); + } + + int getCompletedExercisesCount(Language language, int day) { + return _progress.getCompletedExercisesCount(language, day); + } + + Set getCompletedExerciseIds(Language language, int day) { + return _progress.getCompletedExerciseIds(language, day); + } + + Future resetAll() async { + _progress = Progress(); + await _saveProgress(); + notifyListeners(); + } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..fa61059 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsService extends ChangeNotifier { + static const String _darkModeKey = 'dark_mode'; + static const String _textScaleKey = 'text_scale'; + static const String _showHintsKey = 'show_hints'; + static const String _dailyGoalKey = 'daily_goal'; + static const String _notificationsEnabledKey = 'notifications_enabled'; + static const String _soundEnabledKey = 'sound_enabled'; + static const String _hasCompletedOnboardingKey = 'has_completed_onboarding'; + + bool _isDarkMode = false; + double _textScale = 1.0; + bool _showHints = true; + int _dailyGoal = 1; // lessons per day + bool _notificationsEnabled = true; + bool _soundEnabled = true; + bool _hasCompletedOnboarding = false; + + bool get isDarkMode => _isDarkMode; + double get textScale => _textScale; + bool get showHints => _showHints; + int get dailyGoal => _dailyGoal; + bool get notificationsEnabled => _notificationsEnabled; + bool get soundEnabled => _soundEnabled; + bool get hasCompletedOnboarding => _hasCompletedOnboarding; + + ThemeMode get themeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light; + + SettingsService() { + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + _isDarkMode = prefs.getBool(_darkModeKey) ?? false; + _textScale = prefs.getDouble(_textScaleKey) ?? 1.0; + _showHints = prefs.getBool(_showHintsKey) ?? true; + _dailyGoal = prefs.getInt(_dailyGoalKey) ?? 1; + _notificationsEnabled = prefs.getBool(_notificationsEnabledKey) ?? true; + _soundEnabled = prefs.getBool(_soundEnabledKey) ?? true; + _hasCompletedOnboarding = prefs.getBool(_hasCompletedOnboardingKey) ?? false; + notifyListeners(); + } + + Future toggleDarkMode() async { + _isDarkMode = !_isDarkMode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, _isDarkMode); + notifyListeners(); + } + + Future setTextScale(double scale) async { + if (scale >= 0.8 && scale <= 1.5) { + _textScale = scale; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_textScaleKey, scale); + notifyListeners(); + } + } + + Future toggleHints() async { + _showHints = !_showHints; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_showHintsKey, _showHints); + notifyListeners(); + } + + Future setDailyGoal(int goal) async { + if (goal >= 1 && goal <= 10) { + _dailyGoal = goal; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_dailyGoalKey, goal); + notifyListeners(); + } + } + + Future toggleNotifications() async { + _notificationsEnabled = !_notificationsEnabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_notificationsEnabledKey, _notificationsEnabled); + notifyListeners(); + } + + Future toggleSound() async { + _soundEnabled = !_soundEnabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_soundEnabledKey, _soundEnabled); + notifyListeners(); + } + + Future completeOnboarding() async { + _hasCompletedOnboarding = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hasCompletedOnboardingKey, true); + notifyListeners(); + } + + Future resetOnboarding() async { + _hasCompletedOnboarding = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hasCompletedOnboardingKey, false); + notifyListeners(); + } + + Future resetAllSettings() async { + _isDarkMode = false; + _textScale = 1.0; + _showHints = true; + _dailyGoal = 1; + _notificationsEnabled = true; + _soundEnabled = true; + // Don't reset onboarding + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, false); + await prefs.setDouble(_textScaleKey, 1.0); + await prefs.setBool(_showHintsKey, true); + await prefs.setInt(_dailyGoalKey, 1); + await prefs.setBool(_notificationsEnabledKey, true); + await prefs.setBool(_soundEnabledKey, true); + + notifyListeners(); + } + + // Text scale presets + static const double textScaleSmall = 0.85; + static const double textScaleNormal = 1.0; + static const double textScaleLarge = 1.15; + static const double textScaleExtraLarge = 1.3; + + String getTextScaleLabel() { + if (_textScale <= 0.9) return 'Small'; + if (_textScale <= 1.05) return 'Normal'; + if (_textScale <= 1.2) return 'Large'; + return 'Extra Large'; + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..a7f1b76 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Primary colors + static const Color primaryBlue = Color(0xFF4A90E2); + static const Color secondaryGreen = Color(0xFF50C878); + static const Color accentOrange = Color(0xFFFF9500); + static const Color accentPurple = Color(0xFF9B59B6); + + // Light theme colors + static const Color lightBackground = Color(0xFFF8F9FA); + static const Color lightSurface = Colors.white; + static const Color lightOnSurface = Color(0xFF2C3E50); + static const Color lightCardBackground = Colors.white; + + // Dark theme colors + static const Color darkBackground = Color(0xFF121212); + static const Color darkSurface = Color(0xFF1E1E1E); + static const Color darkOnSurface = Color(0xFFE0E0E0); + static const Color darkCardBackground = Color(0xFF2C2C2C); + + // Gradient colors + static const List primaryGradient = [primaryBlue, secondaryGreen]; + static const List accentGradient = [accentOrange, accentPurple]; + + // Achievement badge colors + static const Color bronzeBadge = Color(0xFFCD7F32); + static const Color silverBadge = Color(0xFFC0C0C0); + static const Color goldBadge = Color(0xFFFFD700); + + // Light Theme + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryBlue, + brightness: Brightness.light, + primary: primaryBlue, + secondary: secondaryGreen, + tertiary: accentOrange, + surface: lightSurface, + onSurface: lightOnSurface, + ), + scaffoldBackgroundColor: lightBackground, + appBarTheme: const AppBarTheme( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: Colors.white), + ), + cardTheme: CardThemeData( + color: lightCardBackground, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryBlue, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Colors.white, + selectedItemColor: primaryBlue, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: primaryBlue, + linearTrackColor: Color(0xFFE0E0E0), + ), + sliderTheme: SliderThemeData( + activeTrackColor: primaryBlue, + inactiveTrackColor: primaryBlue.withOpacity(0.3), + thumbColor: primaryBlue, + overlayColor: primaryBlue.withOpacity(0.2), + ), + chipTheme: ChipThemeData( + backgroundColor: primaryBlue.withOpacity(0.1), + labelStyle: const TextStyle(color: primaryBlue), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryBlue, width: 2), + ), + ), + ); + + // Dark Theme + static ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryBlue, + brightness: Brightness.dark, + primary: primaryBlue, + secondary: secondaryGreen, + tertiary: accentOrange, + surface: darkSurface, + onSurface: darkOnSurface, + ), + scaffoldBackgroundColor: darkBackground, + appBarTheme: const AppBarTheme( + backgroundColor: darkSurface, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: Colors.white), + ), + cardTheme: CardThemeData( + color: darkCardBackground, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryBlue, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: darkSurface, + selectedItemColor: primaryBlue, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: primaryBlue, + linearTrackColor: Color(0xFF424242), + ), + sliderTheme: SliderThemeData( + activeTrackColor: primaryBlue, + inactiveTrackColor: primaryBlue.withOpacity(0.3), + thumbColor: primaryBlue, + overlayColor: primaryBlue.withOpacity(0.2), + ), + chipTheme: ChipThemeData( + backgroundColor: primaryBlue.withOpacity(0.2), + labelStyle: const TextStyle(color: Colors.white), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: darkSurface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryBlue, width: 2), + ), + ), + ); + + // Text Styles + static TextStyle headlineLarge(BuildContext context) => Theme.of(context).textTheme.headlineLarge!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle headlineMedium(BuildContext context) => Theme.of(context).textTheme.headlineMedium!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle titleLarge(BuildContext context) => Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle bodyLarge(BuildContext context) => Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle bodyMedium(BuildContext context) => Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + ); + + // Gradient decorations + static BoxDecoration primaryGradientDecoration({double borderRadius = 0}) { + return BoxDecoration( + gradient: const LinearGradient( + colors: primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(borderRadius), + ); + } + + static BoxDecoration accentGradientDecoration({double borderRadius = 0}) { + return BoxDecoration( + gradient: const LinearGradient( + colors: accentGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(borderRadius), + ); + } + + // Shadow styles + static List cardShadow(BuildContext context) { + return [ + BoxShadow( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.1) + : Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ]; + } +} diff --git a/lib/widgets/daily_lessons_sheet.dart b/lib/widgets/daily_lessons_sheet.dart new file mode 100644 index 0000000..b30126d --- /dev/null +++ b/lib/widgets/daily_lessons_sheet.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/language.dart'; +import '../services/progress_service.dart'; +import '../screens/lesson_screen.dart'; + +class DailyLessonsSheet extends StatefulWidget { + final Language language; + + const DailyLessonsSheet({ + super.key, + required this.language, + }); + + @override + State createState() => _DailyLessonsSheetState(); + + static void show(BuildContext context, Language language) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DailyLessonsSheet(language: language), + ); + } +} + +class _DailyLessonsSheetState extends State { + int _currentPage = 0; + final int _daysPerPage = 10; + + @override + Widget build(BuildContext context) { + final progressService = Provider.of(context); + final startDay = _currentPage * _daysPerPage + 1; + final endDay = (startDay + _daysPerPage - 1).clamp(1, 50); + + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.5, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + spreadRadius: 5, + ), + ], + ), + child: Column( + children: [ + // Drag handle + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header with language info + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Text( + widget.language.flag, + style: const TextStyle(fontSize: 32), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.language.name, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${progressService.getCompletedCount(widget.language)} of 50 lessons completed', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ).animate().fadeIn(duration: 300.ms).slideY(begin: -0.2), + + const Divider(height: 1), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Page indicator + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daily Lessons', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Days $startDay-$endDay', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Day grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: endDay - startDay + 1, + itemBuilder: (context, index) { + final day = startDay + index; + final isCompleted = progressService + .isLessonCompleted(widget.language, day); + final isCurrent = + day == progressService.getCurrentDay(widget.language); + + return InkWell( + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LessonScreen( + language: widget.language, + initialDay: day, + ), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: isCompleted + ? Theme.of(context).colorScheme.secondary + : isCurrent + ? Theme.of(context) + .colorScheme + .primaryContainer + : Colors.grey[200], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCurrent + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + boxShadow: isCurrent + ? [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 1, + ), + ] + : null, + ), + child: Stack( + children: [ + Center( + child: Text( + '$day', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isCompleted + ? Colors.white + : isCurrent + ? Theme.of(context) + .colorScheme + .primary + : Colors.grey[700], + ), + ), + ), + if (isCompleted) + const Positioned( + top: 4, + right: 4, + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.white, + ), + ), + ], + ), + ), + ) + .animate() + .fadeIn(delay: (50 * index).ms) + .scale(begin: const Offset(0.8, 0.8)); + }, + ), + + const SizedBox(height: 24), + + // Navigation buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton.icon( + onPressed: _currentPage > 0 + ? () { + setState(() { + _currentPage--; + }); + } + : null, + icon: const Icon(Icons.arrow_back), + label: const Text('Previous'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Page ${_currentPage + 1}/5', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + OutlinedButton.icon( + onPressed: _currentPage < 4 + ? () { + setState(() { + _currentPage++; + }); + } + : null, + icon: const Icon(Icons.arrow_forward), + label: const Text('Next'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index baf745c..5c9356f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + badges: + dependency: "direct main" + description: + name: badges + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 + url: "https://pub.dev" + source: hosted + version: "3.1.2" boolean_selector: dependency: transitive description: @@ -185,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "979aafde2428c53947892c95eb244466c109c129b7eee9011f0a66caaca52267" + url: "https://pub.dev" + source: hosted + version: "0.7.0" convert: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" dart_style: dependency: transitive description: @@ -209,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -241,11 +273,27 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" + url: "https://pub.dev" + source: hosted + version: "0.66.2" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" flutter_lints: dependency: "direct dev" description: @@ -259,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" flutter_test: dependency: "direct dev" description: flutter @@ -618,6 +674,14 @@ packages: description: flutter source: sdk version: "0.0.0" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c + url: "https://pub.dev" + source: hosted + version: "1.2.1" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9492693..6739590 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,16 @@ dependencies: # Path provider for file access path_provider: ^2.1.1 + # Animations and UI enhancements + flutter_animate: ^4.5.0 + smooth_page_indicator: ^1.1.0 + fl_chart: ^0.66.0 + badges: ^3.1.2 + confetti: ^0.7.0 + + # Icons + cupertino_icons: ^1.0.6 + dev_dependencies: flutter_test: sdk: flutter @@ -44,6 +54,8 @@ flutter: - assets/audio/ - assets/translations/ - assets/lessons/ + - assets/exercises/ + - assets/vocabulary/ # fonts: # - family: Poppins