From fe9bdc701d6c80ca408cadfcb984e93e5353bf41 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Thu, 28 May 2026 12:49:21 +0530 Subject: [PATCH 01/12] fix: Fixed the state error in app prefs page --- lib/l10n/app_localizations.dart | 23 +- lib/l10n/app_localizations_ml.dart | 25 ++ lib/l10n/app_localizations_mr.dart | 1 - lib/l10n/app_localizations_raj.dart | 303 ++++++++++++------ lib/views/screens/app_preferences_screen.dart | 13 +- untranslated.txt | 22 ++ 6 files changed, 267 insertions(+), 120 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 711321c9..1113abdc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -12,8 +12,8 @@ import 'app_localizations_hi.dart'; import 'app_localizations_kn.dart'; import 'app_localizations_ml.dart'; import 'app_localizations_mr.dart'; -import 'app_localizations_raj.dart'; import 'app_localizations_pa.dart'; +import 'app_localizations_raj.dart'; import 'app_localizations_ta.dart'; // ignore_for_file: type=lint @@ -70,7 +70,8 @@ import 'app_localizations_ta.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -93,11 +94,11 @@ abstract class AppLocalizations { /// of delegates is preferred or required. static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ @@ -105,11 +106,11 @@ abstract class AppLocalizations { Locale('en'), Locale('gu'), Locale('hi'), - Locale('raj'), Locale('kn'), Locale('ml'), Locale('mr'), Locale('pa'), + Locale('raj'), Locale('ta'), ]; @@ -2609,7 +2610,7 @@ class _AppLocalizationsDelegate 'ml', 'mr', 'pa', - 'raj' + 'raj', 'ta', ].contains(locale.languageCode); @@ -2628,8 +2629,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsGu(); case 'hi': return AppLocalizationsHi(); - case 'raj': - return AppLocalizationsRaj(); case 'kn': return AppLocalizationsKn(); case 'ml': @@ -2638,6 +2637,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsMr(); case 'pa': return AppLocalizationsPa(); + case 'raj': + return AppLocalizationsRaj(); case 'ta': return AppLocalizationsTa(); } diff --git a/lib/l10n/app_localizations_ml.dart b/lib/l10n/app_localizations_ml.dart index 93bec0f2..257e95e4 100644 --- a/lib/l10n/app_localizations_ml.dart +++ b/lib/l10n/app_localizations_ml.dart @@ -1391,6 +1391,31 @@ class AppLocalizationsMl extends AppLocalizations { @override String get failedToDeleteMessage => 'സന്ദേശം ഇല്ലാതാക്കുന്നതിൽ പരാജയപ്പെട്ടു'; + @override + String get noFriendsYet => 'ഇതുവരെ സുഹൃത്തുക്കളില്ല'; + + @override + String get noFriendsDescription => + 'സുഹൃത്തുക്കളുമായി ബന്ധപ്പെടാൻ താഴെയുള്ള ബട്ടൺ ഉപയോഗിച്ച് അവരെ കണ്ടെത്തുക അല്ലെങ്കിൽ ക്ഷണിക്കുക.'; + + @override + String get findFriends => 'സുഹൃത്തുക്കളെ കണ്ടെത്തുക'; + + @override + String get inviteFriend => 'സുഹൃത്തിനെ ക്ഷണിക്കുക'; + + @override + String get noFriendRequestsYet => 'ഇതുവരെ സുഹൃത്ത് അഭ്യർത്ഥനകളില്ല'; + + @override + String get noFriendRequestsDescription => + 'നിങ്ങൾക്ക് പുതിയ സുഹൃത്ത് അഭ്യർത്ഥനകൾ ലഭിക്കുമ്പോൾ അവ ഇവിടെ പ്രത്യക്ഷപ്പെടും.'; + + @override + String inviteToResonate(String url) { + return 'Resonate-ലേക്ക് ക്ഷണിക്കുക'; + } + @override String get usernameInvalidFormat => 'ദയവായി സാധുവായ ഉപയോക്തൃനാമം നൽകുക. അക്ഷരങ്ങൾ, അക്കങ്ങൾ, ഡോട്ടുകൾ, അണ്ടർസ്കോറുകൾ, ഹൈഫനുകൾ എന്നിവ മാത്രമേ അനുവദിക്കൂ.'; diff --git a/lib/l10n/app_localizations_mr.dart b/lib/l10n/app_localizations_mr.dart index 641dd232..01a9cfbd 100644 --- a/lib/l10n/app_localizations_mr.dart +++ b/lib/l10n/app_localizations_mr.dart @@ -1350,7 +1350,6 @@ class AppLocalizationsMr extends AppLocalizations { 'आपण अध्यायसाठी कोणतेही रेकॉर्डिंग केलेले नाही. कक्ष बंद करण्यापूर्वी कृपया अध्याय रेकॉर्ड करा'; @override - String get audioOutput => 'ऑडिओ आउटपुट'; @override diff --git a/lib/l10n/app_localizations_raj.dart b/lib/l10n/app_localizations_raj.dart index 3a7ebc25..ea3a8e3e 100644 --- a/lib/l10n/app_localizations_raj.dart +++ b/lib/l10n/app_localizations_raj.dart @@ -12,7 +12,8 @@ class AppLocalizationsRaj extends AppLocalizations { String get title => 'रेज़ोनेट'; @override - String get roomDescription => 'दूसरा बंदो री राय ने आदर देणो। गलत बात मत बोलो।'; + String get roomDescription => + 'दूसरा बंदो री राय ने आदर देणो। गलत बात मत बोलो।'; @override String get hidePassword => 'पासवर्ड छुपाओ'; @@ -79,14 +80,11 @@ class AppLocalizationsRaj extends AppLocalizations { @override String noAvailableRoom(String isRoom) { - String _temp0 = intl.Intl.selectLogic( - isRoom, - { - 'true': 'कोई रूम उपलब्ध नी है', - 'false': 'कोई आवण वालो रूम नी है', - 'other': 'रूम री जानकारी नी है', - }, - ); + String _temp0 = intl.Intl.selectLogic(isRoom, { + 'true': 'कोई रूम उपलब्ध नी है', + 'false': 'कोई आवण वालो रूम नी है', + 'other': 'रूम री जानकारी नी है', + }); return '$_temp0\nनीचे एक जोड़ण सूं शुरू करो!'; } @@ -172,13 +170,16 @@ class AppLocalizationsRaj extends AppLocalizations { String get currentPassword => 'हाल को पासवर्ड'; @override - String get emailChangeInfo => 'सुरक्षा खातर थाने थारो हाल को पासवर्ड दालनो पड़ेगो जद थूं ईमेल बदलै। ईमेल बदल पच्छी थारे नये ईमेल सूं लॉगिन करजो।'; + String get emailChangeInfo => + 'सुरक्षा खातर थाने थारो हाल को पासवर्ड दालनो पड़ेगो जद थूं ईमेल बदलै। ईमेल बदल पच्छी थारे नये ईमेल सूं लॉगिन करजो।'; @override - String get oauthUsersMessage => '(सिरफ ओ लोकां खातर जिक्यां Google या Github सूं लॉगिन कर्यो है)'; + String get oauthUsersMessage => + '(सिरफ ओ लोकां खातर जिक्यां Google या Github सूं लॉगिन कर्यो है)'; @override - String get oauthUsersEmailChangeInfo => 'ईमेल बदलवा खातर, \"हाल को पासवर्ड\" फील्ड में नयो पासवर्ड भरो। याद राखजो, फेर थां Google/GitHub या नये ईमेल औ पासवर्ड सूं लॉगिन कर सको।'; + String get oauthUsersEmailChangeInfo => + 'ईमेल बदलवा खातर, \"हाल को पासवर्ड\" फील्ड में नयो पासवर्ड भरो। याद राखजो, फेर थां Google/GitHub या नये ईमेल औ पासवर्ड सूं लॉगिन कर सको।'; @override String get resonateTagline => 'बातचीत रो असीम संसार में पग भरो।'; @@ -316,52 +317,56 @@ class AppLocalizationsRaj extends AppLocalizations { String get contribute => 'योगदान करो'; @override - String get appPreferences => 'ऐप पसंद'; + String get appPreferences => 'ऐप री पसंद'; @override - String get transcriptionModel => 'ट्रांसक्रिप्शन मॉडल'; + String get transcriptionModel => 'लिप्यंतरण मॉडल'; @override - String get transcriptionModelDescription => 'आवाज ट्रांसक्रिप्शन खातिर AI मॉडल चुनो। बड़ा मॉडल ज्यादा सही होवे, पर धीमा चले अने ज्यादा स्टोरेज लेवे।'; + String get transcriptionModelDescription => + 'आवाज रो लिप्यंतरण करवा खातर AI मॉडल चुनो। मोटा मॉडल ज्यादा सही होवे, पण ओ धीमा होवे अने ज्यादा स्टोरेज लेवे।'; @override - String get whisperModelTiny => 'टिनी'; + String get whisperModelTiny => 'टाइनी'; @override - String get whisperModelTinyDescription => 'सबतैं तेज, पर सबसे कम सही (~39 MB)'; + String get whisperModelTinyDescription => 'सब सूं तेज, पण निखार कम (~39 MB)'; @override String get whisperModelBase => 'बेस'; @override - String get whisperModelBaseDescription => 'गति अने सटीकता में संतुलित (~74 MB)'; + String get whisperModelBaseDescription => 'वेग अने निखार रो सन्तुलन (~74 MB)'; @override String get whisperModelSmall => 'स्मॉल'; @override - String get whisperModelSmallDescription => 'सही परिणाम, थोड़ो धीमो (~244 MB)'; + String get whisperModelSmallDescription => 'सारो निखार, थोरो धीमो (~244 MB)'; @override String get whisperModelMedium => 'मीडियम'; @override - String get whisperModelMediumDescription => 'ऊँची सटीकता, पर धीमो (~769 MB'; + String get whisperModelMediumDescription => 'घणो निखार, धीमो (~769 MB)'; @override String get whisperModelLargeV1 => 'लार्ज V1'; @override - String get whisperModelLargeV1Description => 'सबतैं सही, पर सबसे धीमो (~1.55 GB)'; + String get whisperModelLargeV1Description => + 'सब सूं ज्यादा निखार, सब सूं धीमो (~1.55 GB)'; @override String get whisperModelLargeV2 => 'लार्ज V2'; @override - String get whisperModelLargeV2Description => 'सुधारेलो लार्ज मॉडल, और सही (~1.55 GB)'; + String get whisperModelLargeV2Description => + 'सुधारयो मोटा मॉडल, ज्यादा निखार सूं (~1.55 GB)'; @override - String get modelDownloadInfo => 'मॉडल पहली बार उपयोग करते वखत डाउनलोड होवे। बेस, स्मॉल, अर मीडियम मॉडल उपयोग करणो सुझाव। लार्ज मॉडल खातिर बहुत शक्तिशाली डिवाइस चाहिए।'; + String get modelDownloadInfo => + 'मॉडल पहेली वार उपयोग करां टां डाऊनलोड होवे। हम बेस, स्मॉल या मीडियम मॉडल उपयोग करां री सलाह देवां। लार्ज मॉडल खातर बहुत तेज मोबाइल या साधन जरूरी होवे।'; @override String get logOut => 'लॉगआउट करो'; @@ -388,15 +393,17 @@ class AppLocalizationsRaj extends AppLocalizations { String get cancel => 'रद्द करो'; @override - String get hide => 'हटाओ'; + String get hide => 'छुपाओ'; @override - String get removeRoom => 'रूम हटाओ'; + String get removeRoom => 'रूम छुपाओ'; + @override - String get removeRoomFromList => 'लिस्ट में सूं हटाओ'; + String get removeRoomFromList => 'सूची मांय सूं छुपाओ'; @override - String get removeRoomConfirmation => 'के थाने पक्को है के थूं इस आने वालो रूम ने अपनी लिस्ट में सूं हटाणा चाह्यो है?'; + String get removeRoomConfirmation => + 'क्या तूं वाकई आंवण वाळो रूम अपनी सूची मांय सूं छुपावां चाहै?'; @override String get completeYourProfile => 'थारो प्रोफाइल पूरा करो'; @@ -550,7 +557,7 @@ class AppLocalizationsRaj extends AppLocalizations { String get errorLoadPackageInfo => 'पैकेज जानकारी लोड नां थाई सकी'; @override - String get searchFailed => 'Failed to search rooms. Please try again.'; + String get searchFailed => 'खोज नाकाम रही'; @override String get updateAvailable => 'अपडेट उपलब्ध है'; @@ -663,46 +670,55 @@ class AppLocalizationsRaj extends AppLocalizations { String get checking => 'चैक कर रिया...'; @override - String get forgotPasswordMessage => 'पासवर्ड रीसेट करवा खातर आपरो रजिस्टरड ईमेल डालो।'; + String get forgotPasswordMessage => + 'पासवर्ड रीसेट करवा खातर आपरो रजिस्टरड ईमेल डालो।'; @override String get usernameUnavailable => 'यूजरनेम उपलब्ध नाहीं!'; @override - String get usernameInvalidOrTaken => 'ई यूजरनेम अमान्य है या पहले सूं लीधो ग्यो है।'; + String get usernameInvalidOrTaken => + 'ई यूजरनेम अमान्य है या पहले सूं लीधो ग्यो है।'; @override String get otpResentMessage => 'नवो OTP खातर आपरो मेल चैक करजो।'; @override - String get connectionError => 'कनेक्शन में गलती है। आपरो इंटरनेट चैक करजो और फेर कोशिश करजो।'; + String get connectionError => + 'कनेक्शन में गलती है। आपरो इंटरनेट चैक करजो और फेर कोशिश करजो।'; @override String get seconds => 'सेकंड।'; @override - String get unsavedChangesWarning => 'जो सेव बिना आगे वयो तो किएला बदलाव गुम हो जासे।'; + String get unsavedChangesWarning => + 'जो सेव बिना आगे वयो तो किएला बदलाव गुम हो जासे।'; @override - String get deleteAccountPermanent => 'ई क्रिया आपरो अकाउंट हमेशा खातर डिलीट कर देसी। ई उलट नहीं सकै। हम आपरो यूजरनेम, ईमेल पता और बाकी डेटा सब डिलीट कर देसू। फेर ओ वापस नहीं मिल सकै।'; + String get deleteAccountPermanent => + 'ई क्रिया आपरो अकाउंट हमेशा खातर डिलीट कर देसी। ई उलट नहीं सकै। हम आपरो यूजरनेम, ईमेल पता और बाकी डेटा सब डिलीट कर देसू। फेर ओ वापस नहीं मिल सकै।'; @override String get giveGreatName => 'एक बढ़िया नाम डालो..'; @override - String get joinCommunityDescription => 'कम्युनिटी में जूड़के आप संदेह दूर कर सकै, नवीं फीचर सुझा सकै, समस्या बतासकै और घणो कुछ।'; + String get joinCommunityDescription => + 'कम्युनिटी में जूड़के आप संदेह दूर कर सकै, नवीं फीचर सुझा सकै, समस्या बतासकै और घणो कुछ।'; @override - String get resonateDescription => 'रेज़ोनेट एक सोशल मीडिया प्लेटफॉर्म है, जिथे हर आवाज़ री कद्र है। आपरी बात, कहानी और अनुभव सांझा करजो। आपरो ऑडियो सफर शुरू करजो। अलग-अलग चर्चा और विषय में भाग ल्यो। ओ रूम खोजो ज्या आपरो मन रेजोनेट करै और कम्युनिटी को हिस्सा बनजो। बात में जूड़जो!'; + String get resonateDescription => + 'रेज़ोनेट एक सोशल मीडिया प्लेटफॉर्म है, जिथे हर आवाज़ री कद्र है। आपरी बात, कहानी और अनुभव सांझा करजो। आपरो ऑडियो सफर शुरू करजो। अलग-अलग चर्चा और विषय में भाग ल्यो। ओ रूम खोजो ज्या आपरो मन रेजोनेट करै और कम्युनिटी को हिस्सा बनजो। बात में जूड़जो!'; @override - String get resonateFullDescription => 'रेज़ोनेट एक क्रांतिकारी आवाज़-आधारित सोशल मीडिया प्लेटफॉर्म है, जिथे हर आवाज़ मायने रखै। \nरीयल-टाइम ऑडियो बातचीत में जूड़जो, अलग-अलग चर्चा में भाग ल्यो, और मिलते-जुलते सोचवालां सूं जुड़जो।\nहमारो प्लेटफॉर्म ऑफर करै:\n- लाइव ऑडियो रूम विषय आधारित चर्चा खातर\n- आवाज़ सूं सहज सोशल नेटवर्किंग\n- कम्युनिटी द्वारा चलावेली कंटेंट मॉडरेशन\n- सभी प्लेटफॉर्म पर चलै\n- एंड-टू-एंड एन्क्रिप्टेड प्राइवेट बातचीत\n\nAOSSIE ओपन सोर्स कम्युनिटी द्वारा विकसित, हम यूजर प्राइवेसी और कम्युनिटी-ड्रिवन विकास ने प्राथमिकता देसू। आवाज़ री दुनिया को भविष्य आकार देण में सागी बनजो!'; + String get resonateFullDescription => + 'रेज़ोनेट एक क्रांतिकारी आवाज़-आधारित सोशल मीडिया प्लेटफॉर्म है, जिथे हर आवाज़ मायने रखै। \nरीयल-टाइम ऑडियो बातचीत में जूड़जो, अलग-अलग चर्चा में भाग ल्यो, और मिलते-जुलते सोचवालां सूं जुड़जो।\nहमारो प्लेटफॉर्म ऑफर करै:\n- लाइव ऑडियो रूम विषय आधारित चर्चा खातर\n- आवाज़ सूं सहज सोशल नेटवर्किंग\n- कम्युनिटी द्वारा चलावेली कंटेंट मॉडरेशन\n- सभी प्लेटफॉर्म पर चलै\n- एंड-टू-एंड एन्क्रिप्टेड प्राइवेट बातचीत\n\nAOSSIE ओपन सोर्स कम्युनिटी द्वारा विकसित, हम यूजर प्राइवेसी और कम्युनिटी-ड्रिवन विकास ने प्राथमिकता देसू। आवाज़ री दुनिया को भविष्य आकार देण में सागी बनजो!'; @override String get stable => 'स्थिर'; @override - String get usernameCharacterLimit => 'यूजरनेम में 5 सूं ज्यादा अक्षर होवा चाहिए।'; + String get usernameCharacterLimit => + 'यूजरनेम में 5 सूं ज्यादा अक्षर होवा चाहिए।'; @override String get submit => 'भेजो'; @@ -714,31 +730,35 @@ class AppLocalizationsRaj extends AppLocalizations { String get noSearchResults => 'कोई खोज परिणाम नहीं'; @override - String get searchRooms => 'Search rooms...'; + String get searchRooms => 'रूम खोजो'; @override - String get searchingRooms => 'Searching rooms...'; + String get searchingRooms => 'रूम खोजां लाग्यो है'; @override - String get clearSearch => 'Clear search'; + String get clearSearch => 'खोज साफ करो'; @override - String get searchError => 'Search Error'; + String get searchError => 'खोज मांय गलती'; @override - String get searchRoomsError => 'Failed to search rooms. Please try again.'; + String get searchRoomsError => 'रूम खोजां मांय गलती'; @override - String get searchUpcomingRoomsError => 'Failed to search upcoming rooms. Please try again.'; + String get searchUpcomingRoomsError => 'आंवण वाळा रूम खोजां मांय गलती'; @override - String get search => 'Search'; + String get search => 'खोजो'; @override - String get clear => 'Clear'; + String get clear => 'साफ करो'; @override - String shareRoomMessage(String roomName, String description, int participants) { + String shareRoomMessage( + String roomName, + String description, + int participants, + ) { return '🚀 ई बढ़िया रूम देखो: $roomName!\n\n📖 विवरण: $description\n👥 हाले $participants भागीदारां सूं जूड़जो!'; } @@ -772,25 +792,30 @@ class AppLocalizationsRaj extends AppLocalizations { String get connectionFailed => 'कनेक्शन फेल ग्यो'; @override - String get unableToJoinRoom => 'रूम में जूड़ नहीं सकै। नेटवर्क चैक करजो और फेर कोशिश करजो।'; + String get unableToJoinRoom => + 'रूम में जूड़ नहीं सकै। नेटवर्क चैक करजो और फेर कोशिश करजो।'; @override String get connectionLost => 'कनेक्शन टूट ग्यो'; @override - String get unableToReconnect => 'रूम सूं फेर कनेक्ट नहीं हो सकै। फेर कोशिश करजो।'; + String get unableToReconnect => + 'रूम सूं फेर कनेक्ट नहीं हो सकै। फेर कोशिश करजो।'; @override String get invalidFormat => 'अमान्य फॉर्मेट!'; @override - String get usernameAlphanumeric => 'यूजरनेम में केवल अक्षर और नंबर होवा चाहिए, खास चिन्ह नहीं।'; + String get usernameAlphanumeric => + 'यूजरनेम में केवल अक्षर और नंबर होवा चाहिए, खास चिन्ह नहीं।'; @override - String get userProfileCreatedSuccessfully => 'आपरो यूजर प्रोफाइल सफलता सूं बन ग्यो।'; + String get userProfileCreatedSuccessfully => + 'आपरो यूजर प्रोफाइल सफलता सूं बन ग्यो।'; @override - String get emailVerificationMessage => 'आगै बढ़वा खातर आपरी ईमेल वेरिफाय करजो।'; + String get emailVerificationMessage => + 'आगै बढ़वा खातर आपरी ईमेल वेरिफाय करजो।'; @override String addNewChaptersToStory(String storyName) { @@ -835,7 +860,8 @@ class AppLocalizationsRaj extends AppLocalizations { } @override - String get fillAllRequiredFields => 'कृपा करीन सब जरूरी फील्ड भरो अर ऑडियो फाइल अर गीत री फाइल अपलोड करो'; + String get fillAllRequiredFields => + 'कृपा करीन सब जरूरी फील्ड भरो अर ऑडियो फाइल अर गीत री फाइल अपलोड करो'; @override String get scheduled => 'नियत कियो गयो'; @@ -865,7 +891,8 @@ class AppLocalizationsRaj extends AppLocalizations { String get createStory => 'कहानी बनाओ'; @override - String get fillAllRequiredFieldsAndChapter => 'कृपा करीन सब जरूरी फील्ड भरो, कम से कम एक अध्याय जोड़ो अर कवर इमेज चुनो।'; + String get fillAllRequiredFieldsAndChapter => + 'कृपा करीन सब जरूरी फील्ड भरो, कम से कम एक अध्याय जोड़ो अर कवर इमेज चुनो।'; @override String get toConfirmType => 'पुष्टि करवा खातर लिखो'; @@ -904,13 +931,16 @@ class AppLocalizationsRaj extends AppLocalizations { String get yourVoiceMatters => 'आपरी आवाज मायने रखै है'; @override - String get joinConversationExploreRooms => 'बातचीत में शामिल होवो! रूम खोजो, मित्रां सूं जुड़ो अर आपरी आवाज दुनियाने सुनावो।'; + String get joinConversationExploreRooms => + 'बातचीत में शामिल होवो! रूम खोजो, मित्रां सूं जुड़ो अर आपरी आवाज दुनियाने सुनावो।'; @override - String get diveIntoDiverseDiscussions => 'विभिन्न चर्चाओं अर विषयां में डुबकी लगाओ।\nज्या रूम तमने पसंद आवै, ओथ शामिल होवो अर समुदाय रो हिस्सा बनो।'; + String get diveIntoDiverseDiscussions => + 'विभिन्न चर्चाओं अर विषयां में डुबकी लगाओ।\nज्या रूम तमने पसंद आवै, ओथ शामिल होवो अर समुदाय रो हिस्सा बनो।'; @override - String get atResonateEveryVoiceValued => 'Resonate पर हर आवाज रो सम्मान है। आपरा विचार, कहानी अर अनुभव बांटो। आजे आपरी ऑडियो यात्रा शुरू करो।'; + String get atResonateEveryVoiceValued => + 'Resonate पर हर आवाज रो सम्मान है। आपरा विचार, कहानी अर अनुभव बांटो। आजे आपरी ऑडियो यात्रा शुरू करो।'; @override String get notifications => 'सूचनाएँ'; @@ -944,10 +974,12 @@ class AppLocalizationsRaj extends AppLocalizations { String get youHaveNewNotification => 'तमने एक नई सूचना मिली है'; @override - String get hangOnGoodThingsTakeTime => 'थोड़ो थामो, बढ़िया चीज़ां ने समय लागै 🔍'; + String get hangOnGoodThingsTakeTime => + 'थोड़ो थामो, बढ़िया चीज़ां ने समय लागै 🔍'; @override - String get resonateOpenSourceProject => 'Resonate एक ओपन सोर्स प्रोजेक्ट है जे AOSSIE नै बनाए राख्यो है। योगदान करवा खातर अमारो GitHub देखो।'; + String get resonateOpenSourceProject => + 'Resonate एक ओपन सोर्स प्रोजेक्ट है जे AOSSIE नै बनाए राख्यो है। योगदान करवा खातर अमारो GitHub देखो।'; @override String get mute => 'म्यूट करो'; @@ -955,6 +987,9 @@ class AppLocalizationsRaj extends AppLocalizations { @override String get speakerLabel => 'स्पीकर'; + @override + String get audioOptions => 'Audio Options'; + @override String get end => 'समाप्त करो'; @@ -1006,35 +1041,29 @@ class AppLocalizationsRaj extends AppLocalizations { @override String storyCategory(String category) { - String _temp0 = intl.Intl.selectLogic( - category, - { - 'drama': 'ड्रामा', - 'comedy': 'कॉमेडी', - 'horror': 'हॉरर', - 'romance': 'रोमांस', - 'thriller': 'थ्रिलर', - 'spiritual': 'आध्यात्मिक', - 'other': 'अन्य', - }, - ); + String _temp0 = intl.Intl.selectLogic(category, { + 'drama': 'ड्रामा', + 'comedy': 'कॉमेडी', + 'horror': 'हॉरर', + 'romance': 'रोमांस', + 'thriller': 'थ्रिलर', + 'spiritual': 'आध्यात्मिक', + 'other': 'अन्य', + }); return '$_temp0'; } @override String chooseTheme(String category) { - String _temp0 = intl.Intl.selectLogic( - category, - { - 'classicTheme': 'क्लासिक', - 'timeTheme': 'टाइम', - 'vintageTheme': 'विंटेज', - 'amberTheme': 'ऐम्बर', - 'forestTheme': 'फॉरेस्ट', - 'creamTheme': 'क्रीम', - 'other': 'अन्य', - }, - ); + String _temp0 = intl.Intl.selectLogic(category, { + 'classicTheme': 'क्लासिक', + 'timeTheme': 'टाइम', + 'vintageTheme': 'विंटेज', + 'amberTheme': 'ऐम्बर', + 'forestTheme': 'फॉरेस्ट', + 'creamTheme': 'क्रीम', + 'other': 'अन्य', + }); return '$_temp0'; } @@ -1187,7 +1216,8 @@ class AppLocalizationsRaj extends AppLocalizations { String get updateFailed => 'अपडेट फेल थ्यो'; @override - String get updateFailedMessage => 'अपडेट ना थ्यो. कृपा करके Play Store स्यूं मैन्युअली अपडेट करो.'; + String get updateFailedMessage => + 'अपडेट ना थ्यो. कृपा करके Play Store स्यूं मैन्युअली अपडेट करो.'; @override String get updateError => 'अपडेट गलती'; @@ -1199,13 +1229,15 @@ class AppLocalizationsRaj extends AppLocalizations { String get platformNotSupported => 'प्लेटफॉर्म सपोर्टेड ना है'; @override - String get platformNotSupportedMessage => 'अपडेट जांच केवल Android डिवाइसां पै उपलब्ध है'; + String get platformNotSupportedMessage => + 'अपडेट जांच केवल Android डिवाइसां पै उपलब्ध है'; @override String get updateCheckFailed => 'अपडेट जांच फेल थई'; @override - String get updateCheckFailedMessage => 'अपडेट जांच ना थई सकी. पाछो कोसिस करो.'; + String get updateCheckFailedMessage => + 'अपडेट जांच ना थई सकी. पाछो कोसिस करो.'; @override String get upToDateTitle => 'थूं ताजा संस्करण चालाओ!'; @@ -1217,16 +1249,18 @@ class AppLocalizationsRaj extends AppLocalizations { String get updateAvailableTitle => 'नवो अपडेट उपलब्ध!'; @override - String get updateAvailableMessage => 'Resonate को नवो संस्करण Play Store पै उपलब्ध है'; + String get updateAvailableMessage => + 'Resonate को नवो संस्करण Play Store पै उपलब्ध है'; @override String get updateFeaturesImprovement => 'नवीन फीचर अने सुधार मिलवो!'; @override - String get failedToRemoveRoom => 'Failed to remove room'; + String get failedToRemoveRoom => 'रूम हटावां मांय नाकाम'; @override - String get roomRemovedSuccessfully => 'Room removed from your list successfully'; + String get roomRemovedSuccessfully => + 'रूम अपनी सूची मांय सूं सफलतापूर्वक हटायो गयो'; @override String get alert => 'चेतावणी'; @@ -1236,24 +1270,22 @@ class AppLocalizationsRaj extends AppLocalizations { @override String reportType(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'harassment': 'उत्पीड़न / घृणा भाषण', - 'abuse': 'अपमानजनक सामग्री / हिंसा', - 'spam': 'स्पैम / धोखाधड़ी / ठगी', - 'impersonation': 'नकली अकाउंट / भेस बदलो', - 'illegal': 'गैरकानूनी गतिविधियां', - 'selfharm': 'स्वयं-हानि / आत्महत्या / मानसिक स्थिति', - 'misuse': 'प्लेटफॉर्म नो गलत उपयोग', - 'other': 'बाकी', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'harassment': 'उत्पीड़न / घृणा भाषण', + 'abuse': 'अपमानजनक सामग्री / हिंसा', + 'spam': 'स्पैम / धोखाधड़ी / ठगी', + 'impersonation': 'नकली अकाउंट / भेस बदलो', + 'illegal': 'गैरकानूनी गतिविधियां', + 'selfharm': 'स्वयं-हानि / आत्महत्या / मानसिक स्थिति', + 'misuse': 'प्लेटफॉर्म नो गलत उपयोग', + 'other': 'बाकी', + }); return '$_temp0'; } @override - String get userBlockedFromResonate => 'थूं यूजरां स्यूं कई रिपोर्ट पाई है अने थूं Resonate उपयोग करणी रोको ग्यो है. जो गलती लागे तो AOSSIE स्यूं संपर्क करो.'; + String get userBlockedFromResonate => + 'थूं यूजरां स्यूं कई रिपोर्ट पाई है अने थूं Resonate उपयोग करणी रोको ग्यो है. जो गलती लागे तो AOSSIE स्यूं संपर्क करो.'; @override String get reportParticipant => 'प्रतिभागी रिपोर्ट करो'; @@ -1277,7 +1309,8 @@ class AppLocalizationsRaj extends AppLocalizations { String get actionBlocked => 'क्रिया रोकी गी'; @override - String get cannotStopRecording => 'थूं रिकॉर्डिंग नै अपने हाथ स्यूं रोक ना सकै, रिकॉर्डिंग तो कमरो बंद होवे जण रुक जासी.'; + String get cannotStopRecording => + 'थूं रिकॉर्डिंग नै अपने हाथ स्यूं रोक ना सकै, रिकॉर्डिंग तो कमरो बंद होवे जण रुक जासी.'; @override String get liveChapter => 'लाइव अध्याय'; @@ -1301,5 +1334,67 @@ class AppLocalizationsRaj extends AppLocalizations { String get fillAllFields => 'कृपा करी सगळा जरूरी फील्ड भरो'; @override - String get noRecordingError => 'थूं अध्याय खातिर काई रिकॉर्डिंग ना करी. कृपा करी कमरो छोड़णी पेला अध्याय रिकॉर्ड करो'; + String get noRecordingError => + 'थूं अध्याय खातिर काई रिकॉर्डिंग ना करी. कृपा करी कमरो छोड़णी पेला अध्याय रिकॉर्ड करो'; + + @override + String get audioOutput => 'Audio Output'; + + @override + String get selectPreferredSpeaker => 'Select your preferred speaker'; + + @override + String get noAudioOutputDevices => 'No audio output devices detected'; + + @override + String get refresh => 'Refresh'; + + @override + String get done => 'Done'; + + @override + String get deleteMessageTitle => 'Delete Message'; + + @override + String get deleteMessageContent => + 'Are you sure you want to delete this message?'; + + @override + String get thisMessageWasDeleted => 'This message was deleted'; + + @override + String get failedToDeleteMessage => 'Failed to delete message'; + + @override + String get noFriendsYet => 'No Friends Yet'; + + @override + String get noFriendsDescription => + 'Your friends list is empty. Start connecting with people and grow your network!'; + + @override + String get findFriends => 'Find Friends'; + + @override + String get inviteFriend => 'Invite a Friend'; + + @override + String get noFriendRequestsYet => 'No Friend Requests'; + + @override + String get noFriendRequestsDescription => + 'You don\'t have any pending friend requests. Invite your friends to connect!'; + + @override + String inviteToResonate(String url) { + return 'Hey! Join me on Resonate - a social audio platform where every voice is valued. Download now: $url'; + } + + @override + String get usernameInvalidFormat => + 'Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.'; + + @override + String get usernameAlreadyTaken => + 'This username is already taken. Try a different one.'; } diff --git a/lib/views/screens/app_preferences_screen.dart b/lib/views/screens/app_preferences_screen.dart index 3f165d49..1d211db6 100644 --- a/lib/views/screens/app_preferences_screen.dart +++ b/lib/views/screens/app_preferences_screen.dart @@ -74,6 +74,13 @@ class _AppPreferencesScreenState extends State { super.initState(); } + static const Map _customLanguages = { + 'raj': Language('raj', 'Rajasthani', 'राजस्थानी'), + }; + + Language _languageForCode(String code) => + _customLanguages[code] ?? Language.fromIsoCode(code); + Widget _buildSectionTitle(String title) { return Padding( padding: EdgeInsets.only( @@ -132,9 +139,7 @@ class _AppPreferencesScreenState extends State { ), ), child: LanguagePickerDropdown( - initialValue: Language.fromIsoCode( - Get.locale?.languageCode ?? "en", - ), + initialValue: _languageForCode(Get.locale?.languageCode ?? "en"), onValuePicked: (Language language) async { Get.updateLocale(Locale(language.isoCode)); @@ -144,7 +149,7 @@ class _AppPreferencesScreenState extends State { ); }, languages: AppLocalizations.supportedLocales - .map((locale) => Language.fromIsoCode(locale.languageCode)) + .map((locale) => _languageForCode(locale.languageCode)) .toList(), ), ), diff --git a/untranslated.txt b/untranslated.txt index d022afe2..1340a455 100644 --- a/untranslated.txt +++ b/untranslated.txt @@ -106,6 +106,28 @@ "usernameAlreadyTaken" ], + "raj": [ + "audioOptions", + "audioOutput", + "selectPreferredSpeaker", + "noAudioOutputDevices", + "refresh", + "done", + "deleteMessageTitle", + "deleteMessageContent", + "thisMessageWasDeleted", + "failedToDeleteMessage", + "noFriendsYet", + "noFriendsDescription", + "findFriends", + "inviteFriend", + "noFriendRequestsYet", + "noFriendRequestsDescription", + "inviteToResonate", + "usernameInvalidFormat", + "usernameAlreadyTaken" + ], + "ta": [ "noFriendsYet", "noFriendsDescription", From 3a1a45c6ba49cabbc306f73d6d480ccf3edac50a Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Thu, 28 May 2026 16:43:06 +0530 Subject: [PATCH 02/12] feat: MIGRATED ROOMS FROM GETX TO RIVERPOD --- lib/controllers/create_room_controller.dart | 122 - lib/controllers/friend_call_screen.dart | 2 +- lib/controllers/livekit_controller.dart | 5 +- lib/controllers/room_chat_controller.dart | 294 -- lib/controllers/rooms_controller.dart | 181 -- lib/controllers/single_room_controller.dart | 382 --- lib/controllers/tabview_controller.dart | 67 +- .../upcomming_rooms_controller.dart | 440 --- lib/core/container.dart | 8 +- lib/core/legacy_dependencies.dart | 6 +- .../viewmodel/app_bootstrap_notifier.dart | 8 - .../generated/app_bootstrap_notifier.g.dart | 2 +- .../rooms/data/audio_device_service.dart | 36 + .../generated/room_chat_repository.g.dart | 58 + .../data/generated/rooms_repository.g.dart | 52 + .../upcoming_rooms_repository.g.dart | 58 + lib/features/rooms/data/livekit_session.dart | 166 ++ .../rooms/data/room_chat_repository.dart | 160 ++ lib/features/rooms/data/rooms_repository.dart | 417 +++ .../rooms/data/upcoming_rooms_repository.dart | 190 ++ lib/features/rooms/model/appwrite_room.dart | 20 + .../rooms/model/appwrite_upcoming_room.dart | 19 + .../rooms/model/audio_device_state.dart | 12 + .../generated/appwrite_room.freezed.dart | 316 +++ .../appwrite_upcoming_room.freezed.dart | 310 +++ .../generated/audio_device_state.freezed.dart | 280 ++ .../generated/livekit_state.freezed.dart | 277 ++ .../model/generated/participant.freezed.dart | 301 ++ .../rooms/model/generated/participant.g.dart | 32 + .../model/generated/reply_to.freezed.dart | 289 ++ .../rooms/model/generated/reply_to.g.dart | 23 + .../generated/room_chat_state.freezed.dart | 304 +++ .../model/generated/room_failure.freezed.dart | 420 +++ .../model/generated/room_message.freezed.dart | 340 +++ .../rooms/model/generated/room_message.g.dart | 42 + .../model/generated/rooms_state.freezed.dart | 286 ++ .../generated/single_room_state.freezed.dart | 301 ++ lib/features/rooms/model/livekit_state.dart | 12 + lib/features/rooms/model/participant.dart | 29 + lib/features/rooms/model/reply_to.dart | 17 + lib/features/rooms/model/room_chat_state.dart | 13 + lib/features/rooms/model/room_failure.dart | 12 + lib/features/rooms/model/room_message.dart | 44 + lib/features/rooms/model/rooms_state.dart | 21 + .../rooms/model/single_room_state.dart | 13 + lib/features/rooms/rooms_routes.dart | 11 + .../rooms/view/pages/create_room_page.dart | 467 ++++ .../rooms/view/pages/room_chat_page.dart | 717 +++++ lib/features/rooms/view/pages/room_page.dart | 318 +++ .../view/widgets/audio_selector_dialog.dart | 277 ++ .../rooms/view}/widgets/live_room_tile.dart | 94 +- .../rooms/view/widgets/no_room_view.dart} | 34 +- .../rooms/view/widgets/participant_block.dart | 246 ++ .../rooms/view}/widgets/room_app_bar.dart | 15 - .../rooms/view}/widgets/room_header.dart | 11 +- .../rooms/view}/widgets/search_rooms.dart | 0 .../view/widgets/upcoming_room_tile.dart} | 185 +- .../viewmodel/audio_device_notifier.dart | 58 + .../rooms/viewmodel/create_room_notifier.dart | 90 + .../generated/audio_device_notifier.g.dart | 104 + .../generated/create_room_notifier.g.dart | 63 + .../generated/livekit_notifier.g.dart | 62 + .../generated/room_chat_notifier.g.dart | 111 + .../viewmodel/generated/rooms_notifier.g.dart | 54 + .../generated/single_room_notifier.g.dart | 100 + .../generated/upcoming_rooms_notifier.g.dart | 68 + .../rooms/viewmodel/livekit_notifier.dart | 76 + .../rooms/viewmodel/room_chat_notifier.dart | 298 ++ .../rooms/viewmodel/rooms_notifier.dart | 83 + .../rooms/viewmodel/single_room_notifier.dart | 286 ++ .../viewmodel/upcoming_rooms_notifier.dart | 108 + lib/models/appwrite_room.dart | 26 - lib/models/appwrite_upcomming_room.dart | 25 - lib/models/message.dart | 116 - lib/models/participant.dart | 50 - lib/models/reply_to.dart | 32 - lib/routes/app_router.dart | 13 +- lib/services/room_service.dart | 203 +- lib/utils/utils.dart | 109 +- lib/views/screens/create_room_screen.dart | 398 --- lib/views/screens/home_screen.dart | 585 ++-- lib/views/screens/pair_chat_screen.dart | 4 +- lib/views/screens/room_chat_screen.dart | 629 ----- lib/views/screens/room_screen.dart | 303 -- lib/views/screens/tabview_screen.dart | 181 +- lib/views/widgets/participant_block.dart | 234 -- lib/views/widgets/report_widget.dart | 21 +- .../change_email_controller_test.dart | 49 +- .../create_room_controller_test.dart | 83 - .../create_room_controller_test.mocks.dart | 253 -- .../room_chat_controller_test.dart | 145 - .../room_chat_controller_test.mocks.dart | 427 --- test/controllers/rooms_controller_test.dart | 158 -- .../rooms_controller_test.mocks.dart | 662 ----- .../upcoming_controller_tests.dart | 292 -- .../auth/viewmodel/auth_notifier_test.dart | 241 +- .../forgot_password_notifier_test.dart | 124 +- .../rooms/data/rooms_repository_test.dart | 342 +++ .../data/rooms_repository_test.mocks.dart} | 384 +-- .../viewmodel/create_room_notifier_test.dart | 143 + .../viewmodel/room_chat_notifier_test.dart | 263 ++ .../rooms/viewmodel/rooms_notifier_test.dart | 244 ++ .../upcoming_rooms_notifier_test.dart | 204 ++ test/helpers/test_root_container.dart | 208 +- .../test_root_container.mocks.dart} | 2430 ++++++++++------- 105 files changed, 12256 insertions(+), 7648 deletions(-) delete mode 100644 lib/controllers/create_room_controller.dart delete mode 100644 lib/controllers/room_chat_controller.dart delete mode 100644 lib/controllers/rooms_controller.dart delete mode 100644 lib/controllers/single_room_controller.dart delete mode 100644 lib/controllers/upcomming_rooms_controller.dart create mode 100644 lib/features/rooms/data/audio_device_service.dart create mode 100644 lib/features/rooms/data/generated/room_chat_repository.g.dart create mode 100644 lib/features/rooms/data/generated/rooms_repository.g.dart create mode 100644 lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart create mode 100644 lib/features/rooms/data/livekit_session.dart create mode 100644 lib/features/rooms/data/room_chat_repository.dart create mode 100644 lib/features/rooms/data/rooms_repository.dart create mode 100644 lib/features/rooms/data/upcoming_rooms_repository.dart create mode 100644 lib/features/rooms/model/appwrite_room.dart create mode 100644 lib/features/rooms/model/appwrite_upcoming_room.dart create mode 100644 lib/features/rooms/model/audio_device_state.dart create mode 100644 lib/features/rooms/model/generated/appwrite_room.freezed.dart create mode 100644 lib/features/rooms/model/generated/appwrite_upcoming_room.freezed.dart create mode 100644 lib/features/rooms/model/generated/audio_device_state.freezed.dart create mode 100644 lib/features/rooms/model/generated/livekit_state.freezed.dart create mode 100644 lib/features/rooms/model/generated/participant.freezed.dart create mode 100644 lib/features/rooms/model/generated/participant.g.dart create mode 100644 lib/features/rooms/model/generated/reply_to.freezed.dart create mode 100644 lib/features/rooms/model/generated/reply_to.g.dart create mode 100644 lib/features/rooms/model/generated/room_chat_state.freezed.dart create mode 100644 lib/features/rooms/model/generated/room_failure.freezed.dart create mode 100644 lib/features/rooms/model/generated/room_message.freezed.dart create mode 100644 lib/features/rooms/model/generated/room_message.g.dart create mode 100644 lib/features/rooms/model/generated/rooms_state.freezed.dart create mode 100644 lib/features/rooms/model/generated/single_room_state.freezed.dart create mode 100644 lib/features/rooms/model/livekit_state.dart create mode 100644 lib/features/rooms/model/participant.dart create mode 100644 lib/features/rooms/model/reply_to.dart create mode 100644 lib/features/rooms/model/room_chat_state.dart create mode 100644 lib/features/rooms/model/room_failure.dart create mode 100644 lib/features/rooms/model/room_message.dart create mode 100644 lib/features/rooms/model/rooms_state.dart create mode 100644 lib/features/rooms/model/single_room_state.dart create mode 100644 lib/features/rooms/rooms_routes.dart create mode 100644 lib/features/rooms/view/pages/create_room_page.dart create mode 100644 lib/features/rooms/view/pages/room_chat_page.dart create mode 100644 lib/features/rooms/view/pages/room_page.dart create mode 100644 lib/features/rooms/view/widgets/audio_selector_dialog.dart rename lib/{views => features/rooms/view}/widgets/live_room_tile.dart (69%) rename lib/{views/screens/no_room_screen.dart => features/rooms/view/widgets/no_room_view.dart} (60%) create mode 100644 lib/features/rooms/view/widgets/participant_block.dart rename lib/{views => features/rooms/view}/widgets/room_app_bar.dart (50%) rename lib/{views => features/rooms/view}/widgets/room_header.dart (82%) rename lib/{views => features/rooms/view}/widgets/search_rooms.dart (100%) rename lib/{views/widgets/upcomming_room_tile.dart => features/rooms/view/widgets/upcoming_room_tile.dart} (58%) create mode 100644 lib/features/rooms/viewmodel/audio_device_notifier.dart create mode 100644 lib/features/rooms/viewmodel/create_room_notifier.dart create mode 100644 lib/features/rooms/viewmodel/generated/audio_device_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/generated/upcoming_rooms_notifier.g.dart create mode 100644 lib/features/rooms/viewmodel/livekit_notifier.dart create mode 100644 lib/features/rooms/viewmodel/room_chat_notifier.dart create mode 100644 lib/features/rooms/viewmodel/rooms_notifier.dart create mode 100644 lib/features/rooms/viewmodel/single_room_notifier.dart create mode 100644 lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart delete mode 100644 lib/models/appwrite_room.dart delete mode 100644 lib/models/appwrite_upcomming_room.dart delete mode 100644 lib/models/message.dart delete mode 100644 lib/models/participant.dart delete mode 100644 lib/models/reply_to.dart delete mode 100644 lib/views/screens/create_room_screen.dart delete mode 100644 lib/views/screens/room_chat_screen.dart delete mode 100644 lib/views/screens/room_screen.dart delete mode 100644 lib/views/widgets/participant_block.dart delete mode 100644 test/controllers/create_room_controller_test.dart delete mode 100644 test/controllers/create_room_controller_test.mocks.dart delete mode 100644 test/controllers/room_chat_controller_test.dart delete mode 100644 test/controllers/room_chat_controller_test.mocks.dart delete mode 100644 test/controllers/rooms_controller_test.dart delete mode 100644 test/controllers/rooms_controller_test.mocks.dart delete mode 100644 test/controllers/upcoming_controller_tests.dart create mode 100644 test/features/rooms/data/rooms_repository_test.dart rename test/{controllers/upcoming_controller_tests.mocks.dart => features/rooms/data/rooms_repository_test.mocks.dart} (58%) create mode 100644 test/features/rooms/viewmodel/create_room_notifier_test.dart create mode 100644 test/features/rooms/viewmodel/room_chat_notifier_test.dart create mode 100644 test/features/rooms/viewmodel/rooms_notifier_test.dart create mode 100644 test/features/rooms/viewmodel/upcoming_rooms_notifier_test.dart rename test/{controllers/change_email_controller_test.mocks.dart => helpers/test_root_container.mocks.dart} (63%) diff --git a/lib/controllers/create_room_controller.dart b/lib/controllers/create_room_controller.dart deleted file mode 100644 index 2f47ae26..00000000 --- a/lib/controllers/create_room_controller.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:developer'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/enums/room_state.dart'; -import 'package:textfield_tags/textfield_tags.dart'; - -import '../models/appwrite_room.dart'; -import '../services/room_service.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class CreateRoomController extends GetxController { - final ThemeController themeController; - RxBool isLoading = false.obs; - RxBool isScheduled = false.obs; - GlobalKey createRoomFormKey = GlobalKey(); - TextEditingController nameController = TextEditingController(); - TextEditingController descriptionController = TextEditingController(); - TextfieldTagsController tagsController = TextfieldTagsController(); - - CreateRoomController({ThemeController? themeController}) - : themeController = themeController ?? Get.find(); - - @override - void dispose() { - nameController.dispose(); - descriptionController.dispose(); - tagsController.dispose(); - super.dispose(); - } - - String? validateTag(dynamic tag, BuildContext context) { - if (tag != null && tag is String && tag.isValidTag()) { - return null; // Tag is valid - } else { - return '${AppLocalizations.of(context)!.invalidTags} $tag'; // Return an error message for invalid tags - } - } - - Future createRoom( - String name, - String description, - List tags, - bool fromCreateScreen, - ) async { - if (fromCreateScreen) { - if (!createRoomFormKey.currentState!.validate()) { - return; - } - } - - try { - isLoading.value = true; - - // Create a new room and add current user to participant list as admin and join livekit room - List newRoomInfo = await RoomService.createRoom( - roomName: name, - roomDescription: description, - roomTags: tags, - adminUid: requireCurrentAuthUser.uid, - ); - String newRoomId = newRoomInfo[0]; - String myDocId = newRoomInfo[1]; - - // Close the loading dialog - Get.back(); - - // Open the Room Bottom Sheet to interact in the room - AppwriteRoom room = AppwriteRoom( - id: newRoomId, - name: name, - description: description, - totalParticipants: 1, - tags: tags, - memberAvatarUrls: [], - state: RoomState.live, - myDocId: myDocId, - isUserAdmin: true, - reportedUsers: [], - ); - Get.find().openRoomSheet(room); - - // Clear Create Room Form - nameController.clear(); - tagsController.clearTags(); - descriptionController.clear(); - } catch (e) { - log(e.toString()); - - // Close the loading dialog - Get.back(); - } finally { - isLoading.value = false; - } - } -} - -extension Validator on String { - bool isValidTag() { - // Remove any leading or trailing whitespaces - String hashtag = trim(); - - // Check if the hashtag starts with a letter or number - if (!hashtag.startsWith(RegExp(r'[a-zA-Z0-9]'))) { - return false; - } - - // Check if the hashtag contains only letters, numbers, or underscores - if (!hashtag.contains(RegExp(r'^[a-zA-Z0-9_]+$'))) { - return false; - } - - // Check if the hashtag is not too long - if (hashtag.length > 30) { - return false; - } - - return true; - } -} diff --git a/lib/controllers/friend_call_screen.dart b/lib/controllers/friend_call_screen.dart index e3e539d6..7890322e 100644 --- a/lib/controllers/friend_call_screen.dart +++ b/lib/controllers/friend_call_screen.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:resonate/controllers/friend_calling_controller.dart'; import 'package:resonate/themes/theme_controller.dart'; import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/room_header.dart'; +import 'package:resonate/features/rooms/view/widgets/room_header.dart'; import 'package:resonate/views/widgets/audio_selector_dialog.dart'; import 'package:resonate/l10n/app_localizations.dart'; diff --git a/lib/controllers/livekit_controller.dart b/lib/controllers/livekit_controller.dart index a16b48b2..8133cd79 100644 --- a/lib/controllers/livekit_controller.dart +++ b/lib/controllers/livekit_controller.dart @@ -6,7 +6,6 @@ import 'package:resonate/l10n/app_localizations.dart'; import 'package:get/get.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/controllers/single_room_controller.dart'; class LiveKitController extends GetxController { late final Room liveKitRoom; @@ -177,9 +176,7 @@ class LiveKitController extends GetxController { await handleDisconnection(); WidgetsBindingCompatible.instance?.addPostFrameCallback((timeStamp) { - if (Get.isRegistered()) { - Get.find().leaveRoom(); - } else if (Get.isRegistered()) { + if (Get.isRegistered()) { Get.find().endChat(); } }); diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart deleted file mode 100644 index 52210224..00000000 --- a/lib/controllers/room_chat_controller.dart +++ /dev/null @@ -1,294 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart' - hide Message; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/models/appwrite_upcomming_room.dart'; -import 'package:resonate/models/message.dart'; -import 'package:resonate/models/reply_to.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; - -class RoomChatController extends GetxController { - RoomChatController({this.appwriteRoom, this.appwriteUpcommingRoom}); - - final FlutterLocalNotificationsPlugin _notifications = - FlutterLocalNotificationsPlugin(); - RxList messages = [].obs; - final AppwriteRoom? appwriteRoom; - final Functions functions = AppwriteService.getFunctions(); - final AppwriteUpcommingRoom? appwriteUpcommingRoom; - final Realtime realtime = AppwriteService.getRealtime(); - final TablesDB tablesDB = AppwriteService.getTables(); - late final RealtimeSubscription? subscription; - Rx replyingTo = Rxn(); - final NotificationDetails notificationDetails = NotificationDetails( - android: AndroidNotificationDetails( - 'your channel id', - 'your channel name', - channelDescription: 'your channel description', - importance: Importance.max, - priority: Priority.high, - ticker: 'ticker', - ), - ); - - @override - void onInit() async { - super.onInit(); - subscribeToMessages(); - log(appwriteRoom.toString()); - log(appwriteUpcommingRoom.toString()); - } - - // delete method - Future deleteMessage(String messageId) async { - try { - Message messageToDelete = messages.firstWhere( - (msg) => msg.messageId == messageId, - ); - messageToDelete = messageToDelete.copyWith(content: '', isDeleted: true); - - await tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: chatMessagesTableId, - rowId: messageId, - data: messageToDelete.toJsonForUpload(), - ); - log('Message deleted successfully'); - } catch (e) { - log('Error deleting message: $e'); - rethrow; - } - } - - Future loadMessages() async { - messages.clear(); - var queries = [ - Query.equal('roomId', appwriteRoom?.id ?? appwriteUpcommingRoom!.id), - Query.orderAsc('index'), - Query.limit(100), - ]; - ReplyTo? replyTo; - RowList messagesList = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: chatMessagesTableId, - queries: queries, - ); - for (Row message in messagesList.rows) { - try { - Row replyToDoc = await tablesDB.getRow( - databaseId: masterDatabaseId, - tableId: chatMessageReplyTableId, - rowId: message.$id, - ); - replyTo = ReplyTo.fromJson(replyToDoc.data); - } catch (e) { - if (e is AppwriteException && e.code == 404) { - // If replyTo document not found, set it to null - replyTo = null; - } else { - rethrow; // Rethrow if it's a different error - } - } - messages.add( - Message.fromJson(message.data..addAll({'replyTo': replyTo?.toJson()})), - ); - } - messages.sort((a, b) => a.index.compareTo(b.index)); - log(messages.toString()); - } - - Future sendMessage(String content) async { - try { - final String messageId = ID.unique(); - - final int newIndex = messages.isNotEmpty ? messages.last.index + 1 : 0; - final user = requireCurrentAuthUser; - final Message message = Message( - roomId: appwriteRoom?.id ?? appwriteUpcommingRoom!.id, - messageId: messageId, - creatorId: user.uid, - creatorUsername: user.userName ?? '', - creatorName: user.displayName, - hasValidTag: false, - index: newIndex, - creatorImgUrl: user.profileImageUrl ?? '', - isEdited: false, - content: content, - creationDateTime: DateTime.now(), - isDeleted: false, - ); - - await tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: chatMessagesTableId, - rowId: messageId, - data: message.toJsonForUpload(), - ); - - if (replyingTo.value != null) { - await tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: chatMessageReplyTableId, - rowId: messageId, - data: replyingTo.value!.toJson(), - ); - } - if (appwriteUpcommingRoom != null) { - log('Sending notification for sent message'); - var body = json.encode({ - 'roomId': appwriteUpcommingRoom?.id, - 'payload': { - 'title': 'Message received in ${appwriteUpcommingRoom?.name}', - 'body': '${message.creatorName} said: ${message.content}', - }, - }); - var results = await functions.createExecution( - functionId: sendMessageNotificationFunctionID, - body: body.toString(), - ); - log(results.status.name); - } - message.replyTo = replyingTo.value; - - // messages.add(message); - replyingTo.value = null; - } catch (e) { - log('Error sending message: $e'); - return; - } - log('Message sed\nt'); - } - - Future editMessage(String messageId, String newContent) async { - Message updatedMessage = messages.firstWhere( - (msg) => msg.messageId == messageId, - ); - updatedMessage = updatedMessage.copyWith( - content: newContent, - isEdited: true, - isDeleted: false, - ); - - try { - await tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: chatMessagesTableId, - rowId: messageId, - data: updatedMessage.toJsonForUpload(), - ); - if (appwriteUpcommingRoom != null) { - log('Sending notification for edited message'); - var body = json.encode({ - 'roomId': appwriteUpcommingRoom?.id, - 'payload': { - 'title': 'Message Edited in ${appwriteUpcommingRoom?.name}', - 'body': - '${updatedMessage.creatorName} updated his message: ${updatedMessage.content}', - }, - }); - var results = await functions.createExecution( - functionId: sendMessageNotificationFunctionID, - body: body.toString(), - ); - log(results.status.name); - } - log('Message edited successfully'); - } catch (e) { - log('Error editing message: $e'); - return; - } - } - - void setReplyingTo(Message message) { - replyingTo.value = ReplyTo( - messageId: message.messageId, - creatorUsername: message.creatorUsername, - content: message.content, - creatorImgUrl: message.creatorImgUrl, - index: messages.indexOf(message), - ); - } - - void clearReplyingTo() { - replyingTo.value = null; - } - - void subscribeToMessages() { - try { - String channel = - 'databases.$masterDatabaseId.tables.$chatMessagesTableId.rows'; - subscription = realtime.subscribe([channel]); - subscription?.stream.listen((data) async { - if (data.payload.isNotEmpty) { - String roomId = data.payload['roomId']; - if (roomId == (appwriteRoom?.id ?? appwriteUpcommingRoom!.id)) { - String docId = data.payload['\$id']; - String action = data.events.first.substring( - channel.length + 1 + docId.length + 1, - ); - log(action); - if (action == 'create') { - Message newMessage = Message.fromJson(data.payload); - ReplyTo? replyTo; - try { - Row replyToDoc = await tablesDB.getRow( - databaseId: masterDatabaseId, - tableId: chatMessageReplyTableId, - rowId: newMessage.messageId, - ); - replyTo = ReplyTo.fromJson(replyToDoc.data); - } catch (e) { - if (e is AppwriteException && e.code == 404) { - // If replyTo document not found, set it to null - replyTo = null; - } else { - log("Error fetching replyTo document: ${e.toString()}"); - rethrow; // Rethrow if it's a different error - } - } - newMessage.replyTo = replyTo; - - messages.add(newMessage); - if (appwriteRoom != null) { - _notifications.show( - 0, - 'Message received in ${appwriteRoom?.name ?? appwriteUpcommingRoom!.name}', - '${newMessage.creatorName} said: ${newMessage.content}', - notificationDetails, - ); - } - } - if (action == 'update') { - Message updatedMessage = Message.fromJson(data.payload); - var index = messages.indexWhere( - (msg) => msg.messageId == updatedMessage.messageId, - ); - messages[index] = messages[index].copyWith( - content: updatedMessage.content, - isEdited: updatedMessage.isEdited, - isDeleted: updatedMessage.isDeleted, - ); - if (appwriteRoom != null) { - _notifications.show( - 0, - 'Message Edited in ${appwriteRoom?.name ?? appwriteUpcommingRoom!.name}', - '${updatedMessage.creatorName} updated his message: ${updatedMessage.content}', - notificationDetails, - ); - } - } - } - } - }); - } catch (e) { - log('Error subscribing to messages: $e'); - } - } -} diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart deleted file mode 100644 index d2f38b94..00000000 --- a/lib/controllers/rooms_controller.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter/material.dart' hide Row; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/services/room_service.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/utils/enums/room_state.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -import '../utils/constants.dart'; -import 'package:resonate/core/container.dart'; - -class RoomsController extends GetxController { - RxBool isLoading = false.obs; - RxBool isOnActive = false.obs; - Client client = AppwriteService.getClient(); - final TablesDB tablesDB = AppwriteService.getTables(); - RxList rooms = [].obs; - final ThemeController themeController = Get.find(); - RxBool isSearching = false.obs; - RxBool searchBarIsEmpty = true.obs; - RxList filteredRooms = [].obs; - - @override - void onInit() async { - super.onInit(); - await getRooms(); - } - - Future createRoomObject(Row room, String userUid) async { - // Get three particpant data to use for memberAvatar widget - var participantCollectionRef = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [Query.equal("roomId", room.$id), Query.limit(3)], - ); - List memberAvatarUrls = []; - for (var p in participantCollectionRef.rows) { - Row participantDoc = await tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: p.data["uid"], - ); - memberAvatarUrls.add(participantDoc.data["profileImageUrl"]); - } - - // Create appwrite room object and add it to rooms list - AppwriteRoom appwriteRoom = AppwriteRoom( - id: room.$id, - name: room.data["name"], - description: room.data["description"], - totalParticipants: room.data["totalParticipants"], - tags: room.data["tags"], - memberAvatarUrls: memberAvatarUrls, - state: RoomState.live, - isUserAdmin: room.data["adminUid"] == userUid, - reportedUsers: List.from(room.data["reportedUsers"] ?? []), - ); - - return appwriteRoom; - } - - Future getRooms() async { - try { - isLoading.value = true; - String userUid = requireCurrentAuthUser.uid; - - // Get active rooms and add it to rooms list - rooms.value = []; - var roomsCollectionRef = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: roomsTableId, - ); - - for (var room in roomsCollectionRef.rows) { - AppwriteRoom appwriteRoom = await createRoomObject(room, userUid); - if (!appwriteRoom.reportedUsers.contains(userUid)) { - rooms.add(appwriteRoom); - } - } - update(); - } catch (e) { - log(e.toString()); - } finally { - isLoading.value = false; - } - } - - Future getRoomById(String roomId) async { - try { - Row room = await tablesDB.getRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - ); - String userUid = requireCurrentAuthUser.uid; - - AppwriteRoom appwriteRoom = await createRoomObject(room, userUid); - return appwriteRoom; - } catch (e) { - log(e.toString()); - } - } - - Future joinRoom({ - required AppwriteRoom room, - required BuildContext context, - }) async { - try { - Get.dialog( - Center( - child: LoadingAnimationWidget.threeRotatingDots( - color: Theme.of(context).primaryColor, - size: MediaQuery.of(context).devicePixelRatio * 20, - ), - ), - barrierDismissible: false, - name: AppLocalizations.of(Get.context!)!.loadingDialog, - ); - - // Get the token and livekit url and join livekit room - String myDocId = await RoomService.joinRoom( - roomId: room.id, - userId: requireCurrentAuthUser.uid, - isAdmin: room.isUserAdmin, - ); - room.myDocId = myDocId; - - // Close the loading dialog - Get.back(); - // Open the Room Bottom Sheet to interact in the room - Get.find().openRoomSheet(room); - } catch (e) { - log(e.toString()); - getRooms(); - update(); - // Close the loading dialog - Get.back(); - } - } - - Future searchLiveRooms(String query) async { - if (query.isEmpty) { - filteredRooms.value = rooms; - searchBarIsEmpty.value = true; - return; - } - isSearching.value = true; - searchBarIsEmpty.value = false; - try { - final lowerQuery = query.toLowerCase(); - filteredRooms.value = rooms.where((room) { - return room.name.toLowerCase().contains(lowerQuery) || - room.description.toLowerCase().contains(lowerQuery); - }).toList(); - } catch (e) { - log('Error searching rooms: $e'); - filteredRooms.value = rooms; - customSnackbar( - AppLocalizations.of(Get.context!)!.error, - AppLocalizations.of(Get.context!)!.searchFailed, - LogType.error, - ); - } finally { - isSearching.value = false; - } - } - - void clearLiveSearch() { - filteredRooms.value = rooms; - searchBarIsEmpty.value = true; - } -} diff --git a/lib/controllers/single_room_controller.dart b/lib/controllers/single_room_controller.dart deleted file mode 100644 index f6530717..00000000 --- a/lib/controllers/single_room_controller.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter/material.dart' hide Row; - -import 'package:get/get.dart'; - -import 'package:resonate/controllers/audio_device_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/controllers/room_chat_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/models/participant.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/services/room_service.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/screens/room_chat_screen.dart'; -import 'package:resonate/views/widgets/loading_dialog.dart'; -import 'package:resonate/views/widgets/report_widget.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -import '../utils/constants.dart'; - -class SingleRoomController extends GetxController { - final RoomsController roomsController = Get.put(RoomsController()); - RxBool isLoading = false.obs; - late Rx me = () { - final user = requireCurrentAuthUser; - return Participant( - uid: user.uid, - email: user.email, - name: user.userName ?? '', - dpUrl: user.profileImageUrl ?? '', - isAdmin: appwriteRoom.isUserAdmin, - isMicOn: false, - isModerator: appwriteRoom.isUserAdmin, - isSpeaker: appwriteRoom.isUserAdmin, - hasRequestedToBeSpeaker: false, - ).obs; - }(); - Client client = AppwriteService.getClient(); - final AppwriteRoom appwriteRoom; - final Realtime realtime = AppwriteService.getRealtime(); - final TablesDB tablesDB = AppwriteService.getTables(); - late final RealtimeSubscription? subscription; - RxList> participants = >[].obs; - - SingleRoomController({required this.appwriteRoom}); - - @override - void onInit() async { - await Future.delayed(const Duration(milliseconds: 500)); - await getParticipants(); - getRealtimeStream(); - super.onInit(); - } - - @override - void onClose() async { - await subscription?.close(); - await Get.delete(force: true); - appRouter.go(RoutePaths.tabview); - super.onClose(); - } - - Future addParticipantDataToList(Row participant) async { - Row userDataDoc = await tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: participant.data["uid"], - ); - final p = Rx( - Participant( - uid: participant.data["uid"], - email: userDataDoc.data["email"], - name: userDataDoc.data["name"], - dpUrl: userDataDoc.data["profileImageUrl"], - isAdmin: participant.data["isAdmin"], - isMicOn: participant.data["isMicOn"], - isModerator: participant.data["isModerator"], - isSpeaker: participant.data["isSpeaker"], - hasRequestedToBeSpeaker: - participant.data["hasRequestedToBeSpeaker"] ?? false, - ), - ); - participants.add(p); - } - - Future removeParticipantDataFromList(String uid) async { - participants.removeWhere((p) => p.value.uid == uid); - if (participants.isEmpty) { - Get.delete(force: true); - Get.delete(); - } - } - - Future updateParticipantDataInList(Map payload) async { - int toBeUpdatedIndex = participants.indexWhere( - (p) => p.value.uid == payload["uid"], - ); - participants[toBeUpdatedIndex].value.isModerator = payload["isModerator"]; - participants[toBeUpdatedIndex].value.hasRequestedToBeSpeaker = - payload["hasRequestedToBeSpeaker"] ?? false; - participants[toBeUpdatedIndex].value.isMicOn = payload["isMicOn"]; - participants[toBeUpdatedIndex].value.isSpeaker = payload["isSpeaker"]; - if (payload["uid"] == requireCurrentAuthUser.uid && - !payload["isSpeaker"] && - me.value.isMicOn) { - turnOffMic(); - } - update(); - } - - Future getParticipants() async { - try { - isLoading.value = true; - participants.value = >[]; - var participantCollectionRef = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [Query.equal('roomId', appwriteRoom.id)], - ); - for (Row participant in participantCollectionRef.rows) { - addParticipantDataToList(participant); - } - update(); - } catch (e) { - log(e.toString()); - } finally { - isLoading.value = false; - } - } - - void getRealtimeStream() { - String channel = - 'databases.$masterDatabaseId.tables.$participantsTableId.rows'; - subscription = realtime.subscribe([channel]); - subscription?.stream.listen((data) async { - if (data.payload.isNotEmpty) { - String roomId = data.payload["roomId"]; - if (roomId == appwriteRoom.id) { - // This event belongs to the room current user is part of - String updatedUserId = data.payload["uid"]; - String docId = data.payload["\$id"]; - String action = data.events.first.substring( - channel.length + 1 + docId.length + 1, - ); - - switch (action) { - case 'create': - { - addParticipantDataToList(Row.fromMap(data.payload)); - sortParticipants(); - break; - } - case 'update': - { - // if the change is related to the current user - if (updatedUserId == me.value.uid) { - me.value.isModerator = data.payload["isModerator"]; - me.value.hasRequestedToBeSpeaker = - data.payload["hasRequestedToBeSpeaker"] ?? false; - me.value.isMicOn = data.payload["isMicOn"]; - me.value.isSpeaker = data.payload["isSpeaker"]; - } - updateParticipantDataInList(data.payload); - sortParticipants(); - break; - } - case 'delete': - { - if (updatedUserId == me.value.uid) { - customSnackbar( - AppLocalizations.of(Get.context!)!.alert, - AppLocalizations.of(Get.context!)!.removedFromRoom, - LogType.warning, - ); - Get.delete(force: true); - await Get.delete(); - } else { - removeParticipantDataFromList(data.payload["uid"]); - break; - } - } - } - } - log(data.payload.toString()); - } - }); - } - - void sortParticipants() { - participants.sort((a, b) { - // Sort by isAdmin (admins first, then non-admins) - if (b.value.isAdmin && !a.value.isAdmin) return 1; - if (!b.value.isAdmin && a.value.isAdmin) return -1; - - // If isAdmin is the same, sort by isModerator (moderators first, then non-moderators) - if (b.value.isModerator && !a.value.isModerator) return 1; - if (!b.value.isModerator && a.value.isModerator) return -1; - - // Continue sorting by isSpeaker, hasRequestedToBeSpeaker (true first, then false) - if (b.value.isSpeaker && !a.value.isSpeaker) return 1; - if (!b.value.isSpeaker && a.value.isSpeaker) return -1; - - if (b.value.hasRequestedToBeSpeaker && !a.value.hasRequestedToBeSpeaker) { - return 1; - } - if (!b.value.hasRequestedToBeSpeaker && a.value.hasRequestedToBeSpeaker) { - return -1; - } - - return 0; // If all properties are equal (or if Listener), maintain the current order. - }); - } - - Future leaveRoom() async { - loadingDialog(Get.context!); - await subscription?.close(); - await RoomService.leaveRoom(roomId: appwriteRoom.id); - Get.delete(force: true); - Get.delete(); - } - - Future deleteRoom() async { - try { - isLoading.value = true; - await RoomService.deleteRoom(roomId: appwriteRoom.id); - await roomsController.getRooms(); - Get.delete(force: true); - Get.delete(); - } catch (e) { - log( - "Error in Delete Room Function (SingleRoomController): ${e.toString()}", - ); - } finally { - isLoading.value = false; - } - } - - Future getParticipantDocId(Participant participant) async { - var participantDocsRef = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [ - Query.equal('roomId', appwriteRoom.id), - Query.equal('uid', participant.uid), - ], - ); - return participantDocsRef.rows.first.$id; - } - - Future updateParticipantDoc( - String participantDocId, - Map data, - ) async { - await tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: participantDocId, - data: data, - ); - } - - Future turnOnMic() async { - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(true); - await updateParticipantDoc(appwriteRoom.myDocId!, {"isMicOn": true}); - me.value.isMicOn = true; - } - - Future turnOffMic() async { - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(false); - await updateParticipantDoc(appwriteRoom.myDocId!, {"isMicOn": false}); - me.value.isMicOn = false; - } - - Future raiseHand() async { - await updateParticipantDoc(appwriteRoom.myDocId!, { - "hasRequestedToBeSpeaker": true, - }); - me.value.hasRequestedToBeSpeaker = true; - } - - Future unRaiseHand() async { - await updateParticipantDoc(appwriteRoom.myDocId!, { - "hasRequestedToBeSpeaker": false, - }); - me.value.hasRequestedToBeSpeaker = false; - } - - Future makeModerator(Participant participant) async { - String participantDocId = await getParticipantDocId(participant); - await updateParticipantDoc(participantDocId, { - "isSpeaker": true, - "hasRequestedToBeSpeaker": false, - "isModerator": true, - }); - } - - Future removeModerator(Participant participant) async { - String participantDocId = await getParticipantDocId(participant); - await updateParticipantDoc(participantDocId, { - "isSpeaker": false, - "hasRequestedToBeSpeaker": false, - "isModerator": false, - }); - } - - Future reportParticipant(Participant participant) async { - final bool? didSubmit = await Get.dialog( - ReportWidget( - participantName: participant.name, - participantId: participant.uid, - ), - ); - if (didSubmit == true) { - try { - await tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: appwriteRoom.id, - data: { - "reportedUsers": [...appwriteRoom.reportedUsers, participant.uid], - }, - ); - appwriteRoom.reportedUsers.add(participant.uid); - } catch (e) { - log(e.toString()); - } - kickOutParticipant(participant); - } - } - - Future makeSpeaker(Participant participant) async { - String participantDocId = await getParticipantDocId(participant); - await updateParticipantDoc(participantDocId, { - "isSpeaker": true, - "hasRequestedToBeSpeaker": false, - }); - } - - Future makeListener(Participant participant) async { - String participantDocId = await getParticipantDocId(participant); - await updateParticipantDoc(participantDocId, { - "isSpeaker": false, - "hasRequestedToBeSpeaker": false, - }); - } - - Future kickOutParticipant(Participant participant) async { - String participantDocId = await getParticipantDocId(participant); - await tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: participantDocId, - ); - } - - void openChatSheet() { - showModalBottomSheet( - context: Get.context!, - builder: (ctx) { - Get.put(RoomChatController(appwriteRoom: appwriteRoom)); - return RoomChatScreen(); - }, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(50)), - ), - isScrollControlled: true, - enableDrag: false, - isDismissible: false, - ); - } -} diff --git a/lib/controllers/tabview_controller.dart b/lib/controllers/tabview_controller.dart index aa438fc9..ec1a4ef9 100644 --- a/lib/controllers/tabview_controller.dart +++ b/lib/controllers/tabview_controller.dart @@ -3,16 +3,14 @@ import 'dart:developer'; import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; import 'package:get/get.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/models/appwrite_room.dart'; +import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/view/widgets/live_room_tile.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/app_router.dart'; import 'package:resonate/utils/colors.dart'; -import 'package:resonate/views/widgets/live_room_tile.dart'; - -import '../views/screens/room_screen.dart'; class TabViewController extends GetxController { final RxInt _selectedIndex = 0.obs; @@ -36,14 +34,12 @@ class TabViewController extends GetxController { Future initAppLinks() async { _appLinks = AppLinks(); - // Check initial link if app was in cold state (terminated) final appLink = await _appLinks.getInitialLink(); if (appLink != null) { log('getInitialAppLink: $appLink'); openAppLink(appLink); } - // Handle link when app is in warm state (front or background) _linkSubscription = _appLinks.uriLinkStream.listen((uri) { log('onAppLink: $uri'); openAppLink(uri); @@ -52,39 +48,34 @@ class TabViewController extends GetxController { void openAppLink(Uri uri) async { try { - String roomId = uri.pathSegments.last; + final roomId = uri.pathSegments.last; final authState = await rootContainer.read(authProvider.future); - bool isUserLoggedIn = authState.hasSession; - if (isUserLoggedIn) { - AppwriteRoom appwriteRoom = await Get.find() - .getRoomById(roomId); - Get.defaultDialog( - title: AppLocalizations.of(Get.context!)!.joinRoom, - titleStyle: const TextStyle(color: Colors.amber, fontSize: 25), - content: Column( - children: [CustomLiveRoomTile(appwriteRoom: appwriteRoom)], - ), + if (!authState.hasSession) return; + final userUid = authState.userOrNull?.uid; + if (userUid == null) return; + + final room = await rootContainer + .read(roomsRepositoryProvider) + .getRoomById(roomId, userUid); + if (room == null) return; + + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) return; + await showDialog( + context: ctx, + builder: (dialogCtx) => AlertDialog( backgroundColor: AppColor.bgBlackColor, - ); - } + title: Text( + AppLocalizations.of(dialogCtx)!.joinRoom, + style: const TextStyle(color: Colors.amber, fontSize: 25), + ), + content: SingleChildScrollView( + child: CustomLiveRoomTile(appwriteRoom: room), + ), + ), + ); } catch (e) { - log("Open App Link ERROR : ${e.toString()}"); + log('Open App Link ERROR : ${e.toString()}'); } } - - void openRoomSheet(AppwriteRoom room) { - showModalBottomSheet( - context: Get.context!, - builder: (ctx) { - return RoomScreen(room: room); - }, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(50)), - ), - isScrollControlled: true, - enableDrag: true, - isDismissible: false, - ); - } } diff --git a/lib/controllers/upcomming_rooms_controller.dart b/lib/controllers/upcomming_rooms_controller.dart deleted file mode 100644 index f87f85c7..00000000 --- a/lib/controllers/upcomming_rooms_controller.dart +++ /dev/null @@ -1,440 +0,0 @@ -import 'dart:developer'; -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart' hide Row; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:intl/intl.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/controllers/room_chat_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/models/appwrite_upcomming_room.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; -import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/screens/room_chat_screen.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class UpcomingRoomsController extends GetxController { - AuthUser get authStateController => requireCurrentAuthUser; - final CreateRoomController createRoomController; - final TabViewController controller; - final ThemeController themeController; - final RoomsController roomsController; - final TablesDB tablesDB; - final FirebaseMessaging messaging; - TextEditingController dateTimeController = TextEditingController(text: ""); - Rx upcomingRoomScrollController = ScrollController().obs; - late RxList upcomingRooms = - [].obs; - late final GetStorage _storage; - static const String _removedUpcomingRoomsKey = 'removed_upcoming_rooms'; - List _removedRoomsList = []; - UpcomingRoomsController({ - CreateRoomController? createRoomController, - TabViewController? tabViewController, - ThemeController? themeController, - RoomsController? roomsController, - TablesDB? tablesDB, - FirebaseMessaging? messaging, - GetStorage? storage, - }) : createRoomController = - createRoomController ?? Get.find(), - controller = tabViewController ?? Get.find(), - themeController = themeController ?? Get.find(), - roomsController = roomsController ?? Get.find(), - tablesDB = tablesDB ?? AppwriteService.getTables(), - messaging = messaging ?? FirebaseMessaging.instance { - _storage = storage ?? GetStorage(); - } - late String scheduledDateTime; - late Row currentUserDoc; - late Duration localTimeZoneOffset; - late String localTimeZoneName; - late bool isOffsetNegetive; - Rx isLoading = false.obs; - RxBool searchBarIsEmpty = true.obs; - RxList filteredUpcomingRooms = - [].obs; - late DateTime currentTimeInstance; - final Map monthMap = { - "1": "Jan", - "2": "Feb", - "3": "March", - "5": "May", - "4": "April", - "6": "June", - "7": "July", - "8": "Aug", - "9": "Sep", - "10": "Oct", - "11": "Nov", - "12": "Dec", - }; - @override - void onInit() async { - super.onInit(); - _removedRoomsList = List.from( - _storage.read(_removedUpcomingRoomsKey) ?? [], - ); - await getUpcomingRooms(); - } - - Future cleanupRemovedRooms(List existingRoomIds) async { - try { - int initialCount = _removedRoomsList.length; - _removedRoomsList.removeWhere( - (roomId) => !existingRoomIds.contains(roomId), - ); - if (_removedRoomsList.length != initialCount) { - await _storage.write(_removedUpcomingRoomsKey, _removedRoomsList); - log( - 'Cleaned up ${initialCount - _removedRoomsList.length} non-existent rooms', - ); - } - } catch (e) { - log('Error cleaning up removed rooms: ${e.toString()}'); - } - } - - Future addUserToSubscriberList(String upcomingRoomId) async { - final fcmToken = await messaging.getToken(); - await tablesDB.createRow( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - rowId: ID.unique(), - data: { - "userID": authStateController.uid, - "upcomingRoomId": upcomingRoomId, - "registrationTokens": [fcmToken], - "userProfileUrl": authStateController.profileImageUrl, - }, - ); - - await getUpcomingRooms(); - } - - Future removeUserFromSubscriberList(String upcomingRoomId) async { - try { - var subscribeDocument = await tablesDB - .listRows( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - queries: [ - Query.and([ - Query.equal('userID', authStateController.uid), - Query.equal('upcomingRoomId', upcomingRoomId), - ]), - ], - ) - .then((value) => value.rows.first); - - await tablesDB.deleteRow( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - rowId: subscribeDocument.$id, - ); - - await getUpcomingRooms(); - } on AppwriteException catch (error) { - log(error.toString()); - } - } - - Future fetchUpcomingRoomDetails( - Row upcomingRoom, - ) async { - try { - List upcomingRoomSubscribers; - int totalSubscriberCount; - bool userIsCreator = - (authStateController.uid == upcomingRoom.data["creatorUid"]); - bool hasUserSubscribed = false; - - upcomingRoomSubscribers = await tablesDB - .listRows( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - queries: [ - Query.equal('upcomingRoomId', [upcomingRoom.$id]), - ], - ) - .then((value) => value.rows); - totalSubscriberCount = upcomingRoomSubscribers.length; - - List subscribersProfileUrls = []; - - for (var doc in upcomingRoomSubscribers) { - subscribersProfileUrls.add(doc.data["userProfileUrl"]); - - if (!userIsCreator) { - if (doc.data['userID'] == authStateController.uid) { - hasUserSubscribed = true; - } - } - } - - currentTimeInstance = DateTime.now(); - localTimeZoneOffset = currentTimeInstance.timeZoneOffset; - localTimeZoneName = currentTimeInstance.timeZoneName; - isOffsetNegetive = localTimeZoneOffset.isNegative; - return AppwriteUpcommingRoom( - id: upcomingRoom.$id, - name: upcomingRoom.data['name'], - isTime: upcomingRoom.data['isTime'], - scheduledDateTime: DateTime.parse( - upcomingRoom.data['scheduledDateTime'], - ), - totalSubscriberCount: totalSubscriberCount, - tags: upcomingRoom.data['tags'], - subscribersAvatarUrls: subscribersProfileUrls, - userIsCreator: userIsCreator, - description: upcomingRoom.data['description'], - hasUserSubscribed: hasUserSubscribed, - ); - } catch (e) { - log(e.toString()); - return AppwriteUpcommingRoom( - id: '', // Provide some default/fallback values - name: 'Unknown', - isTime: false, - scheduledDateTime: DateTime.now(), - totalSubscriberCount: 0, - tags: [], - subscribersAvatarUrls: [], - userIsCreator: false, - description: 'Error fetching upcomingRoom details', - hasUserSubscribed: false, - ); - } - } - - Future getUpcomingRooms() async { - isLoading.value = true; - try { - List upcomingRoomsDocuments = await tablesDB - .listRows( - databaseId: upcomingRoomsDatabaseId, - tableId: upcomingRoomsTableId, - ) - .then((value) => value.rows); - List nonRemovedRooms = upcomingRoomsDocuments - .where((room) => !_removedRoomsList.contains(room.$id)) - .toList(); - List> roomsFutures = nonRemovedRooms - .map((room) => fetchUpcomingRoomDetails(room)) - .toList(); - upcomingRooms.value = await Future.wait(roomsFutures); - await cleanupRemovedRooms( - upcomingRoomsDocuments.map((doc) => doc.$id).toList(), - ); - } catch (e) { - log(e.toString()); - } finally { - isLoading.value = false; - update(); - } - } - - Future convertUpcomingRoomtoRoom( - String upcomingRoomId, - String name, - String description, - List tags, - ) async { - await createRoomController.createRoom(name, description, tags, false); - controller.setIndex(0); - await roomsController.getRooms(); - - // Delete UpcomingRoom as it is now a room - await deleteUpcomingRoom(upcomingRoomId); - await getUpcomingRooms(); - } - - Future createUpcomingRoom() async { - if (!createRoomController.createRoomFormKey.currentState!.validate()) { - return; - } - try { - final fcmToken = await messaging.getToken(); - await tablesDB.createRow( - databaseId: upcomingRoomsDatabaseId, - tableId: upcomingRoomsTableId, - rowId: ID.unique(), - data: { - "name": createRoomController.nameController.text, - "scheduledDateTime": scheduledDateTime, - "tags": createRoomController.tagsController.getTags, - "description": createRoomController.descriptionController.text, - "creatorUid": authStateController.uid, - "creator_fcm_tokens": [fcmToken], - }, - ); - } on AppwriteException catch (e) { - log(e.toString()); - } - } - - String formatTimeZoneOffset(String offset, bool isNegative) { - String formattedOffset; - List splittedOffset = offset.split(":"); - if (isNegative) { - List removingMinus = splittedOffset[0].split('-'); - String removedMinusHour = removingMinus[1]; - formattedOffset = - '${removedMinusHour.length < 2 ? '0$removedMinusHour' : removedMinusHour}:${splittedOffset[1].length < 2 ? '0${splittedOffset[1]}' : splittedOffset[1]}'; - } else { - formattedOffset = - '${splittedOffset[0].length < 2 ? '0${splittedOffset[0]}' : splittedOffset[0]}:${splittedOffset[1].length < 2 ? '0${splittedOffset[1]}' : splittedOffset[1]}'; - } - return formattedOffset; - } - - Future chooseDateTime() async { - DateTime now = DateTime.now(); - Duration timeZoneOffset = now.timeZoneOffset; - String timeZoneOffsetString = timeZoneOffset.toString(); - bool isOffsetNegetive = timeZoneOffset.isNegative; - String formattedOffset = formatTimeZoneOffset( - timeZoneOffsetString, - isOffsetNegetive, - ); - DateTime? pickedDate = await showDatePicker( - context: Get.context!, - initialDate: now, - firstDate: now, - lastDate: DateTime(DateTime.now().year + 1, now.month, now.day), - ); - DateTime initialTime = now.add(const Duration(minutes: 5)); - - TimeOfDay? pickedTime = await showTimePicker( - context: Get.context!, - initialTime: TimeOfDay( - hour: initialTime.hour, - minute: initialTime.minute, - ), - ); - if (pickedDate != null && pickedTime != null) { - DateTime pickedDateTime = DateTime( - pickedDate.year, - pickedDate.month, - pickedDate.day, - pickedTime.hour, - pickedTime.minute, - ); - if (!pickedDateTime.isAfter(now)) { - Get.snackbar( - AppLocalizations.of(Get.context!)!.invalidScheduledDateTime, - AppLocalizations.of(Get.context!)!.scheduledDateTimePast, - ); - return; - } - scheduledDateTime = - '${DateFormat("yyyy-MM-dd").format(pickedDate).toString()}T${pickedTime.hour.toString()}:${pickedTime.minute.toString()}:00${isOffsetNegetive ? '-' : '+'}$formattedOffset'; - dateTimeController.text = - '${pickedDate.day} ${monthMap[pickedDate.month.toString()]} ${pickedDate.year} ${pickedTime.hour > 12 - ? (pickedTime.hour - 12) - : pickedTime.hour == 0 - ? '00' - : pickedTime.hour}:${pickedTime.minute.toString().length < 2 ? '0${pickedTime.minute}' : pickedTime.minute.toString()} ${pickedTime.period.name.toUpperCase()}'; - } - } - - Future removeUpcomingRoom(String upcomingRoomId) async { - try { - if (!_removedRoomsList.contains(upcomingRoomId)) { - _removedRoomsList.add(upcomingRoomId); - await _storage.write(_removedUpcomingRoomsKey, _removedRoomsList); - log('Room $upcomingRoomId removed. Total: ${_removedRoomsList.length}'); - } - upcomingRooms.removeWhere((room) => room.id == upcomingRoomId); - update(); - } catch (e) { - log("Error in Remove Upcoming Room Function: ${e.toString()}"); - } - } - - Future deleteUpcomingRoom(String upcomingRoomId) async { - await tablesDB.deleteRow( - databaseId: upcomingRoomsDatabaseId, - tableId: upcomingRoomsTableId, - rowId: upcomingRoomId, - ); - deleteAllDeletedUpcomingRoomsSubscribers(upcomingRoomId); - } - - Future deleteAllDeletedUpcomingRoomsSubscribers( - String upcomingRoomId, - ) async { - List deletedUpcomingRoomSubscribers = await tablesDB - .listRows( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - queries: [ - Query.equal('upcomingRoomId', [upcomingRoomId]), - ], - ) - .then((value) => value.rows); - - for (Row subscriber in deletedUpcomingRoomSubscribers) { - await tablesDB.deleteRow( - databaseId: upcomingRoomsDatabaseId, - tableId: subscribedUserTableId, - rowId: subscriber.$id, - ); - } - } - - void openUpcomingChatSheet(AppwriteUpcommingRoom appwriteRoom) { - showModalBottomSheet( - context: Get.context!, - builder: (ctx) { - Get.put(RoomChatController(appwriteUpcommingRoom: appwriteRoom)); - return RoomChatScreen(); - }, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(50)), - ), - isScrollControlled: true, - enableDrag: false, - isDismissible: false, - ); - } - - Future searchUpcomingRooms(String query) async { - if (query.isEmpty) { - filteredUpcomingRooms.value = upcomingRooms; - searchBarIsEmpty.value = true; - return; - } - - searchBarIsEmpty.value = false; - - try { - final lowerQuery = query.toLowerCase(); - filteredUpcomingRooms.value = upcomingRooms.where((room) { - return room.name.toLowerCase().contains(lowerQuery) || - room.description.toLowerCase().contains(lowerQuery); - }).toList(); - } catch (e) { - filteredUpcomingRooms.value = upcomingRooms; - customSnackbar( - AppLocalizations.of(Get.context!)!.error, - AppLocalizations.of(Get.context!)!.searchFailed, - LogType.error, - ); - } - } - - void clearUpcomingSearch() { - filteredUpcomingRooms.value = upcomingRooms; - searchBarIsEmpty.value = true; - } -} diff --git a/lib/core/container.dart b/lib/core/container.dart index 34185c49..7c6c04c6 100644 --- a/lib/core/container.dart +++ b/lib/core/container.dart @@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/rooms/data/livekit_session.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; -/// Single root ProviderContainer for the app. +// Single root ProviderContainer for the app. ProviderContainer _rootContainer = ProviderContainer(); ProviderContainer get rootContainer => _rootContainer; @@ -34,3 +36,7 @@ ProviderSubscription> listenAuthState( fireImmediately: true, ); } + +// LiveKit bridge for legacy GetX controllers +LiveKitSession? get currentLiveKitSession => + rootContainer.read(liveKitProvider.notifier).session; diff --git a/lib/core/legacy_dependencies.dart b/lib/core/legacy_dependencies.dart index 5f61da80..5a971930 100644 --- a/lib/core/legacy_dependencies.dart +++ b/lib/core/legacy_dependencies.dart @@ -1,18 +1,14 @@ import 'package:get/get.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; import 'package:resonate/controllers/explore_story_controller.dart'; import 'package:resonate/controllers/friends_controller.dart'; import 'package:resonate/controllers/network_controller.dart'; import 'package:resonate/controllers/onboarding_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; -// Registers the GetX controllers that are still in use post-auth-migration. +// Registers the GetX controllers that are still in use post-rooms-migration. void setupLegacyGetXDependencies() { Get.put(NetworkController(), permanent: true); Get.lazyPut(() => TabViewController()); - Get.lazyPut(() => RoomsController()); - Get.lazyPut(() => CreateRoomController()); Get.lazyPut(() => OnboardingController()); Get.lazyPut(() => ExploreStoryController()); Get.lazyPut(() => FriendsController(), fenix: true); diff --git a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart index 0bd3aced..2b38e784 100644 --- a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart +++ b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart @@ -3,17 +3,14 @@ import 'dart:developer'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:flutter/material.dart' hide Row; import 'package:get/get.dart'; import 'package:resonate/controllers/friend_calling_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/features/auth/data/callkit_service.dart'; import 'package:resonate/features/auth/data/notification_service.dart'; import 'package:resonate/routes/app_router.dart'; import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/utils/ui_sizes.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/app_bootstrap_notifier.g.dart'; @@ -84,11 +81,6 @@ class AppBootstrap extends _$AppBootstrap { } void _openUpcomingRoom(String roomName) { - final upcoming = Get.find(); - final index = upcoming.upcomingRooms.indexWhere((r) => r.name == roomName); - upcoming.upcomingRoomScrollController.value = ScrollController( - initialScrollOffset: UiSizes.height_170 * index, - ); Get.find().setIndex(1); appRouter.go(RoutePaths.tabview); } diff --git a/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart b/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart index 84792cdb..305ac4f0 100644 --- a/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart +++ b/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart @@ -33,7 +33,7 @@ final class AppBootstrapProvider AppBootstrap create() => AppBootstrap(); } -String _$appBootstrapHash() => r'5421d891cf013154c901ca78de1eedbcabf8ae5e'; +String _$appBootstrapHash() => r'5e3576c9a41cc4f4120a281cdfd59d09ad805127'; abstract class _$AppBootstrap extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/rooms/data/audio_device_service.dart b/lib/features/rooms/data/audio_device_service.dart new file mode 100644 index 00000000..8b304192 --- /dev/null +++ b/lib/features/rooms/data/audio_device_service.dart @@ -0,0 +1,36 @@ +import 'dart:developer'; + +import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; +import 'package:resonate/models/audio_device.dart'; +import 'package:resonate/utils/enums/audio_device_enum.dart'; + +class AudioDeviceService { + Future> enumerateOutputDevices() async { + final devices = await webrtc.navigator.mediaDevices.enumerateDevices(); + return devices + .map((device) => AudioDevice.fromMediaDeviceInfo(device)) + .where((d) => d.isAudioOutput) + .toList(); + } + + Future selectOutput(AudioDevice device) async { + try { + await webrtc.Helper.selectAudioOutput(device.deviceId); + return true; + } catch (e) { + log('Error selecting audio output: $e'); + return false; + } + } + + String displayNameFor(AudioDevice device) { + final deviceType = device.deviceType; + if (deviceType == AudioDeviceType.bluetoothAudio) { + return device.label; + } + if (deviceType == AudioDeviceType.unknown && device.label.isNotEmpty) { + return device.label; + } + return device.label.isNotEmpty ? deviceType.displayName : 'Unknown Device'; + } +} diff --git a/lib/features/rooms/data/generated/room_chat_repository.g.dart b/lib/features/rooms/data/generated/room_chat_repository.g.dart new file mode 100644 index 00000000..d0c96e85 --- /dev/null +++ b/lib/features/rooms/data/generated/room_chat_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../room_chat_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(roomChatRepository) +final roomChatRepositoryProvider = RoomChatRepositoryProvider._(); + +final class RoomChatRepositoryProvider + extends + $FunctionalProvider< + RoomChatRepository, + RoomChatRepository, + RoomChatRepository + > + with $Provider { + RoomChatRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roomChatRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomChatRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + RoomChatRepository create(Ref ref) { + return roomChatRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RoomChatRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$roomChatRepositoryHash() => + r'15c06a01d6f20e32d4a192db48561e9ac0ee1610'; diff --git a/lib/features/rooms/data/generated/rooms_repository.g.dart b/lib/features/rooms/data/generated/rooms_repository.g.dart new file mode 100644 index 00000000..8d5b8817 --- /dev/null +++ b/lib/features/rooms/data/generated/rooms_repository.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../rooms_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(roomsRepository) +final roomsRepositoryProvider = RoomsRepositoryProvider._(); + +final class RoomsRepositoryProvider + extends + $FunctionalProvider + with $Provider { + RoomsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roomsRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomsRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + RoomsRepository create(Ref ref) { + return roomsRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RoomsRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$roomsRepositoryHash() => r'da94bc7ff45cb87da7148a44847f3262d554162a'; diff --git a/lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart b/lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart new file mode 100644 index 00000000..ec517472 --- /dev/null +++ b/lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../upcoming_rooms_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(upcomingRoomsRepository) +final upcomingRoomsRepositoryProvider = UpcomingRoomsRepositoryProvider._(); + +final class UpcomingRoomsRepositoryProvider + extends + $FunctionalProvider< + UpcomingRoomsRepository, + UpcomingRoomsRepository, + UpcomingRoomsRepository + > + with $Provider { + UpcomingRoomsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'upcomingRoomsRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$upcomingRoomsRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + UpcomingRoomsRepository create(Ref ref) { + return upcomingRoomsRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UpcomingRoomsRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$upcomingRoomsRepositoryHash() => + r'43612b78945f87a341d0153960c104cfffba6e89'; diff --git a/lib/features/rooms/data/livekit_session.dart b/lib/features/rooms/data/livekit_session.dart new file mode 100644 index 00000000..23ebcd9c --- /dev/null +++ b/lib/features/rooms/data/livekit_session.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:path_provider/path_provider.dart'; + +class LiveKitSession { + LiveKitSession({ + required this.liveKitUri, + required this.roomToken, + this.isLiveChapter = false, + this.maxAttempts = 3, + this.retryInterval = const Duration(seconds: 2), + }); + + final String liveKitUri; + final String roomToken; + final bool isLiveChapter; + final int maxAttempts; + final Duration retryInterval; + + Room? _room; + EventsListener? _listener; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + + final _connectionStateController = StreamController.broadcast(); + final _eventsController = StreamController.broadcast(); + final _recordingController = StreamController.broadcast(); + final _mediaRecorder = MediaRecorder(); + + bool _isConnected = false; + bool _isRecording = false; + bool _disposed = false; + + Room? get room => _room; + bool get isConnected => _isConnected; + bool get isRecording => _isRecording; + Stream get connectionStates => _connectionStateController.stream; + Stream get events => _eventsController.stream; + Stream get recordingStates => _recordingController.stream; + + Future connect({bool isReconnect = false}) async { + if (_disposed) return false; + if (!isReconnect) _reconnectAttempts = 0; + + while (_reconnectAttempts < maxAttempts) { + Room? attempt; + try { + attempt = Room( + roomOptions: const RoomOptions( + dynacast: false, + adaptiveStream: false, + defaultVideoPublishOptions: VideoPublishOptions(simulcast: false), + ), + ); + _room = attempt; + _listener = attempt.createListener(); + + await attempt.connect( + liveKitUri, + roomToken, + connectOptions: const ConnectOptions(autoSubscribe: true), + ); + await Future.delayed(const Duration(milliseconds: 1000)); + + _isConnected = true; + _connectionStateController.add(true); + _reconnectAttempts = 0; + _setupEventListeners(); + if (isLiveChapter) _listenToRecordingChanges(); + return true; + } catch (error) { + _reconnectAttempts++; + log('LiveKit connect attempt $_reconnectAttempts/$maxAttempts failed: $error'); + if (attempt != null) { + try { + await attempt.disconnect(); + await attempt.dispose(); + } catch (e) { + log('LiveKit cleanup error: $e'); + } + } + if (_reconnectAttempts < maxAttempts) { + await Future.delayed(retryInterval); + } + } + } + + _isConnected = false; + _connectionStateController.add(false); + return false; + } + + Future handleDisconnection() async { + _isConnected = false; + _connectionStateController.add(false); + + if (_reconnectAttempts < maxAttempts) { + _reconnectAttempts++; + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(retryInterval, () async { + await connect(isReconnect: true); + }); + } + } + + void _setupEventListeners() { + _listener + ?..on((event) async { + if (event.reason != null) { + log('Room disconnected: ${event.reason}'); + _eventsController.add(event); + await handleDisconnection(); + } + }) + ..on((event) { + if (event.metadata == 'disconnected') { + handleDisconnection(); + } + }) + ..on((event) { + log('Recording status changed: ${event.activeRecording}'); + _eventsController.add(event); + }); + } + + void _listenToRecordingChanges() async { + final storagePath = await getApplicationDocumentsDirectory(); + _recordingController.stream.listen((state) async { + if (state) { + await _mediaRecorder.start( + '${storagePath.path}/recordings/${_room?.name}.mp4', + audioChannel: RecorderAudioChannel.INPUT, + ); + } else { + await _mediaRecorder.stop(); + } + }); + } + + Future setMicrophoneEnabled(bool enabled) async { + await _room?.localParticipant?.setMicrophoneEnabled(enabled); + } + + Future setRecording(bool recording) async { + _isRecording = recording; + _recordingController.add(recording); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + _reconnectTimer?.cancel(); + try { + await _listener?.dispose(); + await _room?.dispose(); + } catch (e) { + log('LiveKit dispose error: $e'); + } + await _connectionStateController.close(); + await _eventsController.close(); + await _recordingController.close(); + } +} diff --git a/lib/features/rooms/data/room_chat_repository.dart b/lib/features/rooms/data/room_chat_repository.dart new file mode 100644 index 00000000..f8225e68 --- /dev/null +++ b/lib/features/rooms/data/room_chat_repository.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/rooms/model/reply_to.dart'; +import 'package:resonate/features/rooms/model/room_message.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/room_chat_repository.g.dart'; + +@Riverpod(keepAlive: true) +RoomChatRepository roomChatRepository(Ref ref) => RoomChatRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + functions: ref.watch(appwriteFunctionsProvider), +); + +class RoomChatRepository { + RoomChatRepository({ + required TablesDB tables, + required Realtime realtime, + required Functions functions, + }) : _tables = tables, + _realtime = realtime, + _functions = functions; + + final TablesDB _tables; + final Realtime _realtime; + final Functions _functions; + + Future> loadMessages(String roomId) async { + final result = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + queries: [ + Query.equal('roomId', roomId), + Query.orderAsc('index'), + Query.limit(100), + ], + ); + + final messages = []; + for (final row in result.rows) { + final replyTo = await _fetchReplyTo(row.$id); + final json = Map.from(row.data); + if (replyTo != null) { + json['replyTo'] = replyTo.toJson(); + } + messages.add(RoomMessage.fromJson(json)); + } + messages.sort((a, b) => a.index.compareTo(b.index)); + return messages; + } + + Future _fetchReplyTo(String messageId) async { + try { + final doc = await _tables.getRow( + databaseId: masterDatabaseId, + tableId: chatMessageReplyTableId, + rowId: messageId, + ); + return ReplyTo.fromJson(doc.data); + } on AppwriteException catch (e) { + if (e.code == 404) return null; + rethrow; + } + } + + Future fetchReplyTo(String messageId) => _fetchReplyTo(messageId); + + Future sendMessage({ + required RoomMessage message, + ReplyTo? replyTo, + }) async { + await _tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: message.messageId, + data: message.toJsonForUpload(), + ); + if (replyTo != null) { + await _tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessageReplyTableId, + rowId: message.messageId, + data: replyTo.toJson(), + ); + } + } + + Future editMessage(RoomMessage updated) async { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: updated.messageId, + data: updated.toJsonForUpload(), + ); + } + + Future deleteMessage(RoomMessage softDeleted) async { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: softDeleted.messageId, + data: softDeleted.toJsonForUpload(), + ); + } + + Future notifyUpcomingRoomSubscribers({ + required String upcomingRoomId, + required String title, + required String body, + }) async { + await _functions.createExecution( + functionId: sendMessageNotificationFunctionID, + body: json.encode({ + 'roomId': upcomingRoomId, + 'payload': {'title': title, 'body': body}, + }), + ); + } + + Stream<({RoomMessage message, String action})> messageStream( + String roomId, + ) { + final channel = + 'databases.$masterDatabaseId.tables.$chatMessagesTableId.rows'; + final subscription = _realtime.subscribe([channel]); + final controller = + StreamController<({RoomMessage message, String action})>(); + + final sub = subscription.stream.listen((data) async { + if (data.payload.isEmpty || data.payload['roomId'] != roomId) return; + + final docId = data.payload['\$id'] as String; + final action = data.events.first.substring( + channel.length + 1 + docId.length + 1, + ); + + if (action == 'create' || action == 'update') { + try { + final replyTo = await _fetchReplyTo(docId); + final json = Map.from(data.payload); + if (replyTo != null) json['replyTo'] = replyTo.toJson(); + controller.add( + (message: RoomMessage.fromJson(json), action: action), + ); + } catch (_) {} + } + }); + + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } +} diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/rooms_repository.dart new file mode 100644 index 00000000..5a045e7d --- /dev/null +++ b/lib/features/rooms/data/rooms_repository.dart @@ -0,0 +1,417 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/participant.dart'; +import 'package:resonate/features/rooms/model/room_failure.dart'; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/room_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/rooms_repository.g.dart'; + +@Riverpod(keepAlive: true) +RoomsRepository roomsRepository(Ref ref) => RoomsRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + apiService: ApiService(functions: ref.watch(appwriteFunctionsProvider)), +); + +class RoomsRepository { + RoomsRepository({ + required TablesDB tables, + required Realtime realtime, + required ApiService apiService, + FlutterSecureStorage? secureStorage, + }) : _tables = tables, + _realtime = realtime, + _api = apiService, + _secureStorage = secureStorage ?? const FlutterSecureStorage(); + + final TablesDB _tables; + final Realtime _realtime; + final ApiService _api; + final FlutterSecureStorage _secureStorage; + + TablesDB get tables => _tables; + + Future> loadRooms(String userUid) async { + final result = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + ); + + final rooms = []; + for (final row in result.rows) { + try { + final room = await _buildAppwriteRoom(row, userUid); + if (!room.reportedUsers.contains(userUid)) { + rooms.add(room); + } + } catch (_) { + // Skip rows that fail to hydrate (missing/malformed fields). + } + } + return rooms; + } + + Future getRoomById(String roomId, String userUid) async { + try { + final row = await _tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + ); + return _buildAppwriteRoom(row, userUid); + } on AppwriteException catch (e) { + if (e.code == 404) return null; + throw _mapException(e); + } + } + + Future _buildAppwriteRoom(Row row, String userUid) async { + final participantList = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [Query.equal('roomId', row.$id), Query.limit(3)], + ); + + final memberAvatarUrls = []; + for (final p in participantList.rows) { + try { + final userDoc = await _tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: p.data['uid'] as String, + ); + final url = userDoc.data['profileImageUrl']; + if (url is String) memberAvatarUrls.add(url); + } catch (_) { + // Skip avatars we can't fetch — the room can still render. + } + } + + final data = row.data; + return AppwriteRoom( + id: row.$id, + name: (data['name'] as String?) ?? 'Untitled', + description: (data['description'] as String?) ?? '', + totalParticipants: (data['totalParticipants'] as num?)?.toInt() ?? 0, + tags: List.from(data['tags'] as List? ?? const []), + memberAvatarUrls: memberAvatarUrls, + state: RoomState.live, + isUserAdmin: data['adminUid'] == userUid, + reportedUsers: + List.from(data['reportedUsers'] as List? ?? const []), + ); + } + + Future<({String roomId, String myDocId, String liveKitUri, String roomToken})> + createRoom({ + required String name, + required String description, + required List tags, + required String adminUid, + }) async { + try { + final response = await _api.createRoom(name, description, adminUid, tags); + final roomId = response['livekit_room']['name'] as String; + final roomToken = response['access_token'] as String; + final socketUrl = response['livekit_socket_url'] as String; + final liveKitUri = socketUrl == 'wss://host.docker.internal:7880' + ? localhostLivekitEndpoint + : socketUrl; + + await _secureStorage.write(key: 'createdRoomAdminToken', value: roomToken); + await _secureStorage.write(key: 'createdRoomLivekitUrl', value: liveKitUri); + + final myDocId = await _addParticipant( + roomId: roomId, + uid: adminUid, + isAdmin: true, + ); + + return ( + roomId: roomId, + myDocId: myDocId, + liveKitUri: liveKitUri, + roomToken: roomToken, + ); + } on AppwriteException catch (e) { + throw _mapException(e); + } + } + + Future<({String myDocId, String liveKitUri, String roomToken})> joinRoom({ + required String roomId, + required String userId, + required bool isAdmin, + }) async { + try { + final response = await _api.joinRoom(roomId, userId); + final roomToken = response['access_token'] as String; + final socketUrl = response['livekit_socket_url'] as String; + final liveKitUri = socketUrl == 'wss://host.docker.internal:7880' + ? localhostLivekitEndpoint + : socketUrl; + + final myDocId = await _addParticipant( + roomId: roomId, + uid: userId, + isAdmin: isAdmin, + ); + + return (myDocId: myDocId, liveKitUri: liveKitUri, roomToken: roomToken); + } on AppwriteException catch (e) { + throw _mapException(e); + } + } + + Future _addParticipant({ + required String roomId, + required String uid, + required bool isAdmin, + }) async { + final existing = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [ + Query.equal('uid', [uid]), + Query.equal('roomId', [roomId]), + ], + ); + for (final doc in existing.rows) { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: doc.$id, + ); + } + + final participantDoc = await _tables.createRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: ID.unique(), + data: { + 'roomId': roomId, + 'uid': uid, + 'isAdmin': isAdmin, + 'isModerator': isAdmin, + 'isSpeaker': isAdmin, + 'isMicOn': false, + }, + ); + + if (!isAdmin) { + final roomDoc = await _tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + ); + final newCount = + (roomDoc.data['totalParticipants'] as int) - existing.rows.length + 1; + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + data: {'totalParticipants': newCount}, + ); + } + + return participantDoc.$id; + } + + Future leaveRoom({required String roomId, required String userId}) async { + try { + final roomDoc = await _tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + ); + + final participantDocs = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [ + Query.equal('uid', [userId]), + Query.equal('roomId', [roomId]), + ], + ); + for (final doc in participantDocs.rows) { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: doc.$id, + ); + } + + final remaining = + (roomDoc.data['totalParticipants'] as int) - participantDocs.rows.length; + if (remaining == 0) { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + ); + } else { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + data: {'totalParticipants': remaining}, + ); + } + return true; + } on AppwriteException catch (e) { + throw _mapException(e); + } + } + + Future deleteRoom({required String roomId}) async { + try { + final token = await _secureStorage.read(key: 'createdRoomAdminToken'); + if (token == null) { + throw const RoomFailure.permissionDenied(); + } + await _api.deleteRoom(roomId, token); + + final participantDocs = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [ + Query.equal('roomId', [roomId]), + ], + ); + for (final doc in participantDocs.rows) { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: doc.$id, + ); + } + } on AppwriteException catch (e) { + throw _mapException(e); + } + } + + Future> loadParticipants(String roomId) async { + final result = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [Query.equal('roomId', roomId)], + ); + + final participants = []; + for (final row in result.rows) { + try { + participants.add(await buildParticipantFromRow(row)); + } catch (_) { + // Skip participants whose user record is missing/malformed. + } + } + return participants; + } + + Future buildParticipantFromRow(Row row) async { + final userDoc = await _tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: row.data['uid'] as String, + ); + return Participant( + uid: row.data['uid'] as String, + email: userDoc.data['email'] as String, + name: userDoc.data['name'] as String? ?? 'Unknown', + dpUrl: userDoc.data['profileImageUrl'] as String? ?? '', + isAdmin: row.data['isAdmin'] as bool, + isMicOn: row.data['isMicOn'] as bool, + isModerator: row.data['isModerator'] as bool, + isSpeaker: row.data['isSpeaker'] as bool, + hasRequestedToBeSpeaker: + row.data['hasRequestedToBeSpeaker'] as bool? ?? false, + ); + } + + Stream participantStream(String roomId) { + final channel = + 'databases.$masterDatabaseId.tables.$participantsTableId.rows'; + final subscription = _realtime.subscribe([channel]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty && + event.payload['roomId'] == roomId) { + controller.add(event); + } + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } + + Future getParticipantDocId({ + required String roomId, + required String participantUid, + }) async { + final docs = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: [ + Query.equal('roomId', roomId), + Query.equal('uid', participantUid), + ], + ); + return docs.rows.first.$id; + } + + Future updateParticipantDoc({ + required String docId, + required Map data, + }) async { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: docId, + data: data, + ); + } + + Future reportParticipant({ + required String roomId, + required String participantUid, + required List currentReported, + }) async { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + data: { + 'reportedUsers': [...currentReported, participantUid], + }, + ); + } + + Future kickParticipant(String docId) async { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: docId, + ); + } + + static String participantChannel() => + 'databases.$masterDatabaseId.tables.$participantsTableId.rows'; + + RoomFailure _mapException(AppwriteException e) { + return switch (e.code) { + 404 => const RoomFailure.notFound(), + 401 || 403 => const RoomFailure.permissionDenied(), + _ => RoomFailure.unknown(e.message ?? e.toString()), + }; + } +} diff --git a/lib/features/rooms/data/upcoming_rooms_repository.dart b/lib/features/rooms/data/upcoming_rooms_repository.dart new file mode 100644 index 00000000..fd781334 --- /dev/null +++ b/lib/features/rooms/data/upcoming_rooms_repository.dart @@ -0,0 +1,190 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/core/providers/firebase_providers.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/upcoming_rooms_repository.g.dart'; + +@Riverpod(keepAlive: true) +UpcomingRoomsRepository upcomingRoomsRepository(Ref ref) => + UpcomingRoomsRepository( + tables: ref.watch(appwriteTablesProvider), + messaging: ref.watch(firebaseMessagingProvider), + ); + +class UpcomingRoomsRepository { + UpcomingRoomsRepository({ + required TablesDB tables, + required FirebaseMessaging messaging, + }) : _tables = tables, + _messaging = messaging; + + final TablesDB _tables; + final FirebaseMessaging _messaging; + + Future> loadUpcoming({ + required String userUid, + required Set hiddenRoomIds, + }) async { + final list = await _tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + ); + + final visible = list.rows.where((r) => !hiddenRoomIds.contains(r.$id)); + final upcomingFutures = + visible.map((row) => _hydrateUpcomingRoom(row, userUid)); + return Future.wait(upcomingFutures); + } + + Future> liveUpcomingRoomIds() async { + final list = await _tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + ); + return list.rows.map((r) => r.$id).toSet(); + } + + Future _hydrateUpcomingRoom( + Row upcomingRoom, + String userUid, + ) async { + try { + final subscribers = await _tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: [ + Query.equal('upcomingRoomId', [upcomingRoom.$id]), + ], + ); + + final userIsCreator = upcomingRoom.data['creatorUid'] == userUid; + final subscriberAvatars = []; + var hasUserSubscribed = false; + for (final doc in subscribers.rows) { + subscriberAvatars.add(doc.data['userProfileUrl'] as String); + if (!userIsCreator && doc.data['userID'] == userUid) { + hasUserSubscribed = true; + } + } + + return AppwriteUpcomingRoom( + id: upcomingRoom.$id, + name: upcomingRoom.data['name'] as String, + isTime: upcomingRoom.data['isTime'] as bool, + scheduledDateTime: DateTime.parse( + upcomingRoom.data['scheduledDateTime'] as String, + ), + description: upcomingRoom.data['description'] as String, + totalSubscriberCount: subscribers.rows.length, + tags: List.from(upcomingRoom.data['tags'] as List? ?? const []), + subscribersAvatarUrls: subscriberAvatars, + userIsCreator: userIsCreator, + hasUserSubscribed: hasUserSubscribed, + ); + } catch (_) { + return AppwriteUpcomingRoom( + id: '', + name: 'Unknown', + isTime: false, + scheduledDateTime: DateTime.now(), + description: 'Error fetching upcomingRoom details', + totalSubscriberCount: 0, + tags: const [], + subscribersAvatarUrls: const [], + userIsCreator: false, + hasUserSubscribed: false, + ); + } + } + + Future createUpcomingRoom({ + required String name, + required String description, + required List tags, + required String scheduledDateTime, + required String creatorUid, + }) async { + final fcmToken = await _messaging.getToken(); + await _tables.createRow( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + rowId: ID.unique(), + data: { + 'name': name, + 'scheduledDateTime': scheduledDateTime, + 'tags': tags, + 'description': description, + 'creatorUid': creatorUid, + 'creator_fcm_tokens': [fcmToken], + }, + ); + } + + Future addSubscriber({ + required String upcomingRoomId, + required String userUid, + required String profileImageUrl, + }) async { + final fcmToken = await _messaging.getToken(); + await _tables.createRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: ID.unique(), + data: { + 'userID': userUid, + 'upcomingRoomId': upcomingRoomId, + 'registrationTokens': [fcmToken], + 'userProfileUrl': profileImageUrl, + }, + ); + } + + Future removeSubscriber({ + required String upcomingRoomId, + required String userUid, + }) async { + final result = await _tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: [ + Query.and([ + Query.equal('userID', userUid), + Query.equal('upcomingRoomId', upcomingRoomId), + ]), + ], + ); + if (result.rows.isEmpty) return; + await _tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: result.rows.first.$id, + ); + } + + Future deleteUpcomingRoom(String upcomingRoomId) async { + await _tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + rowId: upcomingRoomId, + ); + final subscribers = await _tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: [ + Query.equal('upcomingRoomId', [upcomingRoomId]), + ], + ); + for (final sub in subscribers.rows) { + await _tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: sub.$id, + ); + } + } +} diff --git a/lib/features/rooms/model/appwrite_room.dart b/lib/features/rooms/model/appwrite_room.dart new file mode 100644 index 00000000..4c31130e --- /dev/null +++ b/lib/features/rooms/model/appwrite_room.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/utils/enums/room_state.dart'; + +part 'generated/appwrite_room.freezed.dart'; + +@freezed +abstract class AppwriteRoom with _$AppwriteRoom { + const factory AppwriteRoom({ + required String id, + required String name, + required String description, + required int totalParticipants, + required List tags, + required List memberAvatarUrls, + required RoomState state, + required bool isUserAdmin, + @Default([]) List reportedUsers, + String? myDocId, + }) = _AppwriteRoom; +} diff --git a/lib/features/rooms/model/appwrite_upcoming_room.dart b/lib/features/rooms/model/appwrite_upcoming_room.dart new file mode 100644 index 00000000..edbf86f7 --- /dev/null +++ b/lib/features/rooms/model/appwrite_upcoming_room.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/appwrite_upcoming_room.freezed.dart'; + +@freezed +abstract class AppwriteUpcomingRoom with _$AppwriteUpcomingRoom { + const factory AppwriteUpcomingRoom({ + required String id, + required String name, + required bool isTime, + required DateTime scheduledDateTime, + required String description, + required int totalSubscriberCount, + required List tags, + required List subscribersAvatarUrls, + required bool userIsCreator, + required bool hasUserSubscribed, + }) = _AppwriteUpcomingRoom; +} diff --git a/lib/features/rooms/model/audio_device_state.dart b/lib/features/rooms/model/audio_device_state.dart new file mode 100644 index 00000000..d6c1a766 --- /dev/null +++ b/lib/features/rooms/model/audio_device_state.dart @@ -0,0 +1,12 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/models/audio_device.dart'; + +part 'generated/audio_device_state.freezed.dart'; + +@freezed +abstract class AudioDeviceState with _$AudioDeviceState { + const factory AudioDeviceState({ + @Default([]) List devices, + AudioDevice? selected, + }) = _AudioDeviceState; +} diff --git a/lib/features/rooms/model/generated/appwrite_room.freezed.dart b/lib/features/rooms/model/generated/appwrite_room.freezed.dart new file mode 100644 index 00000000..7730b8d3 --- /dev/null +++ b/lib/features/rooms/model/generated/appwrite_room.freezed.dart @@ -0,0 +1,316 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../appwrite_room.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AppwriteRoom { + + String get id; String get name; String get description; int get totalParticipants; List get tags; List get memberAvatarUrls; RoomState get state; bool get isUserAdmin; List get reportedUsers; String? get myDocId; +/// Create a copy of AppwriteRoom +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AppwriteRoomCopyWith get copyWith => _$AppwriteRoomCopyWithImpl(this as AppwriteRoom, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppwriteRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.totalParticipants, totalParticipants) || other.totalParticipants == totalParticipants)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.memberAvatarUrls, memberAvatarUrls)&&(identical(other.state, state) || other.state == state)&&(identical(other.isUserAdmin, isUserAdmin) || other.isUserAdmin == isUserAdmin)&&const DeepCollectionEquality().equals(other.reportedUsers, reportedUsers)&&(identical(other.myDocId, myDocId) || other.myDocId == myDocId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,description,totalParticipants,const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(memberAvatarUrls),state,isUserAdmin,const DeepCollectionEquality().hash(reportedUsers),myDocId); + +@override +String toString() { + return 'AppwriteRoom(id: $id, name: $name, description: $description, totalParticipants: $totalParticipants, tags: $tags, memberAvatarUrls: $memberAvatarUrls, state: $state, isUserAdmin: $isUserAdmin, reportedUsers: $reportedUsers, myDocId: $myDocId)'; +} + + +} + +/// @nodoc +abstract mixin class $AppwriteRoomCopyWith<$Res> { + factory $AppwriteRoomCopyWith(AppwriteRoom value, $Res Function(AppwriteRoom) _then) = _$AppwriteRoomCopyWithImpl; +@useResult +$Res call({ + String id, String name, String description, int totalParticipants, List tags, List memberAvatarUrls, RoomState state, bool isUserAdmin, List reportedUsers, String? myDocId +}); + + + + +} +/// @nodoc +class _$AppwriteRoomCopyWithImpl<$Res> + implements $AppwriteRoomCopyWith<$Res> { + _$AppwriteRoomCopyWithImpl(this._self, this._then); + + final AppwriteRoom _self; + final $Res Function(AppwriteRoom) _then; + +/// Create a copy of AppwriteRoom +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = null,Object? totalParticipants = null,Object? tags = null,Object? memberAvatarUrls = null,Object? state = null,Object? isUserAdmin = null,Object? reportedUsers = null,Object? myDocId = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,totalParticipants: null == totalParticipants ? _self.totalParticipants : totalParticipants // ignore: cast_nullable_to_non_nullable +as int,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,memberAvatarUrls: null == memberAvatarUrls ? _self.memberAvatarUrls : memberAvatarUrls // ignore: cast_nullable_to_non_nullable +as List,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable +as RoomState,isUserAdmin: null == isUserAdmin ? _self.isUserAdmin : isUserAdmin // ignore: cast_nullable_to_non_nullable +as bool,reportedUsers: null == reportedUsers ? _self.reportedUsers : reportedUsers // ignore: cast_nullable_to_non_nullable +as List,myDocId: freezed == myDocId ? _self.myDocId : myDocId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AppwriteRoom]. +extension AppwriteRoomPatterns on AppwriteRoom { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AppwriteRoom value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AppwriteRoom() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AppwriteRoom value) $default,){ +final _that = this; +switch (_that) { +case _AppwriteRoom(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AppwriteRoom value)? $default,){ +final _that = this; +switch (_that) { +case _AppwriteRoom() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String description, int totalParticipants, List tags, List memberAvatarUrls, RoomState state, bool isUserAdmin, List reportedUsers, String? myDocId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AppwriteRoom() when $default != null: +return $default(_that.id,_that.name,_that.description,_that.totalParticipants,_that.tags,_that.memberAvatarUrls,_that.state,_that.isUserAdmin,_that.reportedUsers,_that.myDocId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String description, int totalParticipants, List tags, List memberAvatarUrls, RoomState state, bool isUserAdmin, List reportedUsers, String? myDocId) $default,) {final _that = this; +switch (_that) { +case _AppwriteRoom(): +return $default(_that.id,_that.name,_that.description,_that.totalParticipants,_that.tags,_that.memberAvatarUrls,_that.state,_that.isUserAdmin,_that.reportedUsers,_that.myDocId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String description, int totalParticipants, List tags, List memberAvatarUrls, RoomState state, bool isUserAdmin, List reportedUsers, String? myDocId)? $default,) {final _that = this; +switch (_that) { +case _AppwriteRoom() when $default != null: +return $default(_that.id,_that.name,_that.description,_that.totalParticipants,_that.tags,_that.memberAvatarUrls,_that.state,_that.isUserAdmin,_that.reportedUsers,_that.myDocId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AppwriteRoom implements AppwriteRoom { + const _AppwriteRoom({required this.id, required this.name, required this.description, required this.totalParticipants, required final List tags, required final List memberAvatarUrls, required this.state, required this.isUserAdmin, final List reportedUsers = const [], this.myDocId}): _tags = tags,_memberAvatarUrls = memberAvatarUrls,_reportedUsers = reportedUsers; + + +@override final String id; +@override final String name; +@override final String description; +@override final int totalParticipants; + final List _tags; +@override List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + + final List _memberAvatarUrls; +@override List get memberAvatarUrls { + if (_memberAvatarUrls is EqualUnmodifiableListView) return _memberAvatarUrls; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_memberAvatarUrls); +} + +@override final RoomState state; +@override final bool isUserAdmin; + final List _reportedUsers; +@override@JsonKey() List get reportedUsers { + if (_reportedUsers is EqualUnmodifiableListView) return _reportedUsers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_reportedUsers); +} + +@override final String? myDocId; + +/// Create a copy of AppwriteRoom +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AppwriteRoomCopyWith<_AppwriteRoom> get copyWith => __$AppwriteRoomCopyWithImpl<_AppwriteRoom>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppwriteRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.totalParticipants, totalParticipants) || other.totalParticipants == totalParticipants)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._memberAvatarUrls, _memberAvatarUrls)&&(identical(other.state, state) || other.state == state)&&(identical(other.isUserAdmin, isUserAdmin) || other.isUserAdmin == isUserAdmin)&&const DeepCollectionEquality().equals(other._reportedUsers, _reportedUsers)&&(identical(other.myDocId, myDocId) || other.myDocId == myDocId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,description,totalParticipants,const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_memberAvatarUrls),state,isUserAdmin,const DeepCollectionEquality().hash(_reportedUsers),myDocId); + +@override +String toString() { + return 'AppwriteRoom(id: $id, name: $name, description: $description, totalParticipants: $totalParticipants, tags: $tags, memberAvatarUrls: $memberAvatarUrls, state: $state, isUserAdmin: $isUserAdmin, reportedUsers: $reportedUsers, myDocId: $myDocId)'; +} + + +} + +/// @nodoc +abstract mixin class _$AppwriteRoomCopyWith<$Res> implements $AppwriteRoomCopyWith<$Res> { + factory _$AppwriteRoomCopyWith(_AppwriteRoom value, $Res Function(_AppwriteRoom) _then) = __$AppwriteRoomCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String description, int totalParticipants, List tags, List memberAvatarUrls, RoomState state, bool isUserAdmin, List reportedUsers, String? myDocId +}); + + + + +} +/// @nodoc +class __$AppwriteRoomCopyWithImpl<$Res> + implements _$AppwriteRoomCopyWith<$Res> { + __$AppwriteRoomCopyWithImpl(this._self, this._then); + + final _AppwriteRoom _self; + final $Res Function(_AppwriteRoom) _then; + +/// Create a copy of AppwriteRoom +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = null,Object? totalParticipants = null,Object? tags = null,Object? memberAvatarUrls = null,Object? state = null,Object? isUserAdmin = null,Object? reportedUsers = null,Object? myDocId = freezed,}) { + return _then(_AppwriteRoom( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,totalParticipants: null == totalParticipants ? _self.totalParticipants : totalParticipants // ignore: cast_nullable_to_non_nullable +as int,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,memberAvatarUrls: null == memberAvatarUrls ? _self._memberAvatarUrls : memberAvatarUrls // ignore: cast_nullable_to_non_nullable +as List,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable +as RoomState,isUserAdmin: null == isUserAdmin ? _self.isUserAdmin : isUserAdmin // ignore: cast_nullable_to_non_nullable +as bool,reportedUsers: null == reportedUsers ? _self._reportedUsers : reportedUsers // ignore: cast_nullable_to_non_nullable +as List,myDocId: freezed == myDocId ? _self.myDocId : myDocId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/appwrite_upcoming_room.freezed.dart b/lib/features/rooms/model/generated/appwrite_upcoming_room.freezed.dart new file mode 100644 index 00000000..85f3eb24 --- /dev/null +++ b/lib/features/rooms/model/generated/appwrite_upcoming_room.freezed.dart @@ -0,0 +1,310 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../appwrite_upcoming_room.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AppwriteUpcomingRoom { + + String get id; String get name; bool get isTime; DateTime get scheduledDateTime; String get description; int get totalSubscriberCount; List get tags; List get subscribersAvatarUrls; bool get userIsCreator; bool get hasUserSubscribed; +/// Create a copy of AppwriteUpcomingRoom +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AppwriteUpcomingRoomCopyWith get copyWith => _$AppwriteUpcomingRoomCopyWithImpl(this as AppwriteUpcomingRoom, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppwriteUpcomingRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.isTime, isTime) || other.isTime == isTime)&&(identical(other.scheduledDateTime, scheduledDateTime) || other.scheduledDateTime == scheduledDateTime)&&(identical(other.description, description) || other.description == description)&&(identical(other.totalSubscriberCount, totalSubscriberCount) || other.totalSubscriberCount == totalSubscriberCount)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.subscribersAvatarUrls, subscribersAvatarUrls)&&(identical(other.userIsCreator, userIsCreator) || other.userIsCreator == userIsCreator)&&(identical(other.hasUserSubscribed, hasUserSubscribed) || other.hasUserSubscribed == hasUserSubscribed)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,isTime,scheduledDateTime,description,totalSubscriberCount,const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(subscribersAvatarUrls),userIsCreator,hasUserSubscribed); + +@override +String toString() { + return 'AppwriteUpcomingRoom(id: $id, name: $name, isTime: $isTime, scheduledDateTime: $scheduledDateTime, description: $description, totalSubscriberCount: $totalSubscriberCount, tags: $tags, subscribersAvatarUrls: $subscribersAvatarUrls, userIsCreator: $userIsCreator, hasUserSubscribed: $hasUserSubscribed)'; +} + + +} + +/// @nodoc +abstract mixin class $AppwriteUpcomingRoomCopyWith<$Res> { + factory $AppwriteUpcomingRoomCopyWith(AppwriteUpcomingRoom value, $Res Function(AppwriteUpcomingRoom) _then) = _$AppwriteUpcomingRoomCopyWithImpl; +@useResult +$Res call({ + String id, String name, bool isTime, DateTime scheduledDateTime, String description, int totalSubscriberCount, List tags, List subscribersAvatarUrls, bool userIsCreator, bool hasUserSubscribed +}); + + + + +} +/// @nodoc +class _$AppwriteUpcomingRoomCopyWithImpl<$Res> + implements $AppwriteUpcomingRoomCopyWith<$Res> { + _$AppwriteUpcomingRoomCopyWithImpl(this._self, this._then); + + final AppwriteUpcomingRoom _self; + final $Res Function(AppwriteUpcomingRoom) _then; + +/// Create a copy of AppwriteUpcomingRoom +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? isTime = null,Object? scheduledDateTime = null,Object? description = null,Object? totalSubscriberCount = null,Object? tags = null,Object? subscribersAvatarUrls = null,Object? userIsCreator = null,Object? hasUserSubscribed = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isTime: null == isTime ? _self.isTime : isTime // ignore: cast_nullable_to_non_nullable +as bool,scheduledDateTime: null == scheduledDateTime ? _self.scheduledDateTime : scheduledDateTime // ignore: cast_nullable_to_non_nullable +as DateTime,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,totalSubscriberCount: null == totalSubscriberCount ? _self.totalSubscriberCount : totalSubscriberCount // ignore: cast_nullable_to_non_nullable +as int,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,subscribersAvatarUrls: null == subscribersAvatarUrls ? _self.subscribersAvatarUrls : subscribersAvatarUrls // ignore: cast_nullable_to_non_nullable +as List,userIsCreator: null == userIsCreator ? _self.userIsCreator : userIsCreator // ignore: cast_nullable_to_non_nullable +as bool,hasUserSubscribed: null == hasUserSubscribed ? _self.hasUserSubscribed : hasUserSubscribed // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AppwriteUpcomingRoom]. +extension AppwriteUpcomingRoomPatterns on AppwriteUpcomingRoom { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AppwriteUpcomingRoom value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AppwriteUpcomingRoom value) $default,){ +final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AppwriteUpcomingRoom value)? $default,){ +final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, bool isTime, DateTime scheduledDateTime, String description, int totalSubscriberCount, List tags, List subscribersAvatarUrls, bool userIsCreator, bool hasUserSubscribed)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom() when $default != null: +return $default(_that.id,_that.name,_that.isTime,_that.scheduledDateTime,_that.description,_that.totalSubscriberCount,_that.tags,_that.subscribersAvatarUrls,_that.userIsCreator,_that.hasUserSubscribed);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, bool isTime, DateTime scheduledDateTime, String description, int totalSubscriberCount, List tags, List subscribersAvatarUrls, bool userIsCreator, bool hasUserSubscribed) $default,) {final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom(): +return $default(_that.id,_that.name,_that.isTime,_that.scheduledDateTime,_that.description,_that.totalSubscriberCount,_that.tags,_that.subscribersAvatarUrls,_that.userIsCreator,_that.hasUserSubscribed);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, bool isTime, DateTime scheduledDateTime, String description, int totalSubscriberCount, List tags, List subscribersAvatarUrls, bool userIsCreator, bool hasUserSubscribed)? $default,) {final _that = this; +switch (_that) { +case _AppwriteUpcomingRoom() when $default != null: +return $default(_that.id,_that.name,_that.isTime,_that.scheduledDateTime,_that.description,_that.totalSubscriberCount,_that.tags,_that.subscribersAvatarUrls,_that.userIsCreator,_that.hasUserSubscribed);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AppwriteUpcomingRoom implements AppwriteUpcomingRoom { + const _AppwriteUpcomingRoom({required this.id, required this.name, required this.isTime, required this.scheduledDateTime, required this.description, required this.totalSubscriberCount, required final List tags, required final List subscribersAvatarUrls, required this.userIsCreator, required this.hasUserSubscribed}): _tags = tags,_subscribersAvatarUrls = subscribersAvatarUrls; + + +@override final String id; +@override final String name; +@override final bool isTime; +@override final DateTime scheduledDateTime; +@override final String description; +@override final int totalSubscriberCount; + final List _tags; +@override List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + + final List _subscribersAvatarUrls; +@override List get subscribersAvatarUrls { + if (_subscribersAvatarUrls is EqualUnmodifiableListView) return _subscribersAvatarUrls; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_subscribersAvatarUrls); +} + +@override final bool userIsCreator; +@override final bool hasUserSubscribed; + +/// Create a copy of AppwriteUpcomingRoom +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AppwriteUpcomingRoomCopyWith<_AppwriteUpcomingRoom> get copyWith => __$AppwriteUpcomingRoomCopyWithImpl<_AppwriteUpcomingRoom>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppwriteUpcomingRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.isTime, isTime) || other.isTime == isTime)&&(identical(other.scheduledDateTime, scheduledDateTime) || other.scheduledDateTime == scheduledDateTime)&&(identical(other.description, description) || other.description == description)&&(identical(other.totalSubscriberCount, totalSubscriberCount) || other.totalSubscriberCount == totalSubscriberCount)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._subscribersAvatarUrls, _subscribersAvatarUrls)&&(identical(other.userIsCreator, userIsCreator) || other.userIsCreator == userIsCreator)&&(identical(other.hasUserSubscribed, hasUserSubscribed) || other.hasUserSubscribed == hasUserSubscribed)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,name,isTime,scheduledDateTime,description,totalSubscriberCount,const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_subscribersAvatarUrls),userIsCreator,hasUserSubscribed); + +@override +String toString() { + return 'AppwriteUpcomingRoom(id: $id, name: $name, isTime: $isTime, scheduledDateTime: $scheduledDateTime, description: $description, totalSubscriberCount: $totalSubscriberCount, tags: $tags, subscribersAvatarUrls: $subscribersAvatarUrls, userIsCreator: $userIsCreator, hasUserSubscribed: $hasUserSubscribed)'; +} + + +} + +/// @nodoc +abstract mixin class _$AppwriteUpcomingRoomCopyWith<$Res> implements $AppwriteUpcomingRoomCopyWith<$Res> { + factory _$AppwriteUpcomingRoomCopyWith(_AppwriteUpcomingRoom value, $Res Function(_AppwriteUpcomingRoom) _then) = __$AppwriteUpcomingRoomCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, bool isTime, DateTime scheduledDateTime, String description, int totalSubscriberCount, List tags, List subscribersAvatarUrls, bool userIsCreator, bool hasUserSubscribed +}); + + + + +} +/// @nodoc +class __$AppwriteUpcomingRoomCopyWithImpl<$Res> + implements _$AppwriteUpcomingRoomCopyWith<$Res> { + __$AppwriteUpcomingRoomCopyWithImpl(this._self, this._then); + + final _AppwriteUpcomingRoom _self; + final $Res Function(_AppwriteUpcomingRoom) _then; + +/// Create a copy of AppwriteUpcomingRoom +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? isTime = null,Object? scheduledDateTime = null,Object? description = null,Object? totalSubscriberCount = null,Object? tags = null,Object? subscribersAvatarUrls = null,Object? userIsCreator = null,Object? hasUserSubscribed = null,}) { + return _then(_AppwriteUpcomingRoom( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isTime: null == isTime ? _self.isTime : isTime // ignore: cast_nullable_to_non_nullable +as bool,scheduledDateTime: null == scheduledDateTime ? _self.scheduledDateTime : scheduledDateTime // ignore: cast_nullable_to_non_nullable +as DateTime,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,totalSubscriberCount: null == totalSubscriberCount ? _self.totalSubscriberCount : totalSubscriberCount // ignore: cast_nullable_to_non_nullable +as int,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,subscribersAvatarUrls: null == subscribersAvatarUrls ? _self._subscribersAvatarUrls : subscribersAvatarUrls // ignore: cast_nullable_to_non_nullable +as List,userIsCreator: null == userIsCreator ? _self.userIsCreator : userIsCreator // ignore: cast_nullable_to_non_nullable +as bool,hasUserSubscribed: null == hasUserSubscribed ? _self.hasUserSubscribed : hasUserSubscribed // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/audio_device_state.freezed.dart b/lib/features/rooms/model/generated/audio_device_state.freezed.dart new file mode 100644 index 00000000..2965a096 --- /dev/null +++ b/lib/features/rooms/model/generated/audio_device_state.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../audio_device_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AudioDeviceState { + + List get devices; AudioDevice? get selected; +/// Create a copy of AudioDeviceState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AudioDeviceStateCopyWith get copyWith => _$AudioDeviceStateCopyWithImpl(this as AudioDeviceState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AudioDeviceState&&const DeepCollectionEquality().equals(other.devices, devices)&&(identical(other.selected, selected) || other.selected == selected)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(devices),selected); + +@override +String toString() { + return 'AudioDeviceState(devices: $devices, selected: $selected)'; +} + + +} + +/// @nodoc +abstract mixin class $AudioDeviceStateCopyWith<$Res> { + factory $AudioDeviceStateCopyWith(AudioDeviceState value, $Res Function(AudioDeviceState) _then) = _$AudioDeviceStateCopyWithImpl; +@useResult +$Res call({ + List devices, AudioDevice? selected +}); + + + + +} +/// @nodoc +class _$AudioDeviceStateCopyWithImpl<$Res> + implements $AudioDeviceStateCopyWith<$Res> { + _$AudioDeviceStateCopyWithImpl(this._self, this._then); + + final AudioDeviceState _self; + final $Res Function(AudioDeviceState) _then; + +/// Create a copy of AudioDeviceState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? devices = null,Object? selected = freezed,}) { + return _then(_self.copyWith( +devices: null == devices ? _self.devices : devices // ignore: cast_nullable_to_non_nullable +as List,selected: freezed == selected ? _self.selected : selected // ignore: cast_nullable_to_non_nullable +as AudioDevice?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AudioDeviceState]. +extension AudioDeviceStatePatterns on AudioDeviceState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AudioDeviceState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AudioDeviceState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AudioDeviceState value) $default,){ +final _that = this; +switch (_that) { +case _AudioDeviceState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AudioDeviceState value)? $default,){ +final _that = this; +switch (_that) { +case _AudioDeviceState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List devices, AudioDevice? selected)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AudioDeviceState() when $default != null: +return $default(_that.devices,_that.selected);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List devices, AudioDevice? selected) $default,) {final _that = this; +switch (_that) { +case _AudioDeviceState(): +return $default(_that.devices,_that.selected);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List devices, AudioDevice? selected)? $default,) {final _that = this; +switch (_that) { +case _AudioDeviceState() when $default != null: +return $default(_that.devices,_that.selected);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AudioDeviceState implements AudioDeviceState { + const _AudioDeviceState({final List devices = const [], this.selected}): _devices = devices; + + + final List _devices; +@override@JsonKey() List get devices { + if (_devices is EqualUnmodifiableListView) return _devices; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_devices); +} + +@override final AudioDevice? selected; + +/// Create a copy of AudioDeviceState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AudioDeviceStateCopyWith<_AudioDeviceState> get copyWith => __$AudioDeviceStateCopyWithImpl<_AudioDeviceState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AudioDeviceState&&const DeepCollectionEquality().equals(other._devices, _devices)&&(identical(other.selected, selected) || other.selected == selected)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_devices),selected); + +@override +String toString() { + return 'AudioDeviceState(devices: $devices, selected: $selected)'; +} + + +} + +/// @nodoc +abstract mixin class _$AudioDeviceStateCopyWith<$Res> implements $AudioDeviceStateCopyWith<$Res> { + factory _$AudioDeviceStateCopyWith(_AudioDeviceState value, $Res Function(_AudioDeviceState) _then) = __$AudioDeviceStateCopyWithImpl; +@override @useResult +$Res call({ + List devices, AudioDevice? selected +}); + + + + +} +/// @nodoc +class __$AudioDeviceStateCopyWithImpl<$Res> + implements _$AudioDeviceStateCopyWith<$Res> { + __$AudioDeviceStateCopyWithImpl(this._self, this._then); + + final _AudioDeviceState _self; + final $Res Function(_AudioDeviceState) _then; + +/// Create a copy of AudioDeviceState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? devices = null,Object? selected = freezed,}) { + return _then(_AudioDeviceState( +devices: null == devices ? _self._devices : devices // ignore: cast_nullable_to_non_nullable +as List,selected: freezed == selected ? _self.selected : selected // ignore: cast_nullable_to_non_nullable +as AudioDevice?, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/livekit_state.freezed.dart b/lib/features/rooms/model/generated/livekit_state.freezed.dart new file mode 100644 index 00000000..7917c74a --- /dev/null +++ b/lib/features/rooms/model/generated/livekit_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../livekit_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveKitState { + + bool get isConnected; bool get isRecording; bool get hasSession; +/// Create a copy of LiveKitState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveKitStateCopyWith get copyWith => _$LiveKitStateCopyWithImpl(this as LiveKitState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveKitState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.hasSession, hasSession) || other.hasSession == hasSession)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isConnected,isRecording,hasSession); + +@override +String toString() { + return 'LiveKitState(isConnected: $isConnected, isRecording: $isRecording, hasSession: $hasSession)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveKitStateCopyWith<$Res> { + factory $LiveKitStateCopyWith(LiveKitState value, $Res Function(LiveKitState) _then) = _$LiveKitStateCopyWithImpl; +@useResult +$Res call({ + bool isConnected, bool isRecording, bool hasSession +}); + + + + +} +/// @nodoc +class _$LiveKitStateCopyWithImpl<$Res> + implements $LiveKitStateCopyWith<$Res> { + _$LiveKitStateCopyWithImpl(this._self, this._then); + + final LiveKitState _self; + final $Res Function(LiveKitState) _then; + +/// Create a copy of LiveKitState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isRecording = null,Object? hasSession = null,}) { + return _then(_self.copyWith( +isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable +as bool,isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable +as bool,hasSession: null == hasSession ? _self.hasSession : hasSession // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LiveKitState]. +extension LiveKitStatePatterns on LiveKitState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveKitState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveKitState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveKitState value) $default,){ +final _that = this; +switch (_that) { +case _LiveKitState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveKitState value)? $default,){ +final _that = this; +switch (_that) { +case _LiveKitState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isConnected, bool isRecording, bool hasSession)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveKitState() when $default != null: +return $default(_that.isConnected,_that.isRecording,_that.hasSession);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isConnected, bool isRecording, bool hasSession) $default,) {final _that = this; +switch (_that) { +case _LiveKitState(): +return $default(_that.isConnected,_that.isRecording,_that.hasSession);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isConnected, bool isRecording, bool hasSession)? $default,) {final _that = this; +switch (_that) { +case _LiveKitState() when $default != null: +return $default(_that.isConnected,_that.isRecording,_that.hasSession);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveKitState implements LiveKitState { + const _LiveKitState({this.isConnected = false, this.isRecording = false, this.hasSession = false}); + + +@override@JsonKey() final bool isConnected; +@override@JsonKey() final bool isRecording; +@override@JsonKey() final bool hasSession; + +/// Create a copy of LiveKitState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveKitStateCopyWith<_LiveKitState> get copyWith => __$LiveKitStateCopyWithImpl<_LiveKitState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveKitState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.hasSession, hasSession) || other.hasSession == hasSession)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isConnected,isRecording,hasSession); + +@override +String toString() { + return 'LiveKitState(isConnected: $isConnected, isRecording: $isRecording, hasSession: $hasSession)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveKitStateCopyWith<$Res> implements $LiveKitStateCopyWith<$Res> { + factory _$LiveKitStateCopyWith(_LiveKitState value, $Res Function(_LiveKitState) _then) = __$LiveKitStateCopyWithImpl; +@override @useResult +$Res call({ + bool isConnected, bool isRecording, bool hasSession +}); + + + + +} +/// @nodoc +class __$LiveKitStateCopyWithImpl<$Res> + implements _$LiveKitStateCopyWith<$Res> { + __$LiveKitStateCopyWithImpl(this._self, this._then); + + final _LiveKitState _self; + final $Res Function(_LiveKitState) _then; + +/// Create a copy of LiveKitState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isRecording = null,Object? hasSession = null,}) { + return _then(_LiveKitState( +isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable +as bool,isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable +as bool,hasSession: null == hasSession ? _self.hasSession : hasSession // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/participant.freezed.dart b/lib/features/rooms/model/generated/participant.freezed.dart new file mode 100644 index 00000000..92601e9d --- /dev/null +++ b/lib/features/rooms/model/generated/participant.freezed.dart @@ -0,0 +1,301 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../participant.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Participant { + + String get uid; String get email; String get name; String get dpUrl; bool get isAdmin; bool get isMicOn; bool get isModerator; bool get isSpeaker; bool get hasRequestedToBeSpeaker; +/// Create a copy of Participant +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ParticipantCopyWith get copyWith => _$ParticipantCopyWithImpl(this as Participant, _$identity); + + /// Serializes this Participant to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Participant&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.email, email) || other.email == email)&&(identical(other.name, name) || other.name == name)&&(identical(other.dpUrl, dpUrl) || other.dpUrl == dpUrl)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isModerator, isModerator) || other.isModerator == isModerator)&&(identical(other.isSpeaker, isSpeaker) || other.isSpeaker == isSpeaker)&&(identical(other.hasRequestedToBeSpeaker, hasRequestedToBeSpeaker) || other.hasRequestedToBeSpeaker == hasRequestedToBeSpeaker)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,email,name,dpUrl,isAdmin,isMicOn,isModerator,isSpeaker,hasRequestedToBeSpeaker); + +@override +String toString() { + return 'Participant(uid: $uid, email: $email, name: $name, dpUrl: $dpUrl, isAdmin: $isAdmin, isMicOn: $isMicOn, isModerator: $isModerator, isSpeaker: $isSpeaker, hasRequestedToBeSpeaker: $hasRequestedToBeSpeaker)'; +} + + +} + +/// @nodoc +abstract mixin class $ParticipantCopyWith<$Res> { + factory $ParticipantCopyWith(Participant value, $Res Function(Participant) _then) = _$ParticipantCopyWithImpl; +@useResult +$Res call({ + String uid, String email, String name, String dpUrl, bool isAdmin, bool isMicOn, bool isModerator, bool isSpeaker, bool hasRequestedToBeSpeaker +}); + + + + +} +/// @nodoc +class _$ParticipantCopyWithImpl<$Res> + implements $ParticipantCopyWith<$Res> { + _$ParticipantCopyWithImpl(this._self, this._then); + + final Participant _self; + final $Res Function(Participant) _then; + +/// Create a copy of Participant +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? uid = null,Object? email = null,Object? name = null,Object? dpUrl = null,Object? isAdmin = null,Object? isMicOn = null,Object? isModerator = null,Object? isSpeaker = null,Object? hasRequestedToBeSpeaker = null,}) { + return _then(_self.copyWith( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,dpUrl: null == dpUrl ? _self.dpUrl : dpUrl // ignore: cast_nullable_to_non_nullable +as String,isAdmin: null == isAdmin ? _self.isAdmin : isAdmin // ignore: cast_nullable_to_non_nullable +as bool,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isModerator: null == isModerator ? _self.isModerator : isModerator // ignore: cast_nullable_to_non_nullable +as bool,isSpeaker: null == isSpeaker ? _self.isSpeaker : isSpeaker // ignore: cast_nullable_to_non_nullable +as bool,hasRequestedToBeSpeaker: null == hasRequestedToBeSpeaker ? _self.hasRequestedToBeSpeaker : hasRequestedToBeSpeaker // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Participant]. +extension ParticipantPatterns on Participant { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Participant value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Participant() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Participant value) $default,){ +final _that = this; +switch (_that) { +case _Participant(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Participant value)? $default,){ +final _that = this; +switch (_that) { +case _Participant() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String uid, String email, String name, String dpUrl, bool isAdmin, bool isMicOn, bool isModerator, bool isSpeaker, bool hasRequestedToBeSpeaker)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Participant() when $default != null: +return $default(_that.uid,_that.email,_that.name,_that.dpUrl,_that.isAdmin,_that.isMicOn,_that.isModerator,_that.isSpeaker,_that.hasRequestedToBeSpeaker);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String uid, String email, String name, String dpUrl, bool isAdmin, bool isMicOn, bool isModerator, bool isSpeaker, bool hasRequestedToBeSpeaker) $default,) {final _that = this; +switch (_that) { +case _Participant(): +return $default(_that.uid,_that.email,_that.name,_that.dpUrl,_that.isAdmin,_that.isMicOn,_that.isModerator,_that.isSpeaker,_that.hasRequestedToBeSpeaker);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uid, String email, String name, String dpUrl, bool isAdmin, bool isMicOn, bool isModerator, bool isSpeaker, bool hasRequestedToBeSpeaker)? $default,) {final _that = this; +switch (_that) { +case _Participant() when $default != null: +return $default(_that.uid,_that.email,_that.name,_that.dpUrl,_that.isAdmin,_that.isMicOn,_that.isModerator,_that.isSpeaker,_that.hasRequestedToBeSpeaker);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Participant implements Participant { + const _Participant({required this.uid, required this.email, required this.name, required this.dpUrl, required this.isAdmin, required this.isMicOn, required this.isModerator, required this.isSpeaker, required this.hasRequestedToBeSpeaker}); + factory _Participant.fromJson(Map json) => _$ParticipantFromJson(json); + +@override final String uid; +@override final String email; +@override final String name; +@override final String dpUrl; +@override final bool isAdmin; +@override final bool isMicOn; +@override final bool isModerator; +@override final bool isSpeaker; +@override final bool hasRequestedToBeSpeaker; + +/// Create a copy of Participant +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ParticipantCopyWith<_Participant> get copyWith => __$ParticipantCopyWithImpl<_Participant>(this, _$identity); + +@override +Map toJson() { + return _$ParticipantToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Participant&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.email, email) || other.email == email)&&(identical(other.name, name) || other.name == name)&&(identical(other.dpUrl, dpUrl) || other.dpUrl == dpUrl)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isModerator, isModerator) || other.isModerator == isModerator)&&(identical(other.isSpeaker, isSpeaker) || other.isSpeaker == isSpeaker)&&(identical(other.hasRequestedToBeSpeaker, hasRequestedToBeSpeaker) || other.hasRequestedToBeSpeaker == hasRequestedToBeSpeaker)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,email,name,dpUrl,isAdmin,isMicOn,isModerator,isSpeaker,hasRequestedToBeSpeaker); + +@override +String toString() { + return 'Participant(uid: $uid, email: $email, name: $name, dpUrl: $dpUrl, isAdmin: $isAdmin, isMicOn: $isMicOn, isModerator: $isModerator, isSpeaker: $isSpeaker, hasRequestedToBeSpeaker: $hasRequestedToBeSpeaker)'; +} + + +} + +/// @nodoc +abstract mixin class _$ParticipantCopyWith<$Res> implements $ParticipantCopyWith<$Res> { + factory _$ParticipantCopyWith(_Participant value, $Res Function(_Participant) _then) = __$ParticipantCopyWithImpl; +@override @useResult +$Res call({ + String uid, String email, String name, String dpUrl, bool isAdmin, bool isMicOn, bool isModerator, bool isSpeaker, bool hasRequestedToBeSpeaker +}); + + + + +} +/// @nodoc +class __$ParticipantCopyWithImpl<$Res> + implements _$ParticipantCopyWith<$Res> { + __$ParticipantCopyWithImpl(this._self, this._then); + + final _Participant _self; + final $Res Function(_Participant) _then; + +/// Create a copy of Participant +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? uid = null,Object? email = null,Object? name = null,Object? dpUrl = null,Object? isAdmin = null,Object? isMicOn = null,Object? isModerator = null,Object? isSpeaker = null,Object? hasRequestedToBeSpeaker = null,}) { + return _then(_Participant( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,dpUrl: null == dpUrl ? _self.dpUrl : dpUrl // ignore: cast_nullable_to_non_nullable +as String,isAdmin: null == isAdmin ? _self.isAdmin : isAdmin // ignore: cast_nullable_to_non_nullable +as bool,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isModerator: null == isModerator ? _self.isModerator : isModerator // ignore: cast_nullable_to_non_nullable +as bool,isSpeaker: null == isSpeaker ? _self.isSpeaker : isSpeaker // ignore: cast_nullable_to_non_nullable +as bool,hasRequestedToBeSpeaker: null == hasRequestedToBeSpeaker ? _self.hasRequestedToBeSpeaker : hasRequestedToBeSpeaker // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/participant.g.dart b/lib/features/rooms/model/generated/participant.g.dart new file mode 100644 index 00000000..00bfdc39 --- /dev/null +++ b/lib/features/rooms/model/generated/participant.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../participant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Participant _$ParticipantFromJson(Map json) => _Participant( + uid: json['uid'] as String, + email: json['email'] as String, + name: json['name'] as String, + dpUrl: json['dpUrl'] as String, + isAdmin: json['isAdmin'] as bool, + isMicOn: json['isMicOn'] as bool, + isModerator: json['isModerator'] as bool, + isSpeaker: json['isSpeaker'] as bool, + hasRequestedToBeSpeaker: json['hasRequestedToBeSpeaker'] as bool, +); + +Map _$ParticipantToJson(_Participant instance) => + { + 'uid': instance.uid, + 'email': instance.email, + 'name': instance.name, + 'dpUrl': instance.dpUrl, + 'isAdmin': instance.isAdmin, + 'isMicOn': instance.isMicOn, + 'isModerator': instance.isModerator, + 'isSpeaker': instance.isSpeaker, + 'hasRequestedToBeSpeaker': instance.hasRequestedToBeSpeaker, + }; diff --git a/lib/features/rooms/model/generated/reply_to.freezed.dart b/lib/features/rooms/model/generated/reply_to.freezed.dart new file mode 100644 index 00000000..a6f7536d --- /dev/null +++ b/lib/features/rooms/model/generated/reply_to.freezed.dart @@ -0,0 +1,289 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../reply_to.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ReplyTo { + + String get messageId; String get creatorUsername; String get creatorImgUrl; int get index; String get content; +/// Create a copy of ReplyTo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ReplyToCopyWith get copyWith => _$ReplyToCopyWithImpl(this as ReplyTo, _$identity); + + /// Serializes this ReplyTo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ReplyTo&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.creatorUsername, creatorUsername) || other.creatorUsername == creatorUsername)&&(identical(other.creatorImgUrl, creatorImgUrl) || other.creatorImgUrl == creatorImgUrl)&&(identical(other.index, index) || other.index == index)&&(identical(other.content, content) || other.content == content)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,messageId,creatorUsername,creatorImgUrl,index,content); + +@override +String toString() { + return 'ReplyTo(messageId: $messageId, creatorUsername: $creatorUsername, creatorImgUrl: $creatorImgUrl, index: $index, content: $content)'; +} + + +} + +/// @nodoc +abstract mixin class $ReplyToCopyWith<$Res> { + factory $ReplyToCopyWith(ReplyTo value, $Res Function(ReplyTo) _then) = _$ReplyToCopyWithImpl; +@useResult +$Res call({ + String messageId, String creatorUsername, String creatorImgUrl, int index, String content +}); + + + + +} +/// @nodoc +class _$ReplyToCopyWithImpl<$Res> + implements $ReplyToCopyWith<$Res> { + _$ReplyToCopyWithImpl(this._self, this._then); + + final ReplyTo _self; + final $Res Function(ReplyTo) _then; + +/// Create a copy of ReplyTo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? messageId = null,Object? creatorUsername = null,Object? creatorImgUrl = null,Object? index = null,Object? content = null,}) { + return _then(_self.copyWith( +messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,creatorUsername: null == creatorUsername ? _self.creatorUsername : creatorUsername // ignore: cast_nullable_to_non_nullable +as String,creatorImgUrl: null == creatorImgUrl ? _self.creatorImgUrl : creatorImgUrl // ignore: cast_nullable_to_non_nullable +as String,index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ReplyTo]. +extension ReplyToPatterns on ReplyTo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ReplyTo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ReplyTo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ReplyTo value) $default,){ +final _that = this; +switch (_that) { +case _ReplyTo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ReplyTo value)? $default,){ +final _that = this; +switch (_that) { +case _ReplyTo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String messageId, String creatorUsername, String creatorImgUrl, int index, String content)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ReplyTo() when $default != null: +return $default(_that.messageId,_that.creatorUsername,_that.creatorImgUrl,_that.index,_that.content);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String messageId, String creatorUsername, String creatorImgUrl, int index, String content) $default,) {final _that = this; +switch (_that) { +case _ReplyTo(): +return $default(_that.messageId,_that.creatorUsername,_that.creatorImgUrl,_that.index,_that.content);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String messageId, String creatorUsername, String creatorImgUrl, int index, String content)? $default,) {final _that = this; +switch (_that) { +case _ReplyTo() when $default != null: +return $default(_that.messageId,_that.creatorUsername,_that.creatorImgUrl,_that.index,_that.content);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ReplyTo implements ReplyTo { + const _ReplyTo({required this.messageId, required this.creatorUsername, required this.creatorImgUrl, required this.index, required this.content}); + factory _ReplyTo.fromJson(Map json) => _$ReplyToFromJson(json); + +@override final String messageId; +@override final String creatorUsername; +@override final String creatorImgUrl; +@override final int index; +@override final String content; + +/// Create a copy of ReplyTo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ReplyToCopyWith<_ReplyTo> get copyWith => __$ReplyToCopyWithImpl<_ReplyTo>(this, _$identity); + +@override +Map toJson() { + return _$ReplyToToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReplyTo&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.creatorUsername, creatorUsername) || other.creatorUsername == creatorUsername)&&(identical(other.creatorImgUrl, creatorImgUrl) || other.creatorImgUrl == creatorImgUrl)&&(identical(other.index, index) || other.index == index)&&(identical(other.content, content) || other.content == content)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,messageId,creatorUsername,creatorImgUrl,index,content); + +@override +String toString() { + return 'ReplyTo(messageId: $messageId, creatorUsername: $creatorUsername, creatorImgUrl: $creatorImgUrl, index: $index, content: $content)'; +} + + +} + +/// @nodoc +abstract mixin class _$ReplyToCopyWith<$Res> implements $ReplyToCopyWith<$Res> { + factory _$ReplyToCopyWith(_ReplyTo value, $Res Function(_ReplyTo) _then) = __$ReplyToCopyWithImpl; +@override @useResult +$Res call({ + String messageId, String creatorUsername, String creatorImgUrl, int index, String content +}); + + + + +} +/// @nodoc +class __$ReplyToCopyWithImpl<$Res> + implements _$ReplyToCopyWith<$Res> { + __$ReplyToCopyWithImpl(this._self, this._then); + + final _ReplyTo _self; + final $Res Function(_ReplyTo) _then; + +/// Create a copy of ReplyTo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? messageId = null,Object? creatorUsername = null,Object? creatorImgUrl = null,Object? index = null,Object? content = null,}) { + return _then(_ReplyTo( +messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,creatorUsername: null == creatorUsername ? _self.creatorUsername : creatorUsername // ignore: cast_nullable_to_non_nullable +as String,creatorImgUrl: null == creatorImgUrl ? _self.creatorImgUrl : creatorImgUrl // ignore: cast_nullable_to_non_nullable +as String,index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/reply_to.g.dart b/lib/features/rooms/model/generated/reply_to.g.dart new file mode 100644 index 00000000..fe75b1f4 --- /dev/null +++ b/lib/features/rooms/model/generated/reply_to.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../reply_to.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ReplyTo _$ReplyToFromJson(Map json) => _ReplyTo( + messageId: json['messageId'] as String, + creatorUsername: json['creatorUsername'] as String, + creatorImgUrl: json['creatorImgUrl'] as String, + index: (json['index'] as num).toInt(), + content: json['content'] as String, +); + +Map _$ReplyToToJson(_ReplyTo instance) => { + 'messageId': instance.messageId, + 'creatorUsername': instance.creatorUsername, + 'creatorImgUrl': instance.creatorImgUrl, + 'index': instance.index, + 'content': instance.content, +}; diff --git a/lib/features/rooms/model/generated/room_chat_state.freezed.dart b/lib/features/rooms/model/generated/room_chat_state.freezed.dart new file mode 100644 index 00000000..44cd8444 --- /dev/null +++ b/lib/features/rooms/model/generated/room_chat_state.freezed.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../room_chat_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RoomChatState { + + List get messages; ReplyTo? get replyingTo; +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomChatStateCopyWith get copyWith => _$RoomChatStateCopyWithImpl(this as RoomChatState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomChatState&&const DeepCollectionEquality().equals(other.messages, messages)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(messages),replyingTo); + +@override +String toString() { + return 'RoomChatState(messages: $messages, replyingTo: $replyingTo)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomChatStateCopyWith<$Res> { + factory $RoomChatStateCopyWith(RoomChatState value, $Res Function(RoomChatState) _then) = _$RoomChatStateCopyWithImpl; +@useResult +$Res call({ + List messages, ReplyTo? replyingTo +}); + + +$ReplyToCopyWith<$Res>? get replyingTo; + +} +/// @nodoc +class _$RoomChatStateCopyWithImpl<$Res> + implements $RoomChatStateCopyWith<$Res> { + _$RoomChatStateCopyWithImpl(this._self, this._then); + + final RoomChatState _self; + final $Res Function(RoomChatState) _then; + +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? messages = null,Object? replyingTo = freezed,}) { + return _then(_self.copyWith( +messages: null == messages ? _self.messages : messages // ignore: cast_nullable_to_non_nullable +as List,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable +as ReplyTo?, + )); +} +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ReplyToCopyWith<$Res>? get replyingTo { + if (_self.replyingTo == null) { + return null; + } + + return $ReplyToCopyWith<$Res>(_self.replyingTo!, (value) { + return _then(_self.copyWith(replyingTo: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RoomChatState]. +extension RoomChatStatePatterns on RoomChatState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RoomChatState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RoomChatState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RoomChatState value) $default,){ +final _that = this; +switch (_that) { +case _RoomChatState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RoomChatState value)? $default,){ +final _that = this; +switch (_that) { +case _RoomChatState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List messages, ReplyTo? replyingTo)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RoomChatState() when $default != null: +return $default(_that.messages,_that.replyingTo);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List messages, ReplyTo? replyingTo) $default,) {final _that = this; +switch (_that) { +case _RoomChatState(): +return $default(_that.messages,_that.replyingTo);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List messages, ReplyTo? replyingTo)? $default,) {final _that = this; +switch (_that) { +case _RoomChatState() when $default != null: +return $default(_that.messages,_that.replyingTo);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RoomChatState implements RoomChatState { + const _RoomChatState({final List messages = const [], this.replyingTo}): _messages = messages; + + + final List _messages; +@override@JsonKey() List get messages { + if (_messages is EqualUnmodifiableListView) return _messages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_messages); +} + +@override final ReplyTo? replyingTo; + +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RoomChatStateCopyWith<_RoomChatState> get copyWith => __$RoomChatStateCopyWithImpl<_RoomChatState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RoomChatState&&const DeepCollectionEquality().equals(other._messages, _messages)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_messages),replyingTo); + +@override +String toString() { + return 'RoomChatState(messages: $messages, replyingTo: $replyingTo)'; +} + + +} + +/// @nodoc +abstract mixin class _$RoomChatStateCopyWith<$Res> implements $RoomChatStateCopyWith<$Res> { + factory _$RoomChatStateCopyWith(_RoomChatState value, $Res Function(_RoomChatState) _then) = __$RoomChatStateCopyWithImpl; +@override @useResult +$Res call({ + List messages, ReplyTo? replyingTo +}); + + +@override $ReplyToCopyWith<$Res>? get replyingTo; + +} +/// @nodoc +class __$RoomChatStateCopyWithImpl<$Res> + implements _$RoomChatStateCopyWith<$Res> { + __$RoomChatStateCopyWithImpl(this._self, this._then); + + final _RoomChatState _self; + final $Res Function(_RoomChatState) _then; + +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? messages = null,Object? replyingTo = freezed,}) { + return _then(_RoomChatState( +messages: null == messages ? _self._messages : messages // ignore: cast_nullable_to_non_nullable +as List,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable +as ReplyTo?, + )); +} + +/// Create a copy of RoomChatState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ReplyToCopyWith<$Res>? get replyingTo { + if (_self.replyingTo == null) { + return null; + } + + return $ReplyToCopyWith<$Res>(_self.replyingTo!, (value) { + return _then(_self.copyWith(replyingTo: value)); + }); +} +} + +// dart format on diff --git a/lib/features/rooms/model/generated/room_failure.freezed.dart b/lib/features/rooms/model/generated/room_failure.freezed.dart new file mode 100644 index 00000000..3a25c398 --- /dev/null +++ b/lib/features/rooms/model/generated/room_failure.freezed.dart @@ -0,0 +1,420 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../room_failure.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RoomFailure { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'RoomFailure()'; +} + + +} + +/// @nodoc +class $RoomFailureCopyWith<$Res> { +$RoomFailureCopyWith(RoomFailure _, $Res Function(RoomFailure) __); +} + + +/// Adds pattern-matching-related methods to [RoomFailure]. +extension RoomFailurePatterns on RoomFailure { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( RoomFailureNotFound value)? notFound,TResult Function( RoomFailureNetwork value)? network,TResult Function( RoomFailureLiveKit value)? liveKit,TResult Function( RoomFailurePermissionDenied value)? permissionDenied,TResult Function( RoomFailureUnknown value)? unknown,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case RoomFailureNotFound() when notFound != null: +return notFound(_that);case RoomFailureNetwork() when network != null: +return network(_that);case RoomFailureLiveKit() when liveKit != null: +return liveKit(_that);case RoomFailurePermissionDenied() when permissionDenied != null: +return permissionDenied(_that);case RoomFailureUnknown() when unknown != null: +return unknown(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( RoomFailureNotFound value) notFound,required TResult Function( RoomFailureNetwork value) network,required TResult Function( RoomFailureLiveKit value) liveKit,required TResult Function( RoomFailurePermissionDenied value) permissionDenied,required TResult Function( RoomFailureUnknown value) unknown,}){ +final _that = this; +switch (_that) { +case RoomFailureNotFound(): +return notFound(_that);case RoomFailureNetwork(): +return network(_that);case RoomFailureLiveKit(): +return liveKit(_that);case RoomFailurePermissionDenied(): +return permissionDenied(_that);case RoomFailureUnknown(): +return unknown(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( RoomFailureNotFound value)? notFound,TResult? Function( RoomFailureNetwork value)? network,TResult? Function( RoomFailureLiveKit value)? liveKit,TResult? Function( RoomFailurePermissionDenied value)? permissionDenied,TResult? Function( RoomFailureUnknown value)? unknown,}){ +final _that = this; +switch (_that) { +case RoomFailureNotFound() when notFound != null: +return notFound(_that);case RoomFailureNetwork() when network != null: +return network(_that);case RoomFailureLiveKit() when liveKit != null: +return liveKit(_that);case RoomFailurePermissionDenied() when permissionDenied != null: +return permissionDenied(_that);case RoomFailureUnknown() when unknown != null: +return unknown(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? notFound,TResult Function()? network,TResult Function( String message)? liveKit,TResult Function()? permissionDenied,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; +switch (_that) { +case RoomFailureNotFound() when notFound != null: +return notFound();case RoomFailureNetwork() when network != null: +return network();case RoomFailureLiveKit() when liveKit != null: +return liveKit(_that.message);case RoomFailurePermissionDenied() when permissionDenied != null: +return permissionDenied();case RoomFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() notFound,required TResult Function() network,required TResult Function( String message) liveKit,required TResult Function() permissionDenied,required TResult Function( String message) unknown,}) {final _that = this; +switch (_that) { +case RoomFailureNotFound(): +return notFound();case RoomFailureNetwork(): +return network();case RoomFailureLiveKit(): +return liveKit(_that.message);case RoomFailurePermissionDenied(): +return permissionDenied();case RoomFailureUnknown(): +return unknown(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? notFound,TResult? Function()? network,TResult? Function( String message)? liveKit,TResult? Function()? permissionDenied,TResult? Function( String message)? unknown,}) {final _that = this; +switch (_that) { +case RoomFailureNotFound() when notFound != null: +return notFound();case RoomFailureNetwork() when network != null: +return network();case RoomFailureLiveKit() when liveKit != null: +return liveKit(_that.message);case RoomFailurePermissionDenied() when permissionDenied != null: +return permissionDenied();case RoomFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class RoomFailureNotFound implements RoomFailure { + const RoomFailureNotFound(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailureNotFound); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'RoomFailure.notFound()'; +} + + +} + + + + +/// @nodoc + + +class RoomFailureNetwork implements RoomFailure { + const RoomFailureNetwork(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailureNetwork); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'RoomFailure.network()'; +} + + +} + + + + +/// @nodoc + + +class RoomFailureLiveKit implements RoomFailure { + const RoomFailureLiveKit(this.message); + + + final String message; + +/// Create a copy of RoomFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomFailureLiveKitCopyWith get copyWith => _$RoomFailureLiveKitCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailureLiveKit&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'RoomFailure.liveKit(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomFailureLiveKitCopyWith<$Res> implements $RoomFailureCopyWith<$Res> { + factory $RoomFailureLiveKitCopyWith(RoomFailureLiveKit value, $Res Function(RoomFailureLiveKit) _then) = _$RoomFailureLiveKitCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$RoomFailureLiveKitCopyWithImpl<$Res> + implements $RoomFailureLiveKitCopyWith<$Res> { + _$RoomFailureLiveKitCopyWithImpl(this._self, this._then); + + final RoomFailureLiveKit _self; + final $Res Function(RoomFailureLiveKit) _then; + +/// Create a copy of RoomFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(RoomFailureLiveKit( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class RoomFailurePermissionDenied implements RoomFailure { + const RoomFailurePermissionDenied(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailurePermissionDenied); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'RoomFailure.permissionDenied()'; +} + + +} + + + + +/// @nodoc + + +class RoomFailureUnknown implements RoomFailure { + const RoomFailureUnknown(this.message); + + + final String message; + +/// Create a copy of RoomFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomFailureUnknownCopyWith get copyWith => _$RoomFailureUnknownCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomFailureUnknown&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'RoomFailure.unknown(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomFailureUnknownCopyWith<$Res> implements $RoomFailureCopyWith<$Res> { + factory $RoomFailureUnknownCopyWith(RoomFailureUnknown value, $Res Function(RoomFailureUnknown) _then) = _$RoomFailureUnknownCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$RoomFailureUnknownCopyWithImpl<$Res> + implements $RoomFailureUnknownCopyWith<$Res> { + _$RoomFailureUnknownCopyWithImpl(this._self, this._then); + + final RoomFailureUnknown _self; + final $Res Function(RoomFailureUnknown) _then; + +/// Create a copy of RoomFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(RoomFailureUnknown( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/room_message.freezed.dart b/lib/features/rooms/model/generated/room_message.freezed.dart new file mode 100644 index 00000000..162f5123 --- /dev/null +++ b/lib/features/rooms/model/generated/room_message.freezed.dart @@ -0,0 +1,340 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../room_message.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RoomMessage { + + String get roomId; String get messageId; String get creatorId; String get creatorUsername; String get creatorName; String get creatorImgUrl; bool get hasValidTag; int get index; bool get isEdited; String get content; DateTime get creationDateTime; bool get isDeleted; ReplyTo? get replyTo;@JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus get status; +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomMessageCopyWith get copyWith => _$RoomMessageCopyWithImpl(this as RoomMessage, _$identity); + + /// Serializes this RoomMessage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomMessage&&(identical(other.roomId, roomId) || other.roomId == roomId)&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.creatorId, creatorId) || other.creatorId == creatorId)&&(identical(other.creatorUsername, creatorUsername) || other.creatorUsername == creatorUsername)&&(identical(other.creatorName, creatorName) || other.creatorName == creatorName)&&(identical(other.creatorImgUrl, creatorImgUrl) || other.creatorImgUrl == creatorImgUrl)&&(identical(other.hasValidTag, hasValidTag) || other.hasValidTag == hasValidTag)&&(identical(other.index, index) || other.index == index)&&(identical(other.isEdited, isEdited) || other.isEdited == isEdited)&&(identical(other.content, content) || other.content == content)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.isDeleted, isDeleted) || other.isDeleted == isDeleted)&&(identical(other.replyTo, replyTo) || other.replyTo == replyTo)&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,roomId,messageId,creatorId,creatorUsername,creatorName,creatorImgUrl,hasValidTag,index,isEdited,content,creationDateTime,isDeleted,replyTo,status); + +@override +String toString() { + return 'RoomMessage(roomId: $roomId, messageId: $messageId, creatorId: $creatorId, creatorUsername: $creatorUsername, creatorName: $creatorName, creatorImgUrl: $creatorImgUrl, hasValidTag: $hasValidTag, index: $index, isEdited: $isEdited, content: $content, creationDateTime: $creationDateTime, isDeleted: $isDeleted, replyTo: $replyTo, status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomMessageCopyWith<$Res> { + factory $RoomMessageCopyWith(RoomMessage value, $Res Function(RoomMessage) _then) = _$RoomMessageCopyWithImpl; +@useResult +$Res call({ + String roomId, String messageId, String creatorId, String creatorUsername, String creatorName, String creatorImgUrl, bool hasValidTag, int index, bool isEdited, String content, DateTime creationDateTime, bool isDeleted, ReplyTo? replyTo,@JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus status +}); + + +$ReplyToCopyWith<$Res>? get replyTo; + +} +/// @nodoc +class _$RoomMessageCopyWithImpl<$Res> + implements $RoomMessageCopyWith<$Res> { + _$RoomMessageCopyWithImpl(this._self, this._then); + + final RoomMessage _self; + final $Res Function(RoomMessage) _then; + +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? roomId = null,Object? messageId = null,Object? creatorId = null,Object? creatorUsername = null,Object? creatorName = null,Object? creatorImgUrl = null,Object? hasValidTag = null,Object? index = null,Object? isEdited = null,Object? content = null,Object? creationDateTime = null,Object? isDeleted = null,Object? replyTo = freezed,Object? status = null,}) { + return _then(_self.copyWith( +roomId: null == roomId ? _self.roomId : roomId // ignore: cast_nullable_to_non_nullable +as String,messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,creatorId: null == creatorId ? _self.creatorId : creatorId // ignore: cast_nullable_to_non_nullable +as String,creatorUsername: null == creatorUsername ? _self.creatorUsername : creatorUsername // ignore: cast_nullable_to_non_nullable +as String,creatorName: null == creatorName ? _self.creatorName : creatorName // ignore: cast_nullable_to_non_nullable +as String,creatorImgUrl: null == creatorImgUrl ? _self.creatorImgUrl : creatorImgUrl // ignore: cast_nullable_to_non_nullable +as String,hasValidTag: null == hasValidTag ? _self.hasValidTag : hasValidTag // ignore: cast_nullable_to_non_nullable +as bool,index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,isEdited: null == isEdited ? _self.isEdited : isEdited // ignore: cast_nullable_to_non_nullable +as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,creationDateTime: null == creationDateTime ? _self.creationDateTime : creationDateTime // ignore: cast_nullable_to_non_nullable +as DateTime,isDeleted: null == isDeleted ? _self.isDeleted : isDeleted // ignore: cast_nullable_to_non_nullable +as bool,replyTo: freezed == replyTo ? _self.replyTo : replyTo // ignore: cast_nullable_to_non_nullable +as ReplyTo?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RoomMessageStatus, + )); +} +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ReplyToCopyWith<$Res>? get replyTo { + if (_self.replyTo == null) { + return null; + } + + return $ReplyToCopyWith<$Res>(_self.replyTo!, (value) { + return _then(_self.copyWith(replyTo: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RoomMessage]. +extension RoomMessagePatterns on RoomMessage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RoomMessage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RoomMessage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RoomMessage value) $default,){ +final _that = this; +switch (_that) { +case _RoomMessage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RoomMessage value)? $default,){ +final _that = this; +switch (_that) { +case _RoomMessage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String roomId, String messageId, String creatorId, String creatorUsername, String creatorName, String creatorImgUrl, bool hasValidTag, int index, bool isEdited, String content, DateTime creationDateTime, bool isDeleted, ReplyTo? replyTo, @JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus status)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RoomMessage() when $default != null: +return $default(_that.roomId,_that.messageId,_that.creatorId,_that.creatorUsername,_that.creatorName,_that.creatorImgUrl,_that.hasValidTag,_that.index,_that.isEdited,_that.content,_that.creationDateTime,_that.isDeleted,_that.replyTo,_that.status);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String roomId, String messageId, String creatorId, String creatorUsername, String creatorName, String creatorImgUrl, bool hasValidTag, int index, bool isEdited, String content, DateTime creationDateTime, bool isDeleted, ReplyTo? replyTo, @JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus status) $default,) {final _that = this; +switch (_that) { +case _RoomMessage(): +return $default(_that.roomId,_that.messageId,_that.creatorId,_that.creatorUsername,_that.creatorName,_that.creatorImgUrl,_that.hasValidTag,_that.index,_that.isEdited,_that.content,_that.creationDateTime,_that.isDeleted,_that.replyTo,_that.status);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String roomId, String messageId, String creatorId, String creatorUsername, String creatorName, String creatorImgUrl, bool hasValidTag, int index, bool isEdited, String content, DateTime creationDateTime, bool isDeleted, ReplyTo? replyTo, @JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus status)? $default,) {final _that = this; +switch (_that) { +case _RoomMessage() when $default != null: +return $default(_that.roomId,_that.messageId,_that.creatorId,_that.creatorUsername,_that.creatorName,_that.creatorImgUrl,_that.hasValidTag,_that.index,_that.isEdited,_that.content,_that.creationDateTime,_that.isDeleted,_that.replyTo,_that.status);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _RoomMessage extends RoomMessage { + const _RoomMessage({required this.roomId, required this.messageId, required this.creatorId, required this.creatorUsername, required this.creatorName, required this.creatorImgUrl, required this.hasValidTag, required this.index, required this.isEdited, required this.content, required this.creationDateTime, this.isDeleted = false, this.replyTo, @JsonKey(includeFromJson: false, includeToJson: false) this.status = RoomMessageStatus.sent}): super._(); + factory _RoomMessage.fromJson(Map json) => _$RoomMessageFromJson(json); + +@override final String roomId; +@override final String messageId; +@override final String creatorId; +@override final String creatorUsername; +@override final String creatorName; +@override final String creatorImgUrl; +@override final bool hasValidTag; +@override final int index; +@override final bool isEdited; +@override final String content; +@override final DateTime creationDateTime; +@override@JsonKey() final bool isDeleted; +@override final ReplyTo? replyTo; +@override@JsonKey(includeFromJson: false, includeToJson: false) final RoomMessageStatus status; + +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RoomMessageCopyWith<_RoomMessage> get copyWith => __$RoomMessageCopyWithImpl<_RoomMessage>(this, _$identity); + +@override +Map toJson() { + return _$RoomMessageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RoomMessage&&(identical(other.roomId, roomId) || other.roomId == roomId)&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.creatorId, creatorId) || other.creatorId == creatorId)&&(identical(other.creatorUsername, creatorUsername) || other.creatorUsername == creatorUsername)&&(identical(other.creatorName, creatorName) || other.creatorName == creatorName)&&(identical(other.creatorImgUrl, creatorImgUrl) || other.creatorImgUrl == creatorImgUrl)&&(identical(other.hasValidTag, hasValidTag) || other.hasValidTag == hasValidTag)&&(identical(other.index, index) || other.index == index)&&(identical(other.isEdited, isEdited) || other.isEdited == isEdited)&&(identical(other.content, content) || other.content == content)&&(identical(other.creationDateTime, creationDateTime) || other.creationDateTime == creationDateTime)&&(identical(other.isDeleted, isDeleted) || other.isDeleted == isDeleted)&&(identical(other.replyTo, replyTo) || other.replyTo == replyTo)&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,roomId,messageId,creatorId,creatorUsername,creatorName,creatorImgUrl,hasValidTag,index,isEdited,content,creationDateTime,isDeleted,replyTo,status); + +@override +String toString() { + return 'RoomMessage(roomId: $roomId, messageId: $messageId, creatorId: $creatorId, creatorUsername: $creatorUsername, creatorName: $creatorName, creatorImgUrl: $creatorImgUrl, hasValidTag: $hasValidTag, index: $index, isEdited: $isEdited, content: $content, creationDateTime: $creationDateTime, isDeleted: $isDeleted, replyTo: $replyTo, status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class _$RoomMessageCopyWith<$Res> implements $RoomMessageCopyWith<$Res> { + factory _$RoomMessageCopyWith(_RoomMessage value, $Res Function(_RoomMessage) _then) = __$RoomMessageCopyWithImpl; +@override @useResult +$Res call({ + String roomId, String messageId, String creatorId, String creatorUsername, String creatorName, String creatorImgUrl, bool hasValidTag, int index, bool isEdited, String content, DateTime creationDateTime, bool isDeleted, ReplyTo? replyTo,@JsonKey(includeFromJson: false, includeToJson: false) RoomMessageStatus status +}); + + +@override $ReplyToCopyWith<$Res>? get replyTo; + +} +/// @nodoc +class __$RoomMessageCopyWithImpl<$Res> + implements _$RoomMessageCopyWith<$Res> { + __$RoomMessageCopyWithImpl(this._self, this._then); + + final _RoomMessage _self; + final $Res Function(_RoomMessage) _then; + +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? roomId = null,Object? messageId = null,Object? creatorId = null,Object? creatorUsername = null,Object? creatorName = null,Object? creatorImgUrl = null,Object? hasValidTag = null,Object? index = null,Object? isEdited = null,Object? content = null,Object? creationDateTime = null,Object? isDeleted = null,Object? replyTo = freezed,Object? status = null,}) { + return _then(_RoomMessage( +roomId: null == roomId ? _self.roomId : roomId // ignore: cast_nullable_to_non_nullable +as String,messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,creatorId: null == creatorId ? _self.creatorId : creatorId // ignore: cast_nullable_to_non_nullable +as String,creatorUsername: null == creatorUsername ? _self.creatorUsername : creatorUsername // ignore: cast_nullable_to_non_nullable +as String,creatorName: null == creatorName ? _self.creatorName : creatorName // ignore: cast_nullable_to_non_nullable +as String,creatorImgUrl: null == creatorImgUrl ? _self.creatorImgUrl : creatorImgUrl // ignore: cast_nullable_to_non_nullable +as String,hasValidTag: null == hasValidTag ? _self.hasValidTag : hasValidTag // ignore: cast_nullable_to_non_nullable +as bool,index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,isEdited: null == isEdited ? _self.isEdited : isEdited // ignore: cast_nullable_to_non_nullable +as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,creationDateTime: null == creationDateTime ? _self.creationDateTime : creationDateTime // ignore: cast_nullable_to_non_nullable +as DateTime,isDeleted: null == isDeleted ? _self.isDeleted : isDeleted // ignore: cast_nullable_to_non_nullable +as bool,replyTo: freezed == replyTo ? _self.replyTo : replyTo // ignore: cast_nullable_to_non_nullable +as ReplyTo?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as RoomMessageStatus, + )); +} + +/// Create a copy of RoomMessage +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ReplyToCopyWith<$Res>? get replyTo { + if (_self.replyTo == null) { + return null; + } + + return $ReplyToCopyWith<$Res>(_self.replyTo!, (value) { + return _then(_self.copyWith(replyTo: value)); + }); +} +} + +// dart format on diff --git a/lib/features/rooms/model/generated/room_message.g.dart b/lib/features/rooms/model/generated/room_message.g.dart new file mode 100644 index 00000000..72f65ffb --- /dev/null +++ b/lib/features/rooms/model/generated/room_message.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../room_message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RoomMessage _$RoomMessageFromJson(Map json) => _RoomMessage( + roomId: json['roomId'] as String, + messageId: json['messageId'] as String, + creatorId: json['creatorId'] as String, + creatorUsername: json['creatorUsername'] as String, + creatorName: json['creatorName'] as String, + creatorImgUrl: json['creatorImgUrl'] as String, + hasValidTag: json['hasValidTag'] as bool, + index: (json['index'] as num).toInt(), + isEdited: json['isEdited'] as bool, + content: json['content'] as String, + creationDateTime: DateTime.parse(json['creationDateTime'] as String), + isDeleted: json['isDeleted'] as bool? ?? false, + replyTo: json['replyTo'] == null + ? null + : ReplyTo.fromJson(json['replyTo'] as Map), +); + +Map _$RoomMessageToJson(_RoomMessage instance) => + { + 'roomId': instance.roomId, + 'messageId': instance.messageId, + 'creatorId': instance.creatorId, + 'creatorUsername': instance.creatorUsername, + 'creatorName': instance.creatorName, + 'creatorImgUrl': instance.creatorImgUrl, + 'hasValidTag': instance.hasValidTag, + 'index': instance.index, + 'isEdited': instance.isEdited, + 'content': instance.content, + 'creationDateTime': instance.creationDateTime.toIso8601String(), + 'isDeleted': instance.isDeleted, + 'replyTo': instance.replyTo, + }; diff --git a/lib/features/rooms/model/generated/rooms_state.freezed.dart b/lib/features/rooms/model/generated/rooms_state.freezed.dart new file mode 100644 index 00000000..202bc92e --- /dev/null +++ b/lib/features/rooms/model/generated/rooms_state.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../rooms_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RoomsState { + + List get rooms; List get filteredRooms; bool get isSearching; bool get searchBarIsEmpty; +/// Create a copy of RoomsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomsStateCopyWith get copyWith => _$RoomsStateCopyWithImpl(this as RoomsState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomsState&&const DeepCollectionEquality().equals(other.rooms, rooms)&&const DeepCollectionEquality().equals(other.filteredRooms, filteredRooms)&&(identical(other.isSearching, isSearching) || other.isSearching == isSearching)&&(identical(other.searchBarIsEmpty, searchBarIsEmpty) || other.searchBarIsEmpty == searchBarIsEmpty)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(rooms),const DeepCollectionEquality().hash(filteredRooms),isSearching,searchBarIsEmpty); + +@override +String toString() { + return 'RoomsState(rooms: $rooms, filteredRooms: $filteredRooms, isSearching: $isSearching, searchBarIsEmpty: $searchBarIsEmpty)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomsStateCopyWith<$Res> { + factory $RoomsStateCopyWith(RoomsState value, $Res Function(RoomsState) _then) = _$RoomsStateCopyWithImpl; +@useResult +$Res call({ + List rooms, List filteredRooms, bool isSearching, bool searchBarIsEmpty +}); + + + + +} +/// @nodoc +class _$RoomsStateCopyWithImpl<$Res> + implements $RoomsStateCopyWith<$Res> { + _$RoomsStateCopyWithImpl(this._self, this._then); + + final RoomsState _self; + final $Res Function(RoomsState) _then; + +/// Create a copy of RoomsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? rooms = null,Object? filteredRooms = null,Object? isSearching = null,Object? searchBarIsEmpty = null,}) { + return _then(_self.copyWith( +rooms: null == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as List,filteredRooms: null == filteredRooms ? _self.filteredRooms : filteredRooms // ignore: cast_nullable_to_non_nullable +as List,isSearching: null == isSearching ? _self.isSearching : isSearching // ignore: cast_nullable_to_non_nullable +as bool,searchBarIsEmpty: null == searchBarIsEmpty ? _self.searchBarIsEmpty : searchBarIsEmpty // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RoomsState]. +extension RoomsStatePatterns on RoomsState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( RoomsStateReady value)? ready,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case RoomsStateReady() when ready != null: +return ready(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( RoomsStateReady value) ready,}){ +final _that = this; +switch (_that) { +case RoomsStateReady(): +return ready(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( RoomsStateReady value)? ready,}){ +final _that = this; +switch (_that) { +case RoomsStateReady() when ready != null: +return ready(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List rooms, List filteredRooms, bool isSearching, bool searchBarIsEmpty)? ready,required TResult orElse(),}) {final _that = this; +switch (_that) { +case RoomsStateReady() when ready != null: +return ready(_that.rooms,_that.filteredRooms,_that.isSearching,_that.searchBarIsEmpty);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List rooms, List filteredRooms, bool isSearching, bool searchBarIsEmpty) ready,}) {final _that = this; +switch (_that) { +case RoomsStateReady(): +return ready(_that.rooms,_that.filteredRooms,_that.isSearching,_that.searchBarIsEmpty);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List rooms, List filteredRooms, bool isSearching, bool searchBarIsEmpty)? ready,}) {final _that = this; +switch (_that) { +case RoomsStateReady() when ready != null: +return ready(_that.rooms,_that.filteredRooms,_that.isSearching,_that.searchBarIsEmpty);case _: + return null; + +} +} + +} + +/// @nodoc + + +class RoomsStateReady extends RoomsState { + const RoomsStateReady({final List rooms = const [], final List filteredRooms = const [], this.isSearching = false, this.searchBarIsEmpty = true}): _rooms = rooms,_filteredRooms = filteredRooms,super._(); + + + final List _rooms; +@override@JsonKey() List get rooms { + if (_rooms is EqualUnmodifiableListView) return _rooms; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_rooms); +} + + final List _filteredRooms; +@override@JsonKey() List get filteredRooms { + if (_filteredRooms is EqualUnmodifiableListView) return _filteredRooms; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_filteredRooms); +} + +@override@JsonKey() final bool isSearching; +@override@JsonKey() final bool searchBarIsEmpty; + +/// Create a copy of RoomsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RoomsStateReadyCopyWith get copyWith => _$RoomsStateReadyCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RoomsStateReady&&const DeepCollectionEquality().equals(other._rooms, _rooms)&&const DeepCollectionEquality().equals(other._filteredRooms, _filteredRooms)&&(identical(other.isSearching, isSearching) || other.isSearching == isSearching)&&(identical(other.searchBarIsEmpty, searchBarIsEmpty) || other.searchBarIsEmpty == searchBarIsEmpty)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_rooms),const DeepCollectionEquality().hash(_filteredRooms),isSearching,searchBarIsEmpty); + +@override +String toString() { + return 'RoomsState.ready(rooms: $rooms, filteredRooms: $filteredRooms, isSearching: $isSearching, searchBarIsEmpty: $searchBarIsEmpty)'; +} + + +} + +/// @nodoc +abstract mixin class $RoomsStateReadyCopyWith<$Res> implements $RoomsStateCopyWith<$Res> { + factory $RoomsStateReadyCopyWith(RoomsStateReady value, $Res Function(RoomsStateReady) _then) = _$RoomsStateReadyCopyWithImpl; +@override @useResult +$Res call({ + List rooms, List filteredRooms, bool isSearching, bool searchBarIsEmpty +}); + + + + +} +/// @nodoc +class _$RoomsStateReadyCopyWithImpl<$Res> + implements $RoomsStateReadyCopyWith<$Res> { + _$RoomsStateReadyCopyWithImpl(this._self, this._then); + + final RoomsStateReady _self; + final $Res Function(RoomsStateReady) _then; + +/// Create a copy of RoomsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? rooms = null,Object? filteredRooms = null,Object? isSearching = null,Object? searchBarIsEmpty = null,}) { + return _then(RoomsStateReady( +rooms: null == rooms ? _self._rooms : rooms // ignore: cast_nullable_to_non_nullable +as List,filteredRooms: null == filteredRooms ? _self._filteredRooms : filteredRooms // ignore: cast_nullable_to_non_nullable +as List,isSearching: null == isSearching ? _self.isSearching : isSearching // ignore: cast_nullable_to_non_nullable +as bool,searchBarIsEmpty: null == searchBarIsEmpty ? _self.searchBarIsEmpty : searchBarIsEmpty // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/features/rooms/model/generated/single_room_state.freezed.dart b/lib/features/rooms/model/generated/single_room_state.freezed.dart new file mode 100644 index 00000000..4ba42e9d --- /dev/null +++ b/lib/features/rooms/model/generated/single_room_state.freezed.dart @@ -0,0 +1,301 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../single_room_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$SingleRoomState { + + Participant get me; List get participants; bool get isLoading; +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SingleRoomStateCopyWith get copyWith => _$SingleRoomStateCopyWithImpl(this as SingleRoomState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other.participants, participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(participants),isLoading); + +@override +String toString() { + return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class $SingleRoomStateCopyWith<$Res> { + factory $SingleRoomStateCopyWith(SingleRoomState value, $Res Function(SingleRoomState) _then) = _$SingleRoomStateCopyWithImpl; +@useResult +$Res call({ + Participant me, List participants, bool isLoading +}); + + +$ParticipantCopyWith<$Res> get me; + +} +/// @nodoc +class _$SingleRoomStateCopyWithImpl<$Res> + implements $SingleRoomStateCopyWith<$Res> { + _$SingleRoomStateCopyWithImpl(this._self, this._then); + + final SingleRoomState _self; + final $Res Function(SingleRoomState) _then; + +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,}) { + return _then(_self.copyWith( +me: null == me ? _self.me : me // ignore: cast_nullable_to_non_nullable +as Participant,participants: null == participants ? _self.participants : participants // ignore: cast_nullable_to_non_nullable +as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ParticipantCopyWith<$Res> get me { + + return $ParticipantCopyWith<$Res>(_self.me, (value) { + return _then(_self.copyWith(me: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [SingleRoomState]. +extension SingleRoomStatePatterns on SingleRoomState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SingleRoomState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SingleRoomState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SingleRoomState value) $default,){ +final _that = this; +switch (_that) { +case _SingleRoomState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SingleRoomState value)? $default,){ +final _that = this; +switch (_that) { +case _SingleRoomState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Participant me, List participants, bool isLoading)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SingleRoomState() when $default != null: +return $default(_that.me,_that.participants,_that.isLoading);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Participant me, List participants, bool isLoading) $default,) {final _that = this; +switch (_that) { +case _SingleRoomState(): +return $default(_that.me,_that.participants,_that.isLoading);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Participant me, List participants, bool isLoading)? $default,) {final _that = this; +switch (_that) { +case _SingleRoomState() when $default != null: +return $default(_that.me,_that.participants,_that.isLoading);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SingleRoomState implements SingleRoomState { + const _SingleRoomState({required this.me, final List participants = const [], this.isLoading = false}): _participants = participants; + + +@override final Participant me; + final List _participants; +@override@JsonKey() List get participants { + if (_participants is EqualUnmodifiableListView) return _participants; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_participants); +} + +@override@JsonKey() final bool isLoading; + +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SingleRoomStateCopyWith<_SingleRoomState> get copyWith => __$SingleRoomStateCopyWithImpl<_SingleRoomState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other._participants, _participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(_participants),isLoading); + +@override +String toString() { + return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class _$SingleRoomStateCopyWith<$Res> implements $SingleRoomStateCopyWith<$Res> { + factory _$SingleRoomStateCopyWith(_SingleRoomState value, $Res Function(_SingleRoomState) _then) = __$SingleRoomStateCopyWithImpl; +@override @useResult +$Res call({ + Participant me, List participants, bool isLoading +}); + + +@override $ParticipantCopyWith<$Res> get me; + +} +/// @nodoc +class __$SingleRoomStateCopyWithImpl<$Res> + implements _$SingleRoomStateCopyWith<$Res> { + __$SingleRoomStateCopyWithImpl(this._self, this._then); + + final _SingleRoomState _self; + final $Res Function(_SingleRoomState) _then; + +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,}) { + return _then(_SingleRoomState( +me: null == me ? _self.me : me // ignore: cast_nullable_to_non_nullable +as Participant,participants: null == participants ? _self._participants : participants // ignore: cast_nullable_to_non_nullable +as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of SingleRoomState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ParticipantCopyWith<$Res> get me { + + return $ParticipantCopyWith<$Res>(_self.me, (value) { + return _then(_self.copyWith(me: value)); + }); +} +} + +// dart format on diff --git a/lib/features/rooms/model/livekit_state.dart b/lib/features/rooms/model/livekit_state.dart new file mode 100644 index 00000000..701fdabf --- /dev/null +++ b/lib/features/rooms/model/livekit_state.dart @@ -0,0 +1,12 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/livekit_state.freezed.dart'; + +@freezed +abstract class LiveKitState with _$LiveKitState { + const factory LiveKitState({ + @Default(false) bool isConnected, + @Default(false) bool isRecording, + @Default(false) bool hasSession, + }) = _LiveKitState; +} diff --git a/lib/features/rooms/model/participant.dart b/lib/features/rooms/model/participant.dart new file mode 100644 index 00000000..7e7a93e8 --- /dev/null +++ b/lib/features/rooms/model/participant.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/participant.freezed.dart'; +part 'generated/participant.g.dart'; + +const _defaultAvatar = + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlciUyMHByb2ZpbGV8ZW58MHx8MHx8&w=1000&q=80'; + +@freezed +abstract class Participant with _$Participant { + const factory Participant({ + required String uid, + required String email, + required String name, + required String dpUrl, + required bool isAdmin, + required bool isMicOn, + required bool isModerator, + required bool isSpeaker, + required bool hasRequestedToBeSpeaker, + }) = _Participant; + + factory Participant.fromJson(Map json) => + _$ParticipantFromJson({ + ...json, + 'name': json['name'] ?? 'Unknown', + 'dpUrl': json['dpUrl'] ?? _defaultAvatar, + }); +} diff --git a/lib/features/rooms/model/reply_to.dart b/lib/features/rooms/model/reply_to.dart new file mode 100644 index 00000000..2a440df6 --- /dev/null +++ b/lib/features/rooms/model/reply_to.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/reply_to.freezed.dart'; +part 'generated/reply_to.g.dart'; + +@freezed +abstract class ReplyTo with _$ReplyTo { + const factory ReplyTo({ + required String messageId, + required String creatorUsername, + required String creatorImgUrl, + required int index, + required String content, + }) = _ReplyTo; + + factory ReplyTo.fromJson(Map json) => _$ReplyToFromJson(json); +} diff --git a/lib/features/rooms/model/room_chat_state.dart b/lib/features/rooms/model/room_chat_state.dart new file mode 100644 index 00000000..de53b246 --- /dev/null +++ b/lib/features/rooms/model/room_chat_state.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/rooms/model/reply_to.dart'; +import 'package:resonate/features/rooms/model/room_message.dart'; + +part 'generated/room_chat_state.freezed.dart'; + +@freezed +abstract class RoomChatState with _$RoomChatState { + const factory RoomChatState({ + @Default([]) List messages, + ReplyTo? replyingTo, + }) = _RoomChatState; +} diff --git a/lib/features/rooms/model/room_failure.dart b/lib/features/rooms/model/room_failure.dart new file mode 100644 index 00000000..19368fa2 --- /dev/null +++ b/lib/features/rooms/model/room_failure.dart @@ -0,0 +1,12 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/room_failure.freezed.dart'; + +@freezed +sealed class RoomFailure with _$RoomFailure { + const factory RoomFailure.notFound() = RoomFailureNotFound; + const factory RoomFailure.network() = RoomFailureNetwork; + const factory RoomFailure.liveKit(String message) = RoomFailureLiveKit; + const factory RoomFailure.permissionDenied() = RoomFailurePermissionDenied; + const factory RoomFailure.unknown(String message) = RoomFailureUnknown; +} diff --git a/lib/features/rooms/model/room_message.dart b/lib/features/rooms/model/room_message.dart new file mode 100644 index 00000000..074d22c2 --- /dev/null +++ b/lib/features/rooms/model/room_message.dart @@ -0,0 +1,44 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/rooms/model/reply_to.dart'; + +part 'generated/room_message.freezed.dart'; +part 'generated/room_message.g.dart'; + +// Client-only send state for chat messages. Not persisted server-side. +enum RoomMessageStatus { pending, sent, failed } + +@freezed +abstract class RoomMessage with _$RoomMessage { + const RoomMessage._(); + + const factory RoomMessage({ + required String roomId, + required String messageId, + required String creatorId, + required String creatorUsername, + required String creatorName, + required String creatorImgUrl, + required bool hasValidTag, + required int index, + required bool isEdited, + required String content, + required DateTime creationDateTime, + @Default(false) bool isDeleted, + ReplyTo? replyTo, + @JsonKey(includeFromJson: false, includeToJson: false) + @Default(RoomMessageStatus.sent) + RoomMessageStatus status, + }) = _RoomMessage; + + factory RoomMessage.fromJson(Map json) => _$RoomMessageFromJson({ + ...json, + 'isDeleted': json['isDeleted'] ?? false, + }); + + Map toJsonForUpload() { + final json = toJson(); + json.remove('replyTo'); + json['creationDateTime'] = creationDateTime.toUtc().toIso8601String(); + return json; + } +} diff --git a/lib/features/rooms/model/rooms_state.dart b/lib/features/rooms/model/rooms_state.dart new file mode 100644 index 00000000..665f33f4 --- /dev/null +++ b/lib/features/rooms/model/rooms_state.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; + +part 'generated/rooms_state.freezed.dart'; + +@freezed +sealed class RoomsState with _$RoomsState { + const RoomsState._(); + + const factory RoomsState.ready({ + @Default([]) List rooms, + @Default([]) List filteredRooms, + @Default(false) bool isSearching, + @Default(true) bool searchBarIsEmpty, + }) = RoomsStateReady; + + List get visibleRooms => switch (this) { + RoomsStateReady(:final filteredRooms, :final searchBarIsEmpty, :final rooms) => + searchBarIsEmpty ? rooms : filteredRooms, + }; +} diff --git a/lib/features/rooms/model/single_room_state.dart b/lib/features/rooms/model/single_room_state.dart new file mode 100644 index 00000000..53cca0e1 --- /dev/null +++ b/lib/features/rooms/model/single_room_state.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/rooms/model/participant.dart'; + +part 'generated/single_room_state.freezed.dart'; + +@freezed +abstract class SingleRoomState with _$SingleRoomState { + const factory SingleRoomState({ + required Participant me, + @Default([]) List participants, + @Default(false) bool isLoading, + }) = _SingleRoomState; +} diff --git a/lib/features/rooms/rooms_routes.dart b/lib/features/rooms/rooms_routes.dart new file mode 100644 index 00000000..ed79175d --- /dev/null +++ b/lib/features/rooms/rooms_routes.dart @@ -0,0 +1,11 @@ +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/rooms/view/pages/create_room_page.dart'; +import 'package:resonate/routes/route_paths.dart'; + +// Routes contributed by the rooms feature to the global GoRouter. +final List roomsRoutes = [ + GoRoute( + path: RoutePaths.createRoom, + builder: (context, state) => CreateRoomPage(), + ), +]; diff --git a/lib/features/rooms/view/pages/create_room_page.dart b/lib/features/rooms/view/pages/create_room_page.dart new file mode 100644 index 00000000..dbbe9deb --- /dev/null +++ b/lib/features/rooms/view/pages/create_room_page.dart @@ -0,0 +1,467 @@ +import 'dart:developer'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/viewmodel/create_room_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:textfield_tags/textfield_tags.dart'; + +final GlobalKey createRoomFormKey = + GlobalKey(); + +class CreateRoomPage extends ConsumerStatefulWidget { + CreateRoomPage({Key? key}) : super(key: key ?? createRoomFormKey); + + @override + ConsumerState createState() => CreateRoomPageState(); +} + +class CreateRoomPageState extends ConsumerState { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + final TextEditingController _dateTimeController = TextEditingController(); + final TextfieldTagsController _tagsController = TextfieldTagsController(); + + bool _isScheduled = false; + String? _scheduledDateTimeIso; + + static const _monthMap = { + '1': 'Jan', + '2': 'Feb', + '3': 'March', + '4': 'April', + '5': 'May', + '6': 'June', + '7': 'July', + '8': 'Aug', + '9': 'Sep', + '10': 'Oct', + '11': 'Nov', + '12': 'Dec', + }; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _dateTimeController.dispose(); + _tagsController.dispose(); + super.dispose(); + } + + String? _validateTag(dynamic tag) { + if (tag is String && tag.trim().isValidTag) return null; + return '${AppLocalizations.of(context)!.invalidTags} $tag'; + } + + String _formatTzOffset(String offset, bool isNegative) { + final parts = offset.split(':'); + final hour = isNegative ? parts[0].split('-')[1] : parts[0]; + final hh = hour.length < 2 ? '0$hour' : hour; + final mm = parts[1].length < 2 ? '0${parts[1]}' : parts[1]; + return '$hh:$mm'; + } + + Future _chooseDateTime() async { + final now = DateTime.now(); + final offsetStr = now.timeZoneOffset.toString(); + final isNeg = now.timeZoneOffset.isNegative; + final formattedOffset = _formatTzOffset(offsetStr, isNeg); + + final pickedDate = await showDatePicker( + context: context, + initialDate: now, + firstDate: now, + lastDate: DateTime(now.year + 1, now.month, now.day), + ); + if (!mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + now.add(const Duration(minutes: 5)), + ), + ); + if (!mounted || pickedDate == null || pickedTime == null) return; + + final pickedDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + + if (!pickedDateTime.isAfter(now)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.scheduledDateTimePast, + ), + ), + ); + return; + } + + setState(() { + _scheduledDateTimeIso = + '${DateFormat('yyyy-MM-dd').format(pickedDate)}T${pickedTime.hour}:${pickedTime.minute}:00${isNeg ? '-' : '+'}$formattedOffset'; + final hour = pickedTime.hour > 12 + ? (pickedTime.hour - 12).toString() + : pickedTime.hour == 0 + ? '00' + : pickedTime.hour.toString(); + final minute = pickedTime.minute.toString().length < 2 + ? '0${pickedTime.minute}' + : pickedTime.minute.toString(); + _dateTimeController.text = + '${pickedDate.day} ${_monthMap[pickedDate.month.toString()]} ${pickedDate.year} $hour:$minute ${pickedTime.period.name.toUpperCase()}'; + }); + } + + void _clearForm() { + _nameController.clear(); + _descriptionController.clear(); + _dateTimeController.clear(); + _tagsController.clearTags(); + setState(() { + _scheduledDateTimeIso = null; + }); + } + + + Future submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return null; + + final name = _nameController.text; + final description = _descriptionController.text; + final tags = (_tagsController.getTags ?? const []) + .map((t) => t.toString()) + .toList(); + + try { + if (_isScheduled) { + if (_scheduledDateTimeIso == null) return null; + await ref.read(createRoomProvider.notifier).createScheduledRoom( + name: name, + description: description, + tags: tags, + scheduledDateTime: _scheduledDateTimeIso!, + ); + _clearForm(); + return null; + } else { + final room = await ref.read(createRoomProvider.notifier).createLiveRoom( + name: name, + description: description, + tags: tags, + ); + _clearForm(); + return room; + } + } catch (e) { + log('createRoom failed: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create room: $e')), + ); + } + return null; + } + } + + bool get isScheduled => _isScheduled; + + @override + Widget build(BuildContext context) { + final isLoading = ref.watch(createRoomProvider); + + final kTextFieldDecoration = BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.secondary.withValues(alpha: 0.8), + const Color.fromARGB(255, 139, 134, 134), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + const BoxShadow( + color: Colors.black12, + offset: Offset(0, 3), + blurRadius: 6, + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.3), + offset: const Offset(-2, -2), + blurRadius: 3, + spreadRadius: 1, + ), + ], + ); + + return Stack( + children: [ + GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_25), + child: Form( + key: _formKey, + child: Column( + children: [ + SizedBox(height: UiSizes.height_24_6), + Text( + AppLocalizations.of(context)!.createNewRoom, + style: TextStyle( + fontSize: UiSizes.size_24 * 1.4, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + SizedBox(height: UiSizes.height_24_6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _ModeChip( + label: AppLocalizations.of(context)!.live, + active: !_isScheduled, + onTap: () => + setState(() => _isScheduled = false), + ), + _ModeChip( + label: AppLocalizations.of(context)!.scheduled, + active: _isScheduled, + onTap: () => + setState(() => _isScheduled = true), + ), + ], + ), + SizedBox(height: UiSizes.height_24_6), + if (_isScheduled) ...[ + SizedBox( + height: UiSizes.height_66, + child: TextFormField( + style: TextStyle(fontSize: UiSizes.size_14), + validator: (value) => value!.isNotEmpty + ? null + : AppLocalizations.of( + context, + )!.pleaseEnterScheduledDateTime, + readOnly: true, + controller: _dateTimeController, + decoration: InputDecoration( + icon: Icon( + Icons.calendar_month, + size: UiSizes.size_23, + ), + labelText: AppLocalizations.of( + context, + )!.scheduleDateTimeLabel, + labelStyle: TextStyle(fontSize: UiSizes.size_14), + suffix: GestureDetector( + onTap: _chooseDateTime, + child: const Icon(Icons.date_range), + ), + ), + ), + ), + SizedBox(height: UiSizes.height_33), + ], + Container( + decoration: kTextFieldDecoration, + child: TextFormField( + controller: _nameController, + style: TextStyle(fontSize: UiSizes.size_25), + cursorColor: Theme.of(context).colorScheme.primary, + maxLength: 30, + minLines: 1, + maxLines: 13, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.giveGreatName, + prefixIcon: Icon( + Icons.edit, + color: Theme.of(context).colorScheme.primary, + ), + filled: false, + border: InputBorder.none, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + SizedBox(height: UiSizes.height_33), + Container( + decoration: kTextFieldDecoration, + child: TextFieldTags( + textfieldTagsController: _tagsController, + initialTags: const ['sample-tag'], + textSeparators: const [' ', ','], + letterCase: LetterCase.normal, + validator: _validateTag, + inputFieldBuilder: (context, values) { + return TextField( + maxLength: 50, + maxLines: 1, + style: TextStyle(fontSize: UiSizes.size_20), + controller: values.textEditingController, + focusNode: values.focusNode, + decoration: InputDecoration( + hintText: values.tags.isNotEmpty + ? null + : AppLocalizations.of(context)!.enterTags, + filled: false, + border: InputBorder.none, + contentPadding: const EdgeInsets.all(16), + errorText: values.error, + prefixIconConstraints: BoxConstraints( + maxWidth: UiSizes.width_304, + ), + prefixIcon: values.tags.isNotEmpty + ? SingleChildScrollView( + controller: values.tagScrollController, + scrollDirection: Axis.horizontal, + child: Row( + children: values.tags.map((tag) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular( + UiSizes.size_20, + ), + ), + color: Colors.black54, + ), + margin: EdgeInsets.symmetric( + horizontal: UiSizes.width_5, + ), + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_10, + vertical: UiSizes.height_5, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Text( + '#$tag', + style: TextStyle( + color: Colors.white, + fontSize: UiSizes.size_18, + ), + ), + SizedBox( + width: UiSizes.width_4, + ), + InkWell( + child: Icon( + Icons.cancel, + size: UiSizes.size_18, + color: Colors.red + .withValues(alpha: 0.7), + ), + onTap: () => + values.onTagRemoved(tag), + ), + ], + ), + ); + }).toList(), + ), + ) + : null, + ), + onChanged: values.onTagChanged, + onSubmitted: values.onTagSubmitted, + ); + }, + ), + ), + SizedBox(height: UiSizes.height_33), + Container( + decoration: kTextFieldDecoration, + child: TextFormField( + controller: _descriptionController, + style: TextStyle(fontSize: UiSizes.size_20), + cursorColor: Theme.of(context).colorScheme.primary, + maxLines: 10, + maxLength: 500, + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.roomDescriptionOptional, + filled: false, + border: InputBorder.none, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + ], + ), + ), + ), + ), + ), + if (isLoading) + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Center( + child: LoadingAnimationWidget.fourRotatingDots( + color: Theme.of(context).colorScheme.primary, + size: MediaQuery.of(context).devicePixelRatio * 50, + ), + ), + ), + ], + ); + } +} + +class _ModeChip extends StatelessWidget { + const _ModeChip({ + required this.label, + required this.active, + required this.onTap, + }); + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final lightTheme = Theme.of(context).brightness == Brightness.light; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_10, + horizontal: UiSizes.width_20, + ), + decoration: BoxDecoration( + color: active + ? scheme.primary + : scheme.secondary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + label, + style: TextStyle( + fontSize: UiSizes.size_14, + color: lightTheme + ? (active ? Colors.white : Colors.black) + : Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/features/rooms/view/pages/room_chat_page.dart b/lib/features/rooms/view/pages/room_chat_page.dart new file mode 100644 index 00000000..81376673 --- /dev/null +++ b/lib/features/rooms/view/pages/room_chat_page.dart @@ -0,0 +1,717 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/features/rooms/model/room_message.dart'; +import 'package:resonate/features/rooms/viewmodel/room_chat_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/extensions/datetime_extension.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class RoomChatPage extends ConsumerStatefulWidget { + const RoomChatPage({ + super.key, + required this.roomId, + required this.roomName, + required this.isUpcoming, + }); + + final String roomId; + final String roomName; + final bool isUpcoming; + + @override + ConsumerState createState() => _RoomChatPageState(); +} + +class _RoomChatPageState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + static const double _itemHeight = 80; + int _previousCount = 0; + + void _scrollToMessage(int index) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + index * _itemHeight, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final providerKey = roomChatProvider( + widget.roomId, + widget.roomName, + widget.isUpcoming, + ); + final asyncState = ref.watch(providerKey); + final messages = asyncState.value?.messages ?? const []; + if (messages.length != _previousCount) { + _previousCount = messages.length; + _scrollToBottom(); + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text('Room Chat'), + centerTitle: true, + actions: [ + IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), + ], + ), + body: Column( + children: [ + Expanded( + child: asyncState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (state) => ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16.0), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final message = state.messages[index]; + final canEdit = requireCurrentAuthUser.uid == message.creatorId && + !message.isDeleted && + !message.isEdited; + final canDelete = requireCurrentAuthUser.uid == message.creatorId && + !message.isDeleted; + return ChatMessageItem( + message: message, + onTapReply: _scrollToMessage, + onEditMessage: (newContent) async { + await ref + .read(providerKey.notifier) + .editMessage( + messageId: message.messageId, + roomName: widget.roomName, + isUpcoming: widget.isUpcoming, + newContent: newContent, + ); + }, + onDeleteMessage: (id) async { + try { + await ref.read(providerKey.notifier).deleteMessage(id); + if (!context.mounted) return; + customSnackbar( + AppLocalizations.of(context)!.success, + AppLocalizations.of(context)!.delete, + LogType.success, + ); + } catch (_) { + if (!context.mounted) return; + customSnackbar( + AppLocalizations.of(context)!.error, + AppLocalizations.of( + context, + )!.failedToDeleteMessage, + LogType.error, + ); + } + }, + replytoMessage: (m) => + ref.read(providerKey.notifier).setReplyingTo(m), + onRetry: () async { + final ok = await ref.read(providerKey.notifier).retrySend( + messageId: message.messageId, + roomName: widget.roomName, + isUpcoming: widget.isUpcoming, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to resend')), + ); + } + }, + canEdit: canEdit, + canDelete: canDelete, + ); + }, + ), + ), + ), + ChatInputField( + roomId: widget.roomId, + roomName: widget.roomName, + isUpcoming: widget.isUpcoming, + ), + ], + ), + ); + } +} + +class ChatMessageItem extends StatefulWidget { + final RoomMessage message; + final void Function(int) onTapReply; + final void Function(String) onEditMessage; + final void Function(RoomMessage) replytoMessage; + final void Function(String) onDeleteMessage; + final VoidCallback? onRetry; + final bool canDelete; + final bool canEdit; + + const ChatMessageItem({ + super.key, + required this.message, + required this.onTapReply, + required this.onEditMessage, + required this.replytoMessage, + required this.canEdit, + required this.onDeleteMessage, + required this.canDelete, + this.onRetry, + }); + + @override + State createState() => _ChatMessageItemState(); +} + +class _ChatMessageItemState extends State { + bool isEditing = false; + late TextEditingController _editingController; + double _dragOffset = 0.0; + + @override + void initState() { + super.initState(); + _editingController = TextEditingController(text: widget.message.content); + } + + @override + void dispose() { + _editingController.dispose(); + super.dispose(); + } + + void _startEditing() { + if (widget.message.isDeleted) return; + setState(() => isEditing = true); + _editingController.selection = TextSelection.fromPosition( + TextPosition(offset: _editingController.text.length), + ); + } + + void _saveEdit() { + widget.onEditMessage(_editingController.text); + setState(() => isEditing = false); + } + + void _cancelEdit() { + setState(() => isEditing = false); + _editingController.text = widget.message.content; + } + + void _showMessageContextMenu(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Wrap( + children: [ + if (widget.canDelete) + ListTile( + leading: Icon( + Icons.delete, + color: Theme.of(ctx).colorScheme.error, + ), + title: Text(AppLocalizations.of(ctx)!.delete), + onTap: () { + Navigator.pop(ctx); + _confirmDelete(context); + }, + ), + ListTile( + leading: const Icon(Icons.close), + title: Text(AppLocalizations.of(ctx)!.cancel), + onTap: () => Navigator.pop(ctx), + ), + ], + ), + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppLocalizations.of(ctx)!.deleteMessageTitle), + content: Text(AppLocalizations.of(ctx)!.deleteMessageContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(AppLocalizations.of(ctx)!.cancel), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + widget.onDeleteMessage(widget.message.messageId); + }, + child: Text(AppLocalizations.of(ctx)!.delete), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + GestureDetector( + onLongPress: () => _showMessageContextMenu(context), + onHorizontalDragUpdate: (details) { + if (_dragOffset + details.delta.dx > 0.0 && + _dragOffset + details.delta.dx < 100) { + _dragOffset += details.delta.dx; + } else if (_dragOffset + details.delta.dx > 100) { + _dragOffset = 100; + } else if (_dragOffset + details.delta.dx < 0) { + _dragOffset = 0.0; + } + setState(() {}); + }, + onHorizontalDragEnd: (_) { + if (_dragOffset > 70) { + widget.replytoMessage(widget.message); + } + setState(() => _dragOffset = 0.0); + }, + onDoubleTap: widget.canEdit ? _startEditing : null, + child: Transform.translate( + offset: Offset(_dragOffset, 0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: widget.message.isDeleted + ? Theme.of(context).colorScheme.secondaryContainer + : Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage( + widget.message.creatorImgUrl, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _capitalize(widget.message.creatorName), + style: TextStyle( + fontWeight: FontWeight.bold, + color: widget.message.isDeleted + ? Theme.of( + context, + ).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 5), + if (widget.message.replyTo != null) + GestureDetector( + onTap: () => widget.onTapReply( + widget.message.replyTo!.index, + ), + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 5), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '@${widget.message.replyTo!.creatorUsername}', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + Text( + widget.message.replyTo!.content, + maxLines: 1, + style: const TextStyle( + color: Colors.black, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + if (isEditing) + Focus( + onKeyEvent: (node, event) { + if (event.logicalKey == + LogicalKeyboardKey.escape) { + _cancelEdit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _editingController, + autofocus: true, + onSubmitted: (_) => _saveEdit(), + onTapOutside: (_) => _cancelEdit(), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + ), + ), + ) + else if (widget.message.isDeleted) + Text( + AppLocalizations.of( + context, + )!.thisMessageWasDeleted, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ) + else + Row( + children: [ + Expanded( + child: Text( + widget.message.content, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.secondary, + ), + ), + ), + if (widget.message.isEdited) + const Text( + ' (edited)', + style: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + Text( + widget.message.creationDateTime + .formatDateTime(context) + .toString(), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const SizedBox(width: 6), + _StatusIndicator( + status: widget.message.status, + onRetry: widget.onRetry, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + Positioned( + bottom: constraints.minHeight / 2, + top: constraints.minHeight / 2, + child: Transform.translate( + offset: Offset(_dragOffset - 70, 0), + child: Opacity( + opacity: (_dragOffset / 100).clamp(0.0, 1.0), + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Icon( + Icons.reply, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class ChatInputField extends ConsumerStatefulWidget { + const ChatInputField({ + super.key, + required this.roomId, + required this.roomName, + required this.isUpcoming, + }); + + final String roomId; + final String roomName; + final bool isUpcoming; + + @override + ConsumerState createState() => _ChatInputFieldState(); +} + +class _ChatInputFieldState extends ConsumerState { + final TextEditingController _messageController = TextEditingController(); + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + Future _send() async { + if (_messageController.text.isEmpty) return; + final content = _messageController.text; + _messageController.clear(); + final providerKey = + roomChatProvider(widget.roomId, widget.roomName, widget.isUpcoming); + final ok = await ref.read(providerKey.notifier).sendMessage( + roomId: widget.roomId, + roomName: widget.roomName, + isUpcoming: widget.isUpcoming, + content: content, + ); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to send. Tap the message to retry.'), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final providerKey = + roomChatProvider(widget.roomId, widget.roomName, widget.isUpcoming); + final replyingTo = ref.watch(providerKey).value?.replyingTo; + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (replyingTo != null) + Row( + children: [ + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 5), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '@${replyingTo.creatorUsername}', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + Text( + replyingTo.content, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => + ref.read(providerKey.notifier).clearReplyingTo(), + ), + ], + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + onSubmitted: (_) => _send(), + decoration: InputDecoration( + hintText: 'Say Something', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 5, + ), + ), + ), + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(Icons.send), + onPressed: _send, + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +String _capitalize(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; + +Future openLiveRoomChatSheet(BuildContext context, AppwriteRoom room) { + return showModalBottomSheet( + context: context, + builder: (_) => RoomChatPage( + roomId: room.id, + roomName: room.name, + isUpcoming: false, + ), + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(50)), + ), + isScrollControlled: true, + enableDrag: false, + isDismissible: false, + ); +} + +Future openUpcomingChatSheet( + BuildContext context, + AppwriteUpcomingRoom room, +) { + return showModalBottomSheet( + context: context, + builder: (_) => RoomChatPage( + roomId: room.id, + roomName: room.name, + isUpcoming: true, + ), + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(50)), + ), + isScrollControlled: true, + enableDrag: false, + isDismissible: false, + ); +} + +class _StatusIndicator extends StatelessWidget { + const _StatusIndicator({required this.status, required this.onRetry}); + + final RoomMessageStatus status; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + switch (status) { + case RoomMessageStatus.sent: + return const SizedBox.shrink(); + case RoomMessageStatus.pending: + return const Icon(Icons.access_time, size: 12, color: Colors.grey); + case RoomMessageStatus.failed: + return GestureDetector( + onTap: onRetry, + child: Tooltip( + message: 'Tap to retry', + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.error_outline, size: 14, color: Colors.red), + SizedBox(width: 4), + Text( + 'Retry', + style: TextStyle( + color: Colors.red, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + } +} diff --git a/lib/features/rooms/view/pages/room_page.dart b/lib/features/rooms/view/pages/room_page.dart new file mode 100644 index 00000000..7ac76361 --- /dev/null +++ b/lib/features/rooms/view/pages/room_page.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/single_room_state.dart'; +import 'package:resonate/features/rooms/view/pages/room_chat_page.dart'; +import 'package:resonate/features/rooms/view/widgets/audio_selector_dialog.dart'; +import 'package:resonate/features/rooms/view/widgets/participant_block.dart'; +import 'package:resonate/features/rooms/view/widgets/room_app_bar.dart'; +import 'package:resonate/features/rooms/view/widgets/room_header.dart'; +import 'package:resonate/features/rooms/viewmodel/single_room_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class RoomPage extends ConsumerWidget { + const RoomPage({super.key, required this.room}); + + final AppwriteRoom room; + + String _formatTags() { + if (room.tags.isEmpty) return ''; + return room.tags.join(' · '); + } + + Future _confirmLeaveOrDelete( + BuildContext context, + String actionLabel, + ) async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppLocalizations.of(ctx)!.areYouSure), + content: Text(AppLocalizations.of(ctx)!.toRoomAction(actionLabel)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(AppLocalizations.of(ctx)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(AppLocalizations.of(ctx)!.confirm), + ), + ], + ), + ); + return result ?? false; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncState = ref.watch(singleRoomProvider(room)); + + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RoomAppBar(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: RoomHeader( + roomName: room.name, + roomDescription: room.description, + roomTags: _formatTags(), + ), + ), + SizedBox(height: UiSizes.height_7), + Expanded( + child: asyncState.when( + loading: () => Center( + child: LoadingAnimationWidget.threeRotatingDots( + color: Theme.of(context).colorScheme.primary, + size: MediaQuery.of(context).devicePixelRatio * 20, + ), + ), + error: (e, _) => _RoomBody( + room: room, + state: null, + onConfirm: _confirmLeaveOrDelete, + ), + data: (state) => _RoomBody( + room: room, + state: state, + onConfirm: _confirmLeaveOrDelete, + ), + ), + ), + ], + ), + ); + } +} + +class _RoomBody extends StatelessWidget { + const _RoomBody({ + required this.room, + required this.state, + required this.onConfirm, + }); + + final AppwriteRoom room; + final SingleRoomState? state; + final Future Function(BuildContext, String) onConfirm; + + @override + Widget build(BuildContext context) { + final participants = state?.participants ?? const []; + + return Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of( + context, + ).colorScheme.onSecondary.withValues(alpha: 0.15), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.participants, + style: TextStyle( + fontSize: UiSizes.size_18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Expanded( + child: participants.isEmpty + ? _NoParticipantsView() + : GridView.builder( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: UiSizes.width_20, + mainAxisSpacing: UiSizes.height_5, + childAspectRatio: 2.5 / 3, + ), + itemCount: participants.length, + itemBuilder: (_, index) => ParticipantBlock( + room: room, + participant: participants[index], + ), + ), + ), + ], + ), + ), + _Footer(room: room, onConfirm: onConfirm), + ], + ); + } +} + +class _NoParticipantsView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: UiSizes.size_65, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + ), + SizedBox(height: UiSizes.height_16), + Text( + 'No participants yet', + style: TextStyle( + fontSize: UiSizes.size_16, + fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } +} + +class _Footer extends ConsumerWidget { + const _Footer({required this.room, required this.onConfirm}); + + final AppwriteRoom room; + final Future Function(BuildContext, String) onConfirm; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(singleRoomProvider(room)).value; + if (state == null) return const SizedBox.shrink(); + + return Align( + alignment: Alignment.bottomCenter, + child: Container( + height: MediaQuery.of(context).size.height * 0.07, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadiusDirectional.circular(24), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Leave / delete + ElevatedButton( + onPressed: () async { + final actionLabel = room.isUserAdmin + ? AppLocalizations.of(context)!.delete + : AppLocalizations.of(context)!.leave; + final navigator = Navigator.of(context); + final confirmed = await onConfirm(context, actionLabel); + if (!confirmed) return; + + final notifier = + ref.read(singleRoomProvider(room).notifier); + if (room.isUserAdmin) { + await notifier.deleteRoom(room); + } else { + await notifier.leaveRoom(room); + } + if (navigator.canPop()) navigator.pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Icon(Icons.call_end, size: 24), + ), + // Mic + FloatingActionButton( + onPressed: state.me.isSpeaker + ? () { + final notifier = + ref.read(singleRoomProvider(room).notifier); + if (state.me.isMicOn) { + notifier.turnOffMic(room); + } else { + notifier.turnOnMic(room); + } + } + : null, + backgroundColor: !state.me.isSpeaker + ? Colors.grey + : (state.me.isMicOn ? Colors.lightGreen : Colors.redAccent), + child: Icon( + state.me.isMicOn ? Icons.mic : Icons.mic_off, + color: Colors.black, + ), + ), + // Raise hand + FloatingActionButton( + onPressed: () { + final notifier = ref.read(singleRoomProvider(room).notifier); + if (state.me.hasRequestedToBeSpeaker) { + notifier.unRaiseHand(room); + } else { + notifier.raiseHand(room); + } + }, + backgroundColor: state.me.hasRequestedToBeSpeaker + ? Theme.of(context).colorScheme.primary + : Theme.of(context).brightness == Brightness.light + ? Colors.white + : Colors.black54, + child: Icon( + state.me.hasRequestedToBeSpeaker + ? Icons.back_hand + : Icons.back_hand_outlined, + color: state.me.hasRequestedToBeSpeaker + ? Colors.black + : Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white54, + ), + ), + // Audio settings + FloatingActionButton( + onPressed: () => showAudioDeviceSelector(context), + backgroundColor: Theme.of(context).colorScheme.onSecondary, + child: const Icon(Icons.volume_up), + ), + // Chat + FloatingActionButton( + onPressed: () => openLiveRoomChatSheet(context, room), + backgroundColor: Colors.redAccent, + child: const Icon(Icons.chat, color: Colors.black), + ), + ], + ), + ), + ); + } +} + +Future openRoomSheet(BuildContext context, AppwriteRoom room) { + return showModalBottomSheet( + context: context, + builder: (_) => RoomPage(room: room), + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(50)), + ), + isScrollControlled: true, + enableDrag: true, + isDismissible: false, + ); +} diff --git a/lib/features/rooms/view/widgets/audio_selector_dialog.dart b/lib/features/rooms/view/widgets/audio_selector_dialog.dart new file mode 100644 index 00000000..ef3f83d2 --- /dev/null +++ b/lib/features/rooms/view/widgets/audio_selector_dialog.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/rooms/viewmodel/audio_device_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/models/audio_device.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class AudioDeviceSelectorDialog extends ConsumerWidget { + const AudioDeviceSelectorDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncState = ref.watch(audioDeviceProvider); + final service = ref.read(audioDeviceServiceProvider); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiSizes.width_20), + ), + ), + padding: EdgeInsets.only( + left: UiSizes.width_20, + right: UiSizes.width_20, + top: UiSizes.height_8, + bottom: MediaQuery.of(context).viewInsets.bottom + UiSizes.height_20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: UiSizes.width_40, + height: UiSizes.height_4, + margin: EdgeInsets.only(bottom: UiSizes.height_20), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(UiSizes.width_2), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.audioOutput, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: UiSizes.height_4), + Text( + AppLocalizations.of(context)!.selectPreferredSpeaker, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + SizedBox(height: UiSizes.height_8), + const Divider(), + SizedBox(height: UiSizes.height_12), + Flexible( + child: SingleChildScrollView( + child: asyncState.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ), + error: (e, _) => Text('$e'), + data: (state) => _DeviceList( + devices: state.devices, + selectedDevice: state.selected, + displayNameFor: service.displayNameFor, + onSelect: (device) => + ref.read(audioDeviceProvider.notifier).selectOutput(device), + ), + ), + ), + ), + SizedBox(height: UiSizes.height_12), + const Divider(), + SizedBox(height: UiSizes.height_8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => ref.invalidate(audioDeviceProvider), + icon: Icon(Icons.refresh, size: UiSizes.size_18), + label: Text(AppLocalizations.of(context)!.refresh), + ), + SizedBox(width: UiSizes.width_8), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + child: Text(AppLocalizations.of(context)!.done), + ), + ], + ), + ], + ), + ); + } +} + +class _DeviceList extends StatelessWidget { + const _DeviceList({ + required this.devices, + required this.selectedDevice, + required this.displayNameFor, + required this.onSelect, + }); + + final List devices; + final AudioDevice? selectedDevice; + final String Function(AudioDevice) displayNameFor; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (devices.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), + child: Text( + AppLocalizations.of(context)!.noAudioOutputDevices, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + fontStyle: FontStyle.italic, + ), + ), + ); + } + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: devices.length, + itemBuilder: (context, index) { + final device = devices[index]; + final isSelected = selectedDevice?.deviceId == device.deviceId; + return _DeviceItem( + device: device, + displayName: displayNameFor(device), + iconName: device.deviceType.iconName, + isSelected: isSelected, + onTap: () => onSelect(device), + ); + }, + ); + } +} + +class _DeviceItem extends StatelessWidget { + const _DeviceItem({ + required this.device, + required this.displayName, + required this.iconName, + required this.isSelected, + required this.onTap, + }); + + final AudioDevice device; + final String displayName; + final String iconName; + final bool isSelected; + final VoidCallback onTap; + + IconData _icon() { + switch (iconName) { + case 'bluetooth_audio': + return Icons.bluetooth_audio; + case 'phone': + return Icons.phone; + case 'headset': + return Icons.headset; + case 'speaker': + return Icons.speaker; + default: + return Icons.volume_up; + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.only(bottom: UiSizes.height_8), + elevation: isSelected ? 2 : 0, + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiSizes.width_10), + side: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: isSelected ? 2 : 1, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(UiSizes.width_10), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_10, + vertical: UiSizes.height_12, + ), + child: Row( + children: [ + Icon( + _icon(), + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + size: UiSizes.size_24, + ), + SizedBox(width: UiSizes.width_10), + Expanded( + child: Text( + displayName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + size: UiSizes.size_20, + ), + ], + ), + ), + ), + ); + } +} + +Future showAudioDeviceSelector(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => const AudioDeviceSelectorDialog(), + ); +} diff --git a/lib/views/widgets/live_room_tile.dart b/lib/features/rooms/view/widgets/live_room_tile.dart similarity index 69% rename from lib/views/widgets/live_room_tile.dart rename to lib/features/rooms/view/widgets/live_room_tile.dart index 3bd00b06..d280cd9e 100644 --- a/lib/views/widgets/live_room_tile.dart +++ b/lib/features/rooms/view/widgets/live_room_tile.dart @@ -1,20 +1,57 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/view/pages/room_page.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/models/appwrite_room.dart'; import 'package:share_plus/share_plus.dart'; -class CustomLiveRoomTile extends StatelessWidget { +class CustomLiveRoomTile extends ConsumerWidget { + const CustomLiveRoomTile({super.key, required this.appwriteRoom}); + final AppwriteRoom appwriteRoom; - CustomLiveRoomTile({super.key, required this.appwriteRoom}); - final RoomsController roomsController = Get.put(RoomsController()); + Future _join(BuildContext context, WidgetRef ref) async { + final rootNavigator = Navigator.of(context, rootNavigator: true); + final dialogFuture = showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (ctx) => Center( + child: LoadingAnimationWidget.threeRotatingDots( + color: Theme.of(ctx).primaryColor, + size: MediaQuery.of(ctx).devicePixelRatio * 20, + ), + ), + ); + + void closeDialog() { + if (rootNavigator.canPop()) rootNavigator.pop(); + } + + try { + final joined = + await ref.read(roomsProvider.notifier).joinRoom(appwriteRoom); + closeDialog(); + if (context.mounted) { + await openRoomSheet(context, joined); + } + } catch (_) { + closeDialog(); + await ref.read(roomsProvider.notifier).refresh(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.error)), + ); + } + } + await dialogFuture; // ensure dialog is fully torn down before returning + } @override - Widget build(BuildContext context) { - // Display the first three member avatars or as many as available. - List memberAvatars = appwriteRoom.memberAvatarUrls.length > 3 + Widget build(BuildContext context, WidgetRef ref) { + final memberAvatars = appwriteRoom.memberAvatarUrls.length > 3 ? appwriteRoom.memberAvatarUrls.sublist(0, 3) : appwriteRoom.memberAvatarUrls; @@ -29,19 +66,20 @@ class CustomLiveRoomTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Room Name and Share Button Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - appwriteRoom.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, + Expanded( + child: Text( + appwriteRoom.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), ), ), IconButton( @@ -63,14 +101,13 @@ class CustomLiveRoomTile extends StatelessWidget { ), ], ), - // Room Tags Wrap( spacing: 8.0, runSpacing: 4.0, children: appwriteRoom.tags .map( (tag) => Text( - "#$tag", + '#$tag', style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: 14, @@ -79,10 +116,7 @@ class CustomLiveRoomTile extends StatelessWidget { ) .toList(), ), - const SizedBox(height: 8), - - // Room Description Text( appwriteRoom.description, maxLines: 3, @@ -94,10 +128,7 @@ class CustomLiveRoomTile extends StatelessWidget { fontSize: 14, ), ), - const SizedBox(height: 5), - - // Total participants count Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -146,12 +177,7 @@ class CustomLiveRoomTile extends StatelessWidget { ], ), ElevatedButton( - onPressed: () { - roomsController.joinRoom( - room: appwriteRoom, - context: context, - ); - }, + onPressed: () => _join(context, ref), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), @@ -162,8 +188,6 @@ class CustomLiveRoomTile extends StatelessWidget { ), ], ), - - // Join button ], ), ), @@ -193,7 +217,7 @@ class CustomCircleAvatar extends StatelessWidget { image: NetworkImage(userImage), fit: BoxFit.cover, ), - borderRadius: BorderRadius.circular(20), // Circular avatar + borderRadius: BorderRadius.circular(20), ), ); } diff --git a/lib/views/screens/no_room_screen.dart b/lib/features/rooms/view/widgets/no_room_view.dart similarity index 60% rename from lib/views/screens/no_room_screen.dart rename to lib/features/rooms/view/widgets/no_room_view.dart index fb2310dc..c3d96520 100644 --- a/lib/views/screens/no_room_screen.dart +++ b/lib/features/rooms/view/widgets/no_room_view.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:resonate/utils/app_images.dart'; -import 'package:resonate/views/widgets/live_room_tile.dart'; import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/app_images.dart'; -class NoRoomScreen extends StatelessWidget { +class NoRoomView extends StatelessWidget { final bool isRoom; - const NoRoomScreen({super.key, required this.isRoom}); + const NoRoomView({super.key, required this.isRoom}); @override Widget build(BuildContext context) { @@ -48,30 +47,3 @@ class NoRoomScreen extends StatelessWidget { ); } } - -class GeneralAppBar extends StatelessWidget { - const GeneralAppBar({super.key}); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - AppLocalizations.of(context)!.live.toUpperCase(), - style: TextStyle(color: Theme.of(context).colorScheme.primary), - ), - const SizedBox(width: 25), - Text( - AppLocalizations.of(context)!.upcoming.toUpperCase(), - style: TextStyle(color: Color.fromRGBO(118, 124, 134, 1)), - ), - const Spacer(), - const CustomCircleAvatar( - userImage: - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ), - ], - ); - } -} diff --git a/lib/features/rooms/view/widgets/participant_block.dart b/lib/features/rooms/view/widgets/participant_block.dart new file mode 100644 index 00000000..7869fe72 --- /dev/null +++ b/lib/features/rooms/view/widgets/participant_block.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:focused_menu/focused_menu.dart'; +import 'package:focused_menu/modals.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/participant.dart'; +import 'package:resonate/features/rooms/viewmodel/single_room_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/report_widget.dart'; + +class _FocusedMenuItemData { + _FocusedMenuItemData(this.text, this.action); + final String text; + final VoidCallback action; +} + +class ParticipantBlock extends ConsumerWidget { + const ParticipantBlock({ + super.key, + required this.room, + required this.participant, + }); + + final AppwriteRoom room; + final Participant participant; + + String _userRole(BuildContext context) { + if (participant.isAdmin) return AppLocalizations.of(context)!.admin; + if (participant.isModerator) return AppLocalizations.of(context)!.moderator; + if (participant.isSpeaker) return AppLocalizations.of(context)!.speaker; + return AppLocalizations.of(context)!.listener; + } + + List _makeItems( + List<_FocusedMenuItemData> items, + Brightness brightness, + ) { + return items + .map( + (item) => FocusedMenuItem( + title: Text( + item.text, + style: TextStyle(fontSize: UiSizes.size_14), + ), + trailingIcon: Icon( + Icons.remove_circle_outline, + color: Colors.red, + size: UiSizes.size_18, + ), + onPressed: item.action, + backgroundColor: brightness == Brightness.light + ? Colors.white + : Colors.black, + ), + ) + .toList(); + } + + Future _reportAndMaybeKick( + BuildContext context, + WidgetRef ref, + ) async { + final didSubmit = await showDialog( + context: context, + builder: (_) => ReportWidget( + participantName: participant.name, + participantId: participant.uid, + ), + ); + if (didSubmit == true) { + await ref + .read(singleRoomProvider(room).notifier) + .reportAndKick(room, participant); + } + } + + List _menuItems( + BuildContext context, + WidgetRef ref, + Participant me, + Brightness brightness, + ) { + if ((!me.isAdmin && !me.isModerator) || participant.isAdmin) return []; + final notifier = ref.read(singleRoomProvider(room).notifier); + + if (me.isAdmin) { + if (participant.isModerator) { + return _makeItems([ + _FocusedMenuItemData( + AppLocalizations.of(context)!.removeModerator, + () => notifier.removeModerator(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.kickOut, + () => notifier.kickOutParticipant(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.reportParticipant, + () => _reportAndMaybeKick(context, ref), + ), + ], brightness); + } else { + return _makeItems([ + _FocusedMenuItemData( + AppLocalizations.of(context)!.addModerator, + () => notifier.makeModerator(room, participant), + ), + if (participant.hasRequestedToBeSpeaker) + _FocusedMenuItemData( + AppLocalizations.of(context)!.addSpeaker, + () => notifier.makeSpeaker(room, participant), + ), + if (participant.isSpeaker) + _FocusedMenuItemData( + AppLocalizations.of(context)!.makeListener, + () => notifier.makeListener(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.kickOut, + () => notifier.kickOutParticipant(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.reportParticipant, + () => _reportAndMaybeKick(context, ref), + ), + ], brightness); + } + } + + if (me.isModerator) { + if (participant.isModerator) return []; + return _makeItems([ + if (participant.hasRequestedToBeSpeaker) + _FocusedMenuItemData( + AppLocalizations.of(context)!.addSpeaker, + () => notifier.makeSpeaker(room, participant), + ), + if (participant.isSpeaker) + _FocusedMenuItemData( + AppLocalizations.of(context)!.makeListener, + () => notifier.makeListener(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.kickOut, + () => notifier.kickOutParticipant(room, participant), + ), + _FocusedMenuItemData( + AppLocalizations.of(context)!.reportParticipant, + () => _reportAndMaybeKick(context, ref), + ), + ], brightness); + } + + return []; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final brightness = Theme.of(context).brightness; + final me = ref.watch(singleRoomProvider(room)).value?.me; + if (me == null) return const SizedBox.shrink(); + + final canOpenMenu = (me.isAdmin || + (me.isModerator && !participant.isModerator)) && + !participant.isAdmin; + + return FocusedMenuHolder( + onPressed: () {}, + menuItemExtent: UiSizes.width_45, + menuWidth: UiSizes.width_200 * 1.05, + menuBoxDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(5.0), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: UiSizes.width_1, + ), + ), + duration: const Duration(milliseconds: 100), + animateMenuItems: true, + blurBackgroundColor: brightness == Brightness.light + ? Colors.white54 + : Colors.black54, + menuItems: _menuItems(context, ref, me, brightness), + openWithTap: canOpenMenu, + child: Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_2, + horizontal: UiSizes.width_2, + ), + alignment: Alignment.center, + child: Column( + children: [ + CircleAvatar( + radius: UiSizes.size_32, + backgroundColor: Theme.of(context).colorScheme.primary, + child: CircleAvatar( + backgroundImage: NetworkImage(participant.dpUrl), + radius: UiSizes.size_30, + child: participant.hasRequestedToBeSpeaker + ? Stack( + children: [ + Align( + alignment: Alignment.topRight, + child: Icon( + Icons.waving_hand_rounded, + color: Theme.of(context).colorScheme.primary, + size: UiSizes.size_20, + ), + ), + ], + ) + : null, + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (participant.isSpeaker) + Icon( + participant.isMicOn ? Icons.mic : Icons.mic_off, + color: participant.isMicOn + ? Colors.lightGreen + : Colors.red, + size: UiSizes.size_16, + ), + Text( + participant.name.split(' ').first, + style: TextStyle(fontSize: UiSizes.size_16), + ), + ], + ), + ), + Text( + _userRole(context), + style: TextStyle(color: Colors.grey, fontSize: UiSizes.size_14), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/widgets/room_app_bar.dart b/lib/features/rooms/view/widgets/room_app_bar.dart similarity index 50% rename from lib/views/widgets/room_app_bar.dart rename to lib/features/rooms/view/widgets/room_app_bar.dart index c154603b..0d891cc1 100644 --- a/lib/views/widgets/room_app_bar.dart +++ b/lib/features/rooms/view/widgets/room_app_bar.dart @@ -11,25 +11,10 @@ class RoomAppBar extends StatelessWidget { leading: IconButton( icon: const Icon( Icons.keyboard_arrow_down, - // color: Colors.black, size: 36, ), onPressed: () => Navigator.of(context).pop(), ), - // Enable this when room chats are implemented - // actions: [ - // Padding( - // padding: const EdgeInsets.only(right: 10.0), - // child: IconButton( - // icon: const Icon( - // FontAwesomeIcons.comments, - // ), - // onPressed: () { - // Get.to(const RoomChatScreen()); - // }, - // ), - // ) - // ], ); } } diff --git a/lib/views/widgets/room_header.dart b/lib/features/rooms/view/widgets/room_header.dart similarity index 82% rename from lib/views/widgets/room_header.dart rename to lib/features/rooms/view/widgets/room_header.dart index b4835d63..199ff069 100644 --- a/lib/views/widgets/room_header.dart +++ b/lib/features/rooms/view/widgets/room_header.dart @@ -11,21 +11,16 @@ class RoomHeader extends StatelessWidget { final String roomName; final String roomDescription; final String? roomTags; + @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - roomName, - style: TextStyle( - fontSize: UiSizes.size_20, - // color: Colors.black, - ), - ), + Text(roomName, style: TextStyle(fontSize: UiSizes.size_20)), SizedBox(height: UiSizes.height_8), Text( - roomTags ?? "", + roomTags ?? '', style: TextStyle( fontSize: UiSizes.size_15, fontWeight: FontWeight.w100, diff --git a/lib/views/widgets/search_rooms.dart b/lib/features/rooms/view/widgets/search_rooms.dart similarity index 100% rename from lib/views/widgets/search_rooms.dart rename to lib/features/rooms/view/widgets/search_rooms.dart diff --git a/lib/views/widgets/upcomming_room_tile.dart b/lib/features/rooms/view/widgets/upcoming_room_tile.dart similarity index 58% rename from lib/views/widgets/upcomming_room_tile.dart rename to lib/features/rooms/view/widgets/upcoming_room_tile.dart index b2b5da80..a7cef598 100644 --- a/lib/views/widgets/upcomming_room_tile.dart +++ b/lib/features/rooms/view/widgets/upcoming_room_tile.dart @@ -1,58 +1,55 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; -import 'package:resonate/models/appwrite_upcomming_room.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/features/rooms/view/pages/room_chat_page.dart'; +import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/enums/log_type.dart'; import 'package:resonate/utils/extensions/datetime_extension.dart'; -import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/views/widgets/snackbar.dart'; -class UpCommingListTile extends StatelessWidget { - UpCommingListTile({super.key, required this.appwriteUpcommingRoom}); - final AppwriteUpcommingRoom appwriteUpcommingRoom; - final UpcomingRoomsController upcomingRoomsController = Get.put( - UpcomingRoomsController(), - ); +class UpcomingListTile extends ConsumerWidget { + const UpcomingListTile({super.key, required this.appwriteUpcomingRoom}); - Future _showRemoveDialog(BuildContext context) async { + final AppwriteUpcomingRoom appwriteUpcomingRoom; + + Future _showRemoveDialog(BuildContext context, WidgetRef ref) async { return showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (dialogCtx) { return AlertDialog( - title: Text(AppLocalizations.of(context)!.removeRoom), - content: Text(AppLocalizations.of(context)!.removeRoomConfirmation), + title: Text(AppLocalizations.of(dialogCtx)!.removeRoom), + content: Text( + AppLocalizations.of(dialogCtx)!.removeRoomConfirmation, + ), actions: [ TextButton( - child: Text(AppLocalizations.of(context)!.cancel), - onPressed: () { - Navigator.of(context).pop(); - }, + child: Text(AppLocalizations.of(dialogCtx)!.cancel), + onPressed: () => Navigator.of(dialogCtx).pop(), ), TextButton( style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(dialogCtx).colorScheme.error, ), - child: Text(AppLocalizations.of(context)!.hide), + child: Text(AppLocalizations.of(dialogCtx)!.hide), onPressed: () async { - Navigator.of(context).pop(); + final l10n = AppLocalizations.of(dialogCtx)!; + final successTitle = l10n.success; + final successBody = l10n.roomRemovedSuccessfully; + final errorTitle = l10n.error; + final errorBody = l10n.failedToRemoveRoom; + + Navigator.of(dialogCtx).pop(); try { - await upcomingRoomsController.removeUpcomingRoom( - appwriteUpcommingRoom.id, - ); - customSnackbar( - AppLocalizations.of(Get.context!)!.success, - AppLocalizations.of(Get.context!)!.roomRemovedSuccessfully, - LogType.success, - ); - } catch (e) { - customSnackbar( - AppLocalizations.of(Get.context!)!.error, - AppLocalizations.of(Get.context!)!.failedToRemoveRoom, - LogType.error, - ); + await ref + .read(upcomingRoomsProvider.notifier) + .hideLocally(appwriteUpcomingRoom.id); + customSnackbar(successTitle, successBody, LogType.success); + } catch (_) { + customSnackbar(errorTitle, errorBody, LogType.error); } }, ), @@ -63,12 +60,11 @@ class UpCommingListTile extends StatelessWidget { } @override - Widget build(BuildContext context) { - // Display the first three subscribers or as many as available. - List subscriberAvatars = - appwriteUpcommingRoom.subscribersAvatarUrls.length > 3 - ? appwriteUpcommingRoom.subscribersAvatarUrls.sublist(0, 3) - : appwriteUpcommingRoom.subscribersAvatarUrls; + Widget build(BuildContext context, WidgetRef ref) { + final subscriberAvatars = + appwriteUpcomingRoom.subscribersAvatarUrls.length > 3 + ? appwriteUpcomingRoom.subscribersAvatarUrls.sublist(0, 3) + : appwriteUpcomingRoom.subscribersAvatarUrls; return Container( padding: const EdgeInsets.all(12), @@ -84,7 +80,7 @@ class UpCommingListTile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - appwriteUpcommingRoom.scheduledDateTime.dateToLocalFormatted( + appwriteUpcomingRoom.scheduledDateTime.dateToLocalFormatted( const Locale('en'), ), style: TextStyle( @@ -97,7 +93,7 @@ class UpCommingListTile extends StatelessWidget { ), const SizedBox(height: 5), Text( - appwriteUpcommingRoom.name, + appwriteUpcomingRoom.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), ), const SizedBox(height: 8), @@ -105,20 +101,18 @@ class UpCommingListTile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: subscriberAvatars - .map((avatarUrl) => CustomCircleAvatar(userImage: avatarUrl)) + .map((avatarUrl) => _UpcomingAvatar(userImage: avatarUrl)) .toList() .withSpacing(7), ), - appwriteUpcommingRoom.tags != [] - ? const SizedBox(height: 8) - : const SizedBox(), + if (appwriteUpcomingRoom.tags.isNotEmpty) const SizedBox(height: 8), Wrap( spacing: 8.0, runSpacing: 4.0, - children: appwriteUpcommingRoom.tags + children: appwriteUpcomingRoom.tags .map( (tag) => Text( - "#$tag", + '#$tag', style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: 14, @@ -129,7 +123,7 @@ class UpCommingListTile extends StatelessWidget { ), const SizedBox(height: 8), Text( - appwriteUpcommingRoom.description, + appwriteUpcomingRoom.description, maxLines: 2, overflow: TextOverflow.fade, textAlign: TextAlign.start, @@ -139,7 +133,7 @@ class UpCommingListTile extends StatelessWidget { ), ), const SizedBox(height: 10), - if (appwriteUpcommingRoom.userIsCreator) + if (appwriteUpcomingRoom.userIsCreator) Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -148,11 +142,10 @@ class UpCommingListTile extends StatelessWidget { width: UiSizes.width_56, child: FloatingActionButton( backgroundColor: const Color.fromARGB(255, 234, 93, 83), - onPressed: () { - upcomingRoomsController.openUpcomingChatSheet( - appwriteUpcommingRoom, - ); - }, + onPressed: () => openUpcomingChatSheet( + context, + appwriteUpcomingRoom, + ), child: Icon( Icons.chat, color: Colors.white, @@ -160,13 +153,12 @@ class UpCommingListTile extends StatelessWidget { ), ), ), - Spacer(), + const Spacer(), ElevatedButton( onPressed: () async { - await upcomingRoomsController.deleteUpcomingRoom( - appwriteUpcommingRoom.id, - ); - await upcomingRoomsController.getUpcomingRooms(); + await ref + .read(upcomingRoomsProvider.notifier) + .deleteUpcoming(appwriteUpcomingRoom.id); }, style: ElevatedButton.styleFrom( backgroundColor: const Color.fromARGB(255, 234, 93, 83), @@ -176,25 +168,25 @@ class UpCommingListTile extends StatelessWidget { ), child: Text( AppLocalizations.of(context)!.cancel, - style: TextStyle(color: Colors.white), + style: const TextStyle(color: Colors.white), ), ), const SizedBox(width: 10), ElevatedButton( - onPressed: appwriteUpcommingRoom.isTime + onPressed: appwriteUpcomingRoom.isTime ? () { - upcomingRoomsController.convertUpcomingRoomtoRoom( - appwriteUpcommingRoom.id, - appwriteUpcommingRoom.name, - appwriteUpcommingRoom.description, - appwriteUpcommingRoom.tags - .map((item) => item.toString()) - .toList(), - ); + ref + .read(upcomingRoomsProvider.notifier) + .convertToLive( + upcomingRoomId: appwriteUpcomingRoom.id, + name: appwriteUpcomingRoom.name, + description: appwriteUpcomingRoom.description, + tags: appwriteUpcomingRoom.tags, + ); } - : null, // Disable button if isTime is false + : null, style: ElevatedButton.styleFrom( - backgroundColor: appwriteUpcommingRoom.isTime + backgroundColor: appwriteUpcomingRoom.isTime ? Theme.of(context).colorScheme.primary : Colors.grey, shape: RoundedRectangleBorder( @@ -205,29 +197,28 @@ class UpCommingListTile extends StatelessWidget { ), ], ) - else // If not creator, show subscribe/unsubscribe button for subscribers + else Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - onPressed: () => _showRemoveDialog(context), + onPressed: () => _showRemoveDialog(context, ref), icon: Icon( Icons.delete_forever, color: Theme.of(context).colorScheme.error, ), tooltip: AppLocalizations.of(context)!.removeRoomFromList, ), - Spacer(), + const Spacer(), SizedBox( height: UiSizes.height_50, width: UiSizes.width_56, child: FloatingActionButton( backgroundColor: const Color.fromARGB(255, 234, 93, 83), - onPressed: () { - upcomingRoomsController.openUpcomingChatSheet( - appwriteUpcommingRoom, - ); - }, + onPressed: () => openUpcomingChatSheet( + context, + appwriteUpcomingRoom, + ), child: Icon( Icons.chat, color: Colors.white, @@ -239,18 +230,17 @@ class UpCommingListTile extends StatelessWidget { padding: const EdgeInsets.only(left: 8), child: ElevatedButton( onPressed: () { - if (appwriteUpcommingRoom.hasUserSubscribed) { - upcomingRoomsController.removeUserFromSubscriberList( - appwriteUpcommingRoom.id, - ); + final notifier = ref.read( + upcomingRoomsProvider.notifier, + ); + if (appwriteUpcomingRoom.hasUserSubscribed) { + notifier.unsubscribe(appwriteUpcomingRoom.id); } else { - upcomingRoomsController.addUserToSubscriberList( - appwriteUpcommingRoom.id, - ); + notifier.subscribe(appwriteUpcomingRoom.id); } }, style: ElevatedButton.styleFrom( - backgroundColor: appwriteUpcommingRoom.hasUserSubscribed + backgroundColor: appwriteUpcomingRoom.hasUserSubscribed ? Colors.red : Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( @@ -258,7 +248,7 @@ class UpCommingListTile extends StatelessWidget { ), ), child: Text( - appwriteUpcommingRoom.hasUserSubscribed + appwriteUpcomingRoom.hasUserSubscribed ? AppLocalizations.of(context)!.unsubscribe : AppLocalizations.of(context)!.subscribe, ), @@ -272,22 +262,15 @@ class UpCommingListTile extends StatelessWidget { } } -class CustomCircleAvatar extends StatelessWidget { - const CustomCircleAvatar({ - super.key, - required this.userImage, - this.width = 36, - this.height = 36, - }); +class _UpcomingAvatar extends StatelessWidget { + const _UpcomingAvatar({required this.userImage}); final String userImage; - final double width; - final double height; @override Widget build(BuildContext context) { return Container( - width: width, - height: height, + width: 36, + height: 36, decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider(userImage), diff --git a/lib/features/rooms/viewmodel/audio_device_notifier.dart b/lib/features/rooms/viewmodel/audio_device_notifier.dart new file mode 100644 index 00000000..60bfca76 --- /dev/null +++ b/lib/features/rooms/viewmodel/audio_device_notifier.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:resonate/features/rooms/data/audio_device_service.dart'; +import 'package:resonate/features/rooms/model/audio_device_state.dart'; +import 'package:resonate/models/audio_device.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/audio_device_notifier.g.dart'; + +@Riverpod(keepAlive: true) +AudioDeviceService audioDeviceService(Ref ref) => AudioDeviceService(); + +@Riverpod(keepAlive: true) +class AudioDeviceNotifier extends _$AudioDeviceNotifier { + Timer? _refreshTimer; + + @override + Future build() async { + ref.onDispose(() => _refreshTimer?.cancel()); + final service = ref.read(audioDeviceServiceProvider); + final devices = await service.enumerateOutputDevices(); + _refreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _refresh(), + ); + return AudioDeviceState( + devices: devices, + selected: devices.isEmpty ? null : devices.first, + ); + } + + Future _refresh() async { + try { + final service = ref.read(audioDeviceServiceProvider); + final devices = await service.enumerateOutputDevices(); + final current = state.value; + state = AsyncData( + AudioDeviceState( + devices: devices, + selected: current?.selected ?? + (devices.isEmpty ? null : devices.first), + ), + ); + } catch (e) { + log('audio device refresh failed: $e'); + } + } + + Future selectOutput(AudioDevice device) async { + final service = ref.read(audioDeviceServiceProvider); + await service.selectOutput(device); + final current = state.value; + if (current != null) { + state = AsyncData(current.copyWith(selected: device)); + } + } +} diff --git a/lib/features/rooms/viewmodel/create_room_notifier.dart b/lib/features/rooms/viewmodel/create_room_notifier.dart new file mode 100644 index 00000000..5858b092 --- /dev/null +++ b/lib/features/rooms/viewmodel/create_room_notifier.dart @@ -0,0 +1,90 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/upcoming_rooms_repository.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; +import 'package:resonate/utils/enums/room_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/create_room_notifier.g.dart'; + +@riverpod +class CreateRoomNotifier extends _$CreateRoomNotifier { + @override + bool build() => false; // isLoading + + Future createLiveRoom({ + required String name, + required String description, + required List tags, + }) async { + state = true; + try { + final repo = ref.read(roomsRepositoryProvider); + final result = await repo.createRoom( + name: name, + description: description, + tags: tags, + adminUid: requireCurrentAuthUser.uid, + ); + await ref.read(liveKitProvider.notifier).connect( + liveKitUri: result.liveKitUri, + roomToken: result.roomToken, + ); + + // Refresh global rooms list. + ref.invalidate(roomsProvider); + + return AppwriteRoom( + id: result.roomId, + name: name, + description: description, + totalParticipants: 1, + tags: tags, + memberAvatarUrls: const [], + state: RoomState.live, + isUserAdmin: true, + myDocId: result.myDocId, + reportedUsers: const [], + ); + } finally { + state = false; + } + } + + // Schedules an upcoming room. Returns true on success. + Future createScheduledRoom({ + required String name, + required String description, + required List tags, + required String scheduledDateTime, + }) async { + state = true; + try { + final repo = ref.read(upcomingRoomsRepositoryProvider); + await repo.createUpcomingRoom( + name: name, + description: description, + tags: tags, + scheduledDateTime: scheduledDateTime, + creatorUid: requireCurrentAuthUser.uid, + ); + ref.invalidate(upcomingRoomsProvider); + return true; + } finally { + state = false; + } + } +} + +extension TagValidator on String { + bool get isValidTag { + final hashtag = trim(); + if (!hashtag.startsWith(RegExp(r'[a-zA-Z0-9]'))) return false; + if (!hashtag.contains(RegExp(r'^[a-zA-Z0-9_]+$'))) return false; + if (hashtag.length > 30) return false; + return true; + } +} diff --git a/lib/features/rooms/viewmodel/generated/audio_device_notifier.g.dart b/lib/features/rooms/viewmodel/generated/audio_device_notifier.g.dart new file mode 100644 index 00000000..4606816f --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/audio_device_notifier.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../audio_device_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(audioDeviceService) +final audioDeviceServiceProvider = AudioDeviceServiceProvider._(); + +final class AudioDeviceServiceProvider + extends + $FunctionalProvider< + AudioDeviceService, + AudioDeviceService, + AudioDeviceService + > + with $Provider { + AudioDeviceServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'audioDeviceServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$audioDeviceServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AudioDeviceService create(Ref ref) { + return audioDeviceService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AudioDeviceService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$audioDeviceServiceHash() => + r'560f7e3d1bb2e75c230e87d296dffced539026ec'; + +@ProviderFor(AudioDeviceNotifier) +final audioDeviceProvider = AudioDeviceNotifierProvider._(); + +final class AudioDeviceNotifierProvider + extends $AsyncNotifierProvider { + AudioDeviceNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'audioDeviceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$audioDeviceNotifierHash(); + + @$internal + @override + AudioDeviceNotifier create() => AudioDeviceNotifier(); +} + +String _$audioDeviceNotifierHash() => + r'f9bc76f602d0fe4eeb034d8de56ec8377a1f1263'; + +abstract class _$AudioDeviceNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, AudioDeviceState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AudioDeviceState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart b/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart new file mode 100644 index 00000000..b9e9f215 --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../create_room_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CreateRoomNotifier) +final createRoomProvider = CreateRoomNotifierProvider._(); + +final class CreateRoomNotifierProvider + extends $NotifierProvider { + CreateRoomNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'createRoomProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$createRoomNotifierHash(); + + @$internal + @override + CreateRoomNotifier create() => CreateRoomNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$createRoomNotifierHash() => + r'56e1b3fdda5466998682742e7e469134f92ccb7a'; + +abstract class _$CreateRoomNotifier extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart b/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart new file mode 100644 index 00000000..33d91bdc --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../livekit_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(LiveKitNotifier) +final liveKitProvider = LiveKitNotifierProvider._(); + +final class LiveKitNotifierProvider + extends $NotifierProvider { + LiveKitNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'liveKitProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$liveKitNotifierHash(); + + @$internal + @override + LiveKitNotifier create() => LiveKitNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LiveKitState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$liveKitNotifierHash() => r'1c9f3d5ae76f6d3f489907a4ae669ef405f5247e'; + +abstract class _$LiveKitNotifier extends $Notifier { + LiveKitState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LiveKitState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart b/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart new file mode 100644 index 00000000..60b7bc83 --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart @@ -0,0 +1,111 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../room_chat_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(RoomChatNotifier) +final roomChatProvider = RoomChatNotifierFamily._(); + +final class RoomChatNotifierProvider + extends $AsyncNotifierProvider { + RoomChatNotifierProvider._({ + required RoomChatNotifierFamily super.from, + required (String, String, bool) super.argument, + }) : super( + retry: null, + name: r'roomChatProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomChatNotifierHash(); + + @override + String toString() { + return r'roomChatProvider' + '' + '$argument'; + } + + @$internal + @override + RoomChatNotifier create() => RoomChatNotifier(); + + @override + bool operator ==(Object other) { + return other is RoomChatNotifierProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$roomChatNotifierHash() => r'23050ba4be956bd36fc50fb714e8e193ac578b4e'; + +final class RoomChatNotifierFamily extends $Family + with + $ClassFamilyOverride< + RoomChatNotifier, + AsyncValue, + RoomChatState, + FutureOr, + (String, String, bool) + > { + RoomChatNotifierFamily._() + : super( + retry: null, + name: r'roomChatProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + RoomChatNotifierProvider call( + String roomId, + String roomName, + bool isUpcoming, + ) => RoomChatNotifierProvider._( + argument: (roomId, roomName, isUpcoming), + from: this, + ); + + @override + String toString() => r'roomChatProvider'; +} + +abstract class _$RoomChatNotifier extends $AsyncNotifier { + late final _$args = ref.$arg as (String, String, bool); + String get roomId => _$args.$1; + String get roomName => _$args.$2; + bool get isUpcoming => _$args.$3; + + FutureOr build( + String roomId, + String roomName, + bool isUpcoming, + ); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, RoomChatState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, RoomChatState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args.$1, _$args.$2, _$args.$3)); + } +} diff --git a/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart b/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart new file mode 100644 index 00000000..4a727bb2 --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../rooms_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(RoomsNotifier) +final roomsProvider = RoomsNotifierProvider._(); + +final class RoomsNotifierProvider + extends $AsyncNotifierProvider { + RoomsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roomsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomsNotifierHash(); + + @$internal + @override + RoomsNotifier create() => RoomsNotifier(); +} + +String _$roomsNotifierHash() => r'71f3665edd5dd53478437d1822feb9bc35806270'; + +abstract class _$RoomsNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, RoomsState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, RoomsState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart b/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart new file mode 100644 index 00000000..3ac30259 --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../single_room_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SingleRoomNotifier) +final singleRoomProvider = SingleRoomNotifierFamily._(); + +final class SingleRoomNotifierProvider + extends $AsyncNotifierProvider { + SingleRoomNotifierProvider._({ + required SingleRoomNotifierFamily super.from, + required AppwriteRoom super.argument, + }) : super( + retry: null, + name: r'singleRoomProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$singleRoomNotifierHash(); + + @override + String toString() { + return r'singleRoomProvider' + '' + '($argument)'; + } + + @$internal + @override + SingleRoomNotifier create() => SingleRoomNotifier(); + + @override + bool operator ==(Object other) { + return other is SingleRoomNotifierProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$singleRoomNotifierHash() => + r'2734ebcd25c8e2c440b211c5e248764b5d5c9d19'; + +final class SingleRoomNotifierFamily extends $Family + with + $ClassFamilyOverride< + SingleRoomNotifier, + AsyncValue, + SingleRoomState, + FutureOr, + AppwriteRoom + > { + SingleRoomNotifierFamily._() + : super( + retry: null, + name: r'singleRoomProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + SingleRoomNotifierProvider call(AppwriteRoom appwriteRoom) => + SingleRoomNotifierProvider._(argument: appwriteRoom, from: this); + + @override + String toString() => r'singleRoomProvider'; +} + +abstract class _$SingleRoomNotifier extends $AsyncNotifier { + late final _$args = ref.$arg as AppwriteRoom; + AppwriteRoom get appwriteRoom => _$args; + + FutureOr build(AppwriteRoom appwriteRoom); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, SingleRoomState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, SingleRoomState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/lib/features/rooms/viewmodel/generated/upcoming_rooms_notifier.g.dart b/lib/features/rooms/viewmodel/generated/upcoming_rooms_notifier.g.dart new file mode 100644 index 00000000..01319202 --- /dev/null +++ b/lib/features/rooms/viewmodel/generated/upcoming_rooms_notifier.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../upcoming_rooms_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(UpcomingRoomsNotifier) +final upcomingRoomsProvider = UpcomingRoomsNotifierProvider._(); + +final class UpcomingRoomsNotifierProvider + extends + $AsyncNotifierProvider< + UpcomingRoomsNotifier, + List + > { + UpcomingRoomsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'upcomingRoomsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$upcomingRoomsNotifierHash(); + + @$internal + @override + UpcomingRoomsNotifier create() => UpcomingRoomsNotifier(); +} + +String _$upcomingRoomsNotifierHash() => + r'856b5b330c3b3a3269f4eb7f237f6b09cbfe3a19'; + +abstract class _$UpcomingRoomsNotifier + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref< + AsyncValue>, + List + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/rooms/viewmodel/livekit_notifier.dart b/lib/features/rooms/viewmodel/livekit_notifier.dart new file mode 100644 index 00000000..81657bdf --- /dev/null +++ b/lib/features/rooms/viewmodel/livekit_notifier.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:livekit_client/livekit_client.dart'; +import 'package:resonate/features/rooms/data/livekit_session.dart'; +import 'package:resonate/features/rooms/model/livekit_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/livekit_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class LiveKitNotifier extends _$LiveKitNotifier { + LiveKitSession? _session; + StreamSubscription? _connectionSub; + StreamSubscription? _eventsSub; + + @override + LiveKitState build() { + ref.onDispose(_cleanup); + return const LiveKitState(); + } + + LiveKitSession? get session => _session; + Room? get currentRoom => _session?.room; + + Future connect({ + required String liveKitUri, + required String roomToken, + bool isLiveChapter = false, + }) async { + await _cleanup(); + + final session = LiveKitSession( + liveKitUri: liveKitUri, + roomToken: roomToken, + isLiveChapter: isLiveChapter, + ); + _session = session; + if (ref.mounted) state = state.copyWith(hasSession: true); + + _connectionSub = session.connectionStates.listen((connected) { + if (ref.mounted) state = state.copyWith(isConnected: connected); + }); + + _eventsSub = session.events.listen((event) { + if (event is RoomRecordingStatusChanged && ref.mounted) { + state = state.copyWith(isRecording: event.activeRecording); + } + }); + + final ok = await session.connect(); + if (!ok) { + await _cleanup(); + } + return ok; + } + + Future setMicrophoneEnabled(bool enabled) => + _session?.setMicrophoneEnabled(enabled) ?? Future.value(); + + Future setRecording(bool recording) async { + await _session?.setRecording(recording); + state = state.copyWith(isRecording: recording); + } + + Future disconnect() => _cleanup(); + + Future _cleanup() async { + await _connectionSub?.cancel(); + await _eventsSub?.cancel(); + _connectionSub = null; + _eventsSub = null; + await _session?.dispose(); + _session = null; + if (ref.mounted) state = const LiveKitState(); + } +} diff --git a/lib/features/rooms/viewmodel/room_chat_notifier.dart b/lib/features/rooms/viewmodel/room_chat_notifier.dart new file mode 100644 index 00000000..33bd6680 --- /dev/null +++ b/lib/features/rooms/viewmodel/room_chat_notifier.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart' show ID; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + hide Message; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/data/room_chat_repository.dart'; +import 'package:resonate/features/rooms/model/reply_to.dart'; +import 'package:resonate/features/rooms/model/room_chat_state.dart'; +import 'package:resonate/features/rooms/model/room_message.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/room_chat_notifier.g.dart'; + +const _androidChannel = AndroidNotificationDetails( + 'your channel id', + 'your channel name', + channelDescription: 'your channel description', + importance: Importance.max, + priority: Priority.high, + ticker: 'ticker', +); +const _notificationDetails = NotificationDetails(android: _androidChannel); + +@riverpod +class RoomChatNotifier extends _$RoomChatNotifier { + StreamSubscription? _messageSub; + final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + + @override + Future build( + String roomId, + String roomName, + bool isUpcoming, + ) async { + ref.onDispose(() async { + await _messageSub?.cancel(); + }); + + final repo = ref.read(roomChatRepositoryProvider); + final messages = await repo.loadMessages(roomId); + _subscribe(roomId, roomName, isUpcoming); + return RoomChatState(messages: messages); + } + + void _subscribe(String roomId, String roomName, bool isUpcoming) { + final repo = ref.read(roomChatRepositoryProvider); + _messageSub = repo.messageStream(roomId).listen((event) { + final current = state.value; + if (current == null) return; + + if (event.action == 'create') { + // Dedupe: if this messageId was optimistically inserted by us, + // replace it in place rather than appending a duplicate. + final existingIndex = current.messages.indexWhere( + (m) => m.messageId == event.message.messageId, + ); + final List list; + if (existingIndex >= 0) { + list = [...current.messages]; + list[existingIndex] = event.message; + } else { + list = [...current.messages, event.message]; + } + state = AsyncData(current.copyWith(messages: list)); + + // Only notify on incoming messages from others, not echoes of ours. + final fromSelf = + event.message.creatorId == requireCurrentAuthUser.uid; + if (!isUpcoming && !fromSelf) { + _notifications.show( + 0, + 'Message received in $roomName', + '${event.message.creatorName} said: ${event.message.content}', + _notificationDetails, + ); + } + } else if (event.action == 'update') { + final updated = current.messages.map((m) { + if (m.messageId != event.message.messageId) return m; + return m.copyWith( + content: event.message.content, + isEdited: event.message.isEdited, + isDeleted: event.message.isDeleted, + ); + }).toList(); + state = AsyncData(current.copyWith(messages: updated)); + + final fromSelf = + event.message.creatorId == requireCurrentAuthUser.uid; + if (!isUpcoming && !fromSelf) { + _notifications.show( + 0, + 'Message Edited in $roomName', + '${event.message.creatorName} updated his message: ${event.message.content}', + _notificationDetails, + ); + } + } + }); + } + + // Returns true on successful POST, false if it failed + Future sendMessage({ + required String roomId, + required String roomName, + required bool isUpcoming, + required String content, + }) async { + final current = state.value; + if (current == null) return false; + + final messageId = ID.unique(); + final newIndex = + current.messages.isNotEmpty ? current.messages.last.index + 1 : 0; + final user = requireCurrentAuthUser; + final replyTo = current.replyingTo; + + final message = RoomMessage( + roomId: roomId, + messageId: messageId, + creatorId: user.uid, + creatorUsername: user.userName ?? '', + creatorName: user.displayName, + hasValidTag: false, + index: newIndex, + creatorImgUrl: user.profileImageUrl ?? '', + isEdited: false, + content: content, + creationDateTime: DateTime.now(), + isDeleted: false, + replyTo: replyTo, + status: RoomMessageStatus.pending, + ); + +// Optimistic insertion: show the message immediately with a pending status while we attempt to send it. + state = AsyncData( + current.copyWith( + messages: [...current.messages, message], + replyingTo: null, + ), + ); + + return _postMessage( + message: message, + replyTo: replyTo, + roomName: roomName, + isUpcoming: isUpcoming, + ); + } + + // Retry sending a previously-failed message + Future retrySend({ + required String messageId, + required String roomName, + required bool isUpcoming, + }) async { + final current = state.value; + if (current == null) return false; + final idx = current.messages.indexWhere((m) => m.messageId == messageId); + if (idx < 0) return false; + final failed = current.messages[idx]; + if (failed.status != RoomMessageStatus.failed) return false; + + // Flip back to pending while we re-attempt. + state = AsyncData(_replaceStatus(current, messageId, RoomMessageStatus.pending)); + + return _postMessage( + message: failed.copyWith(status: RoomMessageStatus.pending), + replyTo: failed.replyTo, + roomName: roomName, + isUpcoming: isUpcoming, + ); + } + + Future _postMessage({ + required RoomMessage message, + required ReplyTo? replyTo, + required String roomName, + required bool isUpcoming, + }) async { + try { + await ref.read(roomChatRepositoryProvider).sendMessage( + message: message, + replyTo: replyTo, + ); +// On success, the Realtime listener will update the message in state + final after = state.value; + if (after != null) { + state = AsyncData( + _replaceStatus(after, message.messageId, RoomMessageStatus.sent), + ); + } + if (isUpcoming) { + await ref.read(roomChatRepositoryProvider).notifyUpcomingRoomSubscribers( + upcomingRoomId: message.roomId, + title: 'Message received in $roomName', + body: '${message.creatorName} said: ${message.content}', + ); + } + return true; + } catch (e) { + log('sendMessage failed: $e — marking message ${message.messageId} as failed'); + final rolled = state.value; + if (rolled != null) { + state = AsyncData( + _replaceStatus(rolled, message.messageId, RoomMessageStatus.failed), + ); + } + return false; + } + } + + RoomChatState _replaceStatus( + RoomChatState current, + String messageId, + RoomMessageStatus status, + ) { + return current.copyWith( + messages: current.messages.map((m) { + if (m.messageId != messageId) return m; + return m.copyWith(status: status); + }).toList(), + ); + } + + Future editMessage({ + required String messageId, + required String roomName, + required bool isUpcoming, + required String newContent, + }) async { + final current = state.value; + if (current == null) return; + + final original = current.messages.firstWhere( + (m) => m.messageId == messageId, + ); + final updated = original.copyWith( + content: newContent, + isEdited: true, + isDeleted: false, + ); + + try { + await ref.read(roomChatRepositoryProvider).editMessage(updated); + if (isUpcoming) { + await ref.read(roomChatRepositoryProvider).notifyUpcomingRoomSubscribers( + upcomingRoomId: updated.roomId, + title: 'Message Edited in $roomName', + body: '${updated.creatorName} updated his message: ${updated.content}', + ); + } + } catch (e) { + log('editMessage failed: $e'); + } + } + + Future deleteMessage(String messageId) async { + final current = state.value; + if (current == null) return; + + final original = current.messages.firstWhere( + (m) => m.messageId == messageId, + ); + final softDeleted = original.copyWith(content: '', isDeleted: true); + + try { + await ref.read(roomChatRepositoryProvider).deleteMessage(softDeleted); + } catch (e) { + log('deleteMessage failed: $e'); + } + } + + void setReplyingTo(RoomMessage message) { + final current = state.value; + if (current == null) return; + state = AsyncData( + current.copyWith( + replyingTo: ReplyTo( + messageId: message.messageId, + creatorUsername: message.creatorUsername, + content: message.content, + creatorImgUrl: message.creatorImgUrl, + index: current.messages.indexOf(message), + ), + ), + ); + } + + void clearReplyingTo() { + final current = state.value; + if (current == null) return; + state = AsyncData(current.copyWith(replyingTo: null)); + } +} diff --git a/lib/features/rooms/viewmodel/rooms_notifier.dart b/lib/features/rooms/viewmodel/rooms_notifier.dart new file mode 100644 index 00000000..720c61ee --- /dev/null +++ b/lib/features/rooms/viewmodel/rooms_notifier.dart @@ -0,0 +1,83 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/rooms_state.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/rooms_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class RoomsNotifier extends _$RoomsNotifier { + @override + Future build() async { + final userUid = requireCurrentAuthUser.uid; + final rooms = await ref.watch(roomsRepositoryProvider).loadRooms(userUid); + return RoomsState.ready(rooms: rooms); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final userUid = requireCurrentAuthUser.uid; + final rooms = await ref.read(roomsRepositoryProvider).loadRooms(userUid); + return RoomsState.ready(rooms: rooms); + }); + } + + Future joinRoom(AppwriteRoom room) async { + final repo = ref.read(roomsRepositoryProvider); + final result = await repo.joinRoom( + roomId: room.id, + userId: requireCurrentAuthUser.uid, + isAdmin: room.isUserAdmin, + ); + await ref.read(liveKitProvider.notifier).connect( + liveKitUri: result.liveKitUri, + roomToken: result.roomToken, + ); + return room.copyWith(myDocId: result.myDocId); + } + + void searchLiveRooms(String query) { + final current = state.value; + if (current is! RoomsStateReady) return; + + if (query.isEmpty) { + state = AsyncData( + current.copyWith( + filteredRooms: const [], + isSearching: false, + searchBarIsEmpty: true, + ), + ); + return; + } + + final lower = query.toLowerCase(); + final filtered = current.rooms.where((r) { + return r.name.toLowerCase().contains(lower) || + r.description.toLowerCase().contains(lower); + }).toList(); + + state = AsyncData( + current.copyWith( + filteredRooms: filtered, + isSearching: true, + searchBarIsEmpty: false, + ), + ); + } + + void clearLiveSearch() { + final current = state.value; + if (current is! RoomsStateReady) return; + state = AsyncData( + current.copyWith( + filteredRooms: const [], + isSearching: false, + searchBarIsEmpty: true, + ), + ); + } +} diff --git a/lib/features/rooms/viewmodel/single_room_notifier.dart b/lib/features/rooms/viewmodel/single_room_notifier.dart new file mode 100644 index 00000000..5974d54c --- /dev/null +++ b/lib/features/rooms/viewmodel/single_room_notifier.dart @@ -0,0 +1,286 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/participant.dart'; +import 'package:resonate/features/rooms/model/single_room_state.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/single_room_notifier.g.dart'; + +@riverpod +class SingleRoomNotifier extends _$SingleRoomNotifier { + StreamSubscription? _participantSub; + + @override + Future build(AppwriteRoom appwriteRoom) async { + ref.onDispose(_disposeStream); + + final me = _meFor(appwriteRoom); + final repo = ref.read(roomsRepositoryProvider); + final participants = await repo.loadParticipants(appwriteRoom.id); + final sorted = _sort(participants); + + _subscribe(appwriteRoom); + + return SingleRoomState(me: me, participants: sorted); + } + + Participant _meFor(AppwriteRoom room) { + final user = requireCurrentAuthUser; + return Participant( + uid: user.uid, + email: user.email, + name: user.userName ?? '', + dpUrl: user.profileImageUrl ?? '', + isAdmin: room.isUserAdmin, + isMicOn: false, + isModerator: room.isUserAdmin, + isSpeaker: room.isUserAdmin, + hasRequestedToBeSpeaker: false, + ); + } + + void _subscribe(AppwriteRoom appwriteRoom) { + final repo = ref.read(roomsRepositoryProvider); + final channel = RoomsRepository.participantChannel(); + + _participantSub = repo.participantStream(appwriteRoom.id).listen((event) async { + final docId = event.payload['\$id'] as String; + final action = event.events.first.substring( + channel.length + 1 + docId.length + 1, + ); + final current = state.value; + if (current == null) return; + + switch (action) { + case 'create': + { + final newParticipant = await repo.buildParticipantFromRow( + Row.fromMap(event.payload), + ); + final list = [...current.participants, newParticipant]; + state = AsyncData(current.copyWith(participants: _sort(list))); + break; + } + case 'update': + { + final updatedUid = event.payload['uid'] as String; + var nextMe = current.me; + if (updatedUid == current.me.uid) { + nextMe = current.me.copyWith( + isModerator: event.payload['isModerator'] as bool, + hasRequestedToBeSpeaker: + (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? false, + isMicOn: event.payload['isMicOn'] as bool, + isSpeaker: event.payload['isSpeaker'] as bool, + ); + } + final updated = current.participants.map((p) { + if (p.uid != updatedUid) return p; + return p.copyWith( + isModerator: event.payload['isModerator'] as bool, + hasRequestedToBeSpeaker: + (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? false, + isMicOn: event.payload['isMicOn'] as bool, + isSpeaker: event.payload['isSpeaker'] as bool, + ); + }).toList(); + + state = AsyncData( + current.copyWith(me: nextMe, participants: _sort(updated)), + ); + + // If we got demoted from speaker while mic was on, mute. + if (updatedUid == current.me.uid && + !(event.payload['isSpeaker'] as bool) && + current.me.isMicOn) { + await turnOffMic(appwriteRoom); + } + break; + } + case 'delete': + { + final removedUid = event.payload['uid'] as String; + if (removedUid == current.me.uid) { + // We were kicked. Notifier disposal handled by view layer + // observing the participant list / failure. + break; + } + final filtered = current.participants + .where((p) => p.uid != removedUid) + .toList(); + state = AsyncData(current.copyWith(participants: filtered)); + break; + } + } + }); + } + + Future _disposeStream() async { + await _participantSub?.cancel(); + _participantSub = null; + } + + List _sort(List participants) { + final sorted = [...participants]; + sorted.sort((a, b) { + if (b.isAdmin && !a.isAdmin) return 1; + if (!b.isAdmin && a.isAdmin) return -1; + if (b.isModerator && !a.isModerator) return 1; + if (!b.isModerator && a.isModerator) return -1; + if (b.isSpeaker && !a.isSpeaker) return 1; + if (!b.isSpeaker && a.isSpeaker) return -1; + if (b.hasRequestedToBeSpeaker && !a.hasRequestedToBeSpeaker) return 1; + if (!b.hasRequestedToBeSpeaker && a.hasRequestedToBeSpeaker) return -1; + return 0; + }); + return sorted; + } + + Future turnOnMic(AppwriteRoom appwriteRoom) => _setMic(appwriteRoom, true); + Future turnOffMic(AppwriteRoom appwriteRoom) => _setMic(appwriteRoom, false); + + Future _setMic(AppwriteRoom appwriteRoom, bool enabled) async { + // Optimistic UI update so the button reacts even if LiveKit/Appwrite are + // slow or fail. The Realtime stream will overwrite this with the + // authoritative value once the update propagates. + final current = state.value; + if (current != null) { + state = AsyncData( + current.copyWith(me: current.me.copyWith(isMicOn: enabled)), + ); + } + + try { + await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(enabled); + } catch (_) {} + + final docId = appwriteRoom.myDocId; + if (docId == null) return; + try { + await ref.read(roomsRepositoryProvider).updateParticipantDoc( + docId: docId, + data: {'isMicOn': enabled}, + ); + } catch (_) {} + } + + Future raiseHand(AppwriteRoom appwriteRoom) async { + await ref.read(roomsRepositoryProvider).updateParticipantDoc( + docId: appwriteRoom.myDocId!, + data: {'hasRequestedToBeSpeaker': true}, + ); + final current = state.value; + if (current != null) { + state = AsyncData( + current.copyWith(me: current.me.copyWith(hasRequestedToBeSpeaker: true)), + ); + } + } + + Future unRaiseHand(AppwriteRoom appwriteRoom) async { + await ref.read(roomsRepositoryProvider).updateParticipantDoc( + docId: appwriteRoom.myDocId!, + data: {'hasRequestedToBeSpeaker': false}, + ); + final current = state.value; + if (current != null) { + state = AsyncData( + current.copyWith(me: current.me.copyWith(hasRequestedToBeSpeaker: false)), + ); + } + } + + Future makeModerator(AppwriteRoom appwriteRoom, Participant participant) => + _participantMutation(appwriteRoom, participant, { + 'isSpeaker': true, + 'hasRequestedToBeSpeaker': false, + 'isModerator': true, + }); + + Future removeModerator(AppwriteRoom appwriteRoom, Participant participant) => + _participantMutation(appwriteRoom, participant, { + 'isSpeaker': false, + 'hasRequestedToBeSpeaker': false, + 'isModerator': false, + }); + + Future makeSpeaker(AppwriteRoom appwriteRoom, Participant participant) => + _participantMutation(appwriteRoom, participant, { + 'isSpeaker': true, + 'hasRequestedToBeSpeaker': false, + }); + + Future makeListener(AppwriteRoom appwriteRoom, Participant participant) => + _participantMutation(appwriteRoom, participant, { + 'isSpeaker': false, + 'hasRequestedToBeSpeaker': false, + }); + + Future _participantMutation( + AppwriteRoom appwriteRoom, + Participant participant, + Map data, + ) async { + final repo = ref.read(roomsRepositoryProvider); + final docId = await repo.getParticipantDocId( + roomId: appwriteRoom.id, + participantUid: participant.uid, + ); + await repo.updateParticipantDoc(docId: docId, data: data); + } + + Future kickOutParticipant( + AppwriteRoom appwriteRoom, + Participant participant, + ) async { + final repo = ref.read(roomsRepositoryProvider); + final docId = await repo.getParticipantDocId( + roomId: appwriteRoom.id, + participantUid: participant.uid, + ); + await repo.kickParticipant(docId); + } + + Future reportAndKick( + AppwriteRoom appwriteRoom, + Participant participant, + ) async { + final repo = ref.read(roomsRepositoryProvider); + await repo.reportParticipant( + roomId: appwriteRoom.id, + participantUid: participant.uid, + currentReported: appwriteRoom.reportedUsers, + ); + await kickOutParticipant(appwriteRoom, participant); + } + + Future leaveRoom(AppwriteRoom appwriteRoom) async { + final current = state.value; + if (current != null) { + state = AsyncData(current.copyWith(isLoading: true)); + } + await _disposeStream(); + await ref.read(roomsRepositoryProvider).leaveRoom( + roomId: appwriteRoom.id, + userId: requireCurrentAuthUser.uid, + ); + await ref.read(liveKitProvider.notifier).disconnect(); + } + + Future deleteRoom(AppwriteRoom appwriteRoom) async { + final current = state.value; + if (current != null) { + state = AsyncData(current.copyWith(isLoading: true)); + } + await ref.read(roomsRepositoryProvider).deleteRoom(roomId: appwriteRoom.id); + await ref.read(liveKitProvider.notifier).disconnect(); + ref.invalidate(roomsProvider); + } +} diff --git a/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart b/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart new file mode 100644 index 00000000..a6b550cb --- /dev/null +++ b/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart @@ -0,0 +1,108 @@ +import 'package:get_storage/get_storage.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/core/providers/get_storage_provider.dart'; +import 'package:resonate/features/rooms/data/upcoming_rooms_repository.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/features/rooms/viewmodel/create_room_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/upcoming_rooms_notifier.g.dart'; + +const String _removedUpcomingRoomsKey = 'removed_upcoming_rooms'; + +@Riverpod(keepAlive: true) +class UpcomingRoomsNotifier extends _$UpcomingRoomsNotifier { + GetStorage get _storage => ref.read(getStorageBoxProvider); + + List _readHidden() => + List.from(_storage.read(_removedUpcomingRoomsKey) ?? []); + + Future _writeHidden(List ids) => + _storage.write(_removedUpcomingRoomsKey, ids); + + @override + Future> build() async { + final repo = ref.watch(upcomingRoomsRepositoryProvider); + final hidden = _readHidden(); + final rooms = await repo.loadUpcoming( + userUid: requireCurrentAuthUser.uid, + hiddenRoomIds: hidden.toSet(), + ); + + final liveIds = await repo.liveUpcomingRoomIds(); + final cleaned = hidden.where(liveIds.contains).toList(); + if (cleaned.length != hidden.length) { + await _writeHidden(cleaned); + } + return rooms; + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final repo = ref.read(upcomingRoomsRepositoryProvider); + return repo.loadUpcoming( + userUid: requireCurrentAuthUser.uid, + hiddenRoomIds: _readHidden().toSet(), + ); + }); + } + + Future subscribe(String upcomingRoomId) async { + final user = requireCurrentAuthUser; + await ref.read(upcomingRoomsRepositoryProvider).addSubscriber( + upcomingRoomId: upcomingRoomId, + userUid: user.uid, + profileImageUrl: user.profileImageUrl ?? '', + ); + await refresh(); + } + + Future unsubscribe(String upcomingRoomId) async { + await ref.read(upcomingRoomsRepositoryProvider).removeSubscriber( + upcomingRoomId: upcomingRoomId, + userUid: requireCurrentAuthUser.uid, + ); + await refresh(); + } + + Future deleteUpcoming(String upcomingRoomId) async { + await ref.read(upcomingRoomsRepositoryProvider).deleteUpcomingRoom( + upcomingRoomId, + ); + await refresh(); + } + + // Hide an upcoming room locally without deleting it server-side. + Future hideLocally(String upcomingRoomId) async { + final hidden = _readHidden(); + if (!hidden.contains(upcomingRoomId)) { + hidden.add(upcomingRoomId); + await _writeHidden(hidden); + } + final current = state.value; + if (current != null) { + state = AsyncData( + current.where((r) => r.id != upcomingRoomId).toList(), + ); + } + } + + // Promotes an upcoming room into a live room via CreateRoomNotifier + Future convertToLive({ + required String upcomingRoomId, + required String name, + required String description, + required List tags, + }) async { + final created = await ref.read(createRoomProvider.notifier).createLiveRoom( + name: name, + description: description, + tags: tags, + ); + if (created == null) return; + await deleteUpcoming(upcomingRoomId); + ref.invalidate(roomsProvider); + } +} diff --git a/lib/models/appwrite_room.dart b/lib/models/appwrite_room.dart deleted file mode 100644 index 8531485e..00000000 --- a/lib/models/appwrite_room.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:resonate/utils/enums/room_state.dart'; - -class AppwriteRoom { - AppwriteRoom({ - required this.id, - required this.name, - required this.description, - required this.totalParticipants, - required this.tags, - required this.memberAvatarUrls, - required this.state, - required this.isUserAdmin, - required this.reportedUsers, - this.myDocId, - }); - late final String id; - late final String name; - late final String description; - late final int totalParticipants; - late final List tags; - late final List memberAvatarUrls; - late final RoomState state; - late final bool isUserAdmin; - late String? myDocId; - late final List reportedUsers; -} diff --git a/lib/models/appwrite_upcomming_room.dart b/lib/models/appwrite_upcomming_room.dart deleted file mode 100644 index 47f2c504..00000000 --- a/lib/models/appwrite_upcomming_room.dart +++ /dev/null @@ -1,25 +0,0 @@ -class AppwriteUpcommingRoom { - AppwriteUpcommingRoom({ - required this.id, - required this.name, - required this.isTime, - required this.scheduledDateTime, - required this.description, - required this.totalSubscriberCount, - required this.tags, - required this.subscribersAvatarUrls, - required this.userIsCreator, - required this.hasUserSubscribed, - }); - late final String id; - late final String name; - late final bool isTime; - late final DateTime scheduledDateTime; - late final String description; - late final int totalSubscriberCount; - late final List tags; - late final List subscribersAvatarUrls; - late final bool userIsCreator; - // Only matters if user is not a creator (if not a creator has he subscribed or not) - late final bool hasUserSubscribed; -} diff --git a/lib/models/message.dart b/lib/models/message.dart deleted file mode 100644 index b1a1bf67..00000000 --- a/lib/models/message.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:resonate/models/reply_to.dart'; - -class Message { - String roomId; - String messageId; - String creatorId; - String creatorUsername; - String creatorName; - String creatorImgUrl; - bool hasValidTag; - bool isDeleted; - int index; - bool isEdited; - String content; - DateTime creationDateTime; - ReplyTo? replyTo; - - Message({ - required this.roomId, - required this.messageId, - required this.creatorId, - required this.creatorUsername, - required this.creatorName, - required this.hasValidTag, - required this.index, - required this.creatorImgUrl, - required this.isEdited, - required this.content, - required this.isDeleted, - required this.creationDateTime, - this.replyTo, - }); - - Map toJson() { - return { - 'roomId': roomId, - 'messageId': messageId, - 'creatorId': creatorId, - 'creatorUsername': creatorUsername, - 'creatorName': creatorName, - 'creatorImgUrl': creatorImgUrl, - 'hasValidTag': hasValidTag, - 'index': index, - 'isEdited': isEdited, - 'content': content, - 'isDeleted': isDeleted, - 'creationDateTime': creationDateTime.toUtc().toIso8601String(), - 'replyTo': replyTo?.toJson(), - }; - } - - Map toJsonForUpload() { - return { - 'roomId': roomId, - 'messageId': messageId, - 'creatorId': creatorId, - 'creatorUsername': creatorUsername, - 'creatorName': creatorName, - 'creatorImgUrl': creatorImgUrl, - 'hasValidTag': hasValidTag, - 'index': index, - 'isEdited': isEdited, - 'content': content, - 'isDeleted': isDeleted, - 'creationDateTime': creationDateTime.toUtc().toIso8601String(), - }; - } - - Message.fromJson(Map json) - : roomId = json['roomId'], - messageId = json['messageId'], - creatorId = json['creatorId'], - creatorUsername = json['creatorUsername'], - creatorName = json['creatorName'], - creatorImgUrl = json['creatorImgUrl'], - hasValidTag = json['hasValidTag'], - index = json['index'], - isEdited = json['isEdited'], - content = json['content'], - creationDateTime = DateTime.parse(json['creationDateTime']), - isDeleted = json['isDeleted'] ?? false, - replyTo = json['replyTo'] != null - ? ReplyTo.fromJson(json['replyTo']) - : null; - Message copyWith({ - String? roomId, - String? messageId, - String? creatorId, - String? creatorUsername, - String? creatorName, - String? creatorImgUrl, - bool? hasValidTag, - int? index, - bool? isEdited, - bool? isDeleted, - String? content, - DateTime? creationDateTime, - ReplyTo? replyTo, - }) { - return Message( - roomId: roomId ?? this.roomId, - messageId: messageId ?? this.messageId, - creatorId: creatorId ?? this.creatorId, - creatorUsername: creatorUsername ?? this.creatorUsername, - creatorName: creatorName ?? this.creatorName, - creatorImgUrl: creatorImgUrl ?? this.creatorImgUrl, - hasValidTag: hasValidTag ?? this.hasValidTag, - index: index ?? this.index, - isEdited: isEdited ?? this.isEdited, - content: content ?? this.content, - isDeleted: isDeleted ?? this.isDeleted, - creationDateTime: creationDateTime ?? this.creationDateTime, - replyTo: replyTo ?? this.replyTo, - ); - } -} diff --git a/lib/models/participant.dart b/lib/models/participant.dart deleted file mode 100644 index 41cd2a3a..00000000 --- a/lib/models/participant.dart +++ /dev/null @@ -1,50 +0,0 @@ -class Participant { - Participant({ - required this.uid, - required this.email, - required this.name, - required this.dpUrl, - required this.isAdmin, - required this.isMicOn, - required this.isModerator, - required this.isSpeaker, - required this.hasRequestedToBeSpeaker, - }); - late final String uid; - late final String email; - late final String name; - late final String dpUrl; - late bool isAdmin; - late bool isMicOn; - late bool isModerator; - late bool isSpeaker; - late bool hasRequestedToBeSpeaker; - - Participant.fromJson(Map json) { - uid = json["uid"]; - email = json['email']; - name = json['name'] ?? "Unknown"; - dpUrl = - json['dpUrl'] ?? - "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dXNlciUyMHByb2ZpbGV8ZW58MHx8MHx8&w=1000&q=80"; - isAdmin = json['isAdmin']; - isMicOn = json['isMicOn']; - isModerator = json['isModerator']; - isSpeaker = json['isSpeaker']; - hasRequestedToBeSpeaker = json["hasRequestedToBeSpeaker"]; - } - - Map toJson() { - final data = {}; - data["uid"] = uid; - data['email'] = email; - data['name'] = name; - data['dpUrl'] = dpUrl; - data['isAdmin'] = isAdmin; - data['isMicOn'] = isMicOn; - data['isModerator'] = isModerator; - data['isSpeaker'] = isSpeaker; - data['hasRequestedToBeSpeaker'] = hasRequestedToBeSpeaker; - return data; - } -} diff --git a/lib/models/reply_to.dart b/lib/models/reply_to.dart deleted file mode 100644 index 099d7477..00000000 --- a/lib/models/reply_to.dart +++ /dev/null @@ -1,32 +0,0 @@ -class ReplyTo { - String creatorUsername; - String creatorImgUrl; - int index; - String content; - String messageId; - - ReplyTo({ - required this.creatorUsername, - required this.creatorImgUrl, - required this.index, - required this.content, - required this.messageId, - }); - - Map toJson() { - return { - 'creatorUsername': creatorUsername, - 'creatorImgUrl': creatorImgUrl, - 'index': index, - 'content': content, - 'messageId': messageId, - }; - } - - ReplyTo.fromJson(Map json) - : creatorUsername = json['creatorUsername'], - creatorImgUrl = json['creatorImgUrl'], - index = json['index'], - content = json['content'], - messageId = json['messageId']; -} diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index 75a7760b..825738d2 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -6,13 +6,13 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/auth_routes.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/rooms/rooms_routes.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/themes/theme_screen.dart'; import 'package:resonate/views/screens/about_app_screen.dart'; import 'package:resonate/views/screens/app_preferences_screen.dart'; import 'package:resonate/views/screens/change_email_screen.dart'; import 'package:resonate/views/screens/contribute_screen.dart'; -import 'package:resonate/views/screens/create_room_screen.dart'; import 'package:resonate/views/screens/create_story_screen.dart'; import 'package:resonate/views/screens/delete_account_screen.dart'; import 'package:resonate/views/screens/edit_profile_screen.dart'; @@ -58,16 +58,13 @@ final routerProvider = Provider((ref) { // Main app shell GoRoute( path: RoutePaths.tabview, - builder: (_, _) => TabViewScreen(), + builder: (_, _) => const TabViewScreen(), ), GoRoute( path: RoutePaths.homeScreen, builder: (_, _) => const HomeScreen(), ), - GoRoute( - path: RoutePaths.createRoom, - builder: (_, _) => CreateRoomScreen(), - ), + ...roomsRoutes, // Profile & account GoRoute( @@ -167,10 +164,6 @@ String? _redirect(Ref ref, GoRouterState state) { return authRedirect(auth, state.uri.path); } -/// Pure redirect decision: given the current [AuthState] (or `null` if -/// still loading) and the requested [path], return the path to redirect to -/// or `null` to allow the navigation as-is. Extracted as a standalone -/// function so it can be unit-tested without spinning up a [GoRouter]. String? authRedirect(AuthState? auth, String path) { if (path == RoutePaths.splash) return null; if (auth == null) return null; diff --git a/lib/services/room_service.dart b/lib/services/room_service.dart index 6787a8f4..0e0a2941 100644 --- a/lib/services/room_service.dart +++ b/lib/services/room_service.dart @@ -1,13 +1,11 @@ -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; import 'package:resonate/services/api_service.dart'; import 'package:resonate/utils/constants.dart'; +// Legacy GetX-era helper still used by features that haven't been migrated + class RoomService { static ApiService apiService = ApiService(); @@ -26,101 +24,6 @@ class RoomService { ); } - static Future addParticipantToAppwriteCollection({ - required String roomId, - required String uid, - required bool isAdmin, - }) async { - RoomsController roomsController = Get.find(); - - // Get all documents with participant uid and roomid and delete them before adding the participant again - RowList participantDocsRef = await roomsController.tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [ - Query.equal("uid", [uid]), - Query.equal('roomId', [roomId]), - ], - ); - for (var document in participantDocsRef.rows) { - await roomsController.tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: document.$id, - ); - } - // Add participant to collection - Row participantDoc = await roomsController.tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: ID.unique().toString(), - data: { - "roomId": roomId, - "uid": uid, - "isAdmin": isAdmin, - "isModerator": isAdmin, - "isSpeaker": isAdmin, - "isMicOn": false, - }, - ); - if (!isAdmin) { - // Get present totalParticipants Attribute - Row roomDoc = await roomsController.tablesDB.getRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - ); - - // Increment the totalParticipants Attribute - int newParticipantCount = - roomDoc.data["totalParticipants"] - - participantDocsRef.rows.length + - 1; - await roomsController.tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - data: {"totalParticipants": newParticipantCount}, - ); - } - - return participantDoc.$id; - } - - static Future> createRoom({ - required String roomName, - required String roomDescription, - required List roomTags, - required String adminUid, - }) async { - var response = await apiService.createRoom( - roomName, - roomDescription, - adminUid, - roomTags, - ); - String appwriteRoomDocId = response["livekit_room"]["name"]; - String livekitToken = response["access_token"]; - String livekitSocketUrl = - response["livekit_socket_url"] == "wss://host.docker.internal:7880" - ? localhostLivekitEndpoint - : response["livekit_socket_url"]; - - // Store Livekit Url and Token in Secure Storage - const storage = FlutterSecureStorage(); - await storage.write(key: "createdRoomAdminToken", value: livekitToken); - await storage.write(key: "createdRoomLivekitUrl", value: livekitSocketUrl); - - String myDocId = await addParticipantToAppwriteCollection( - roomId: appwriteRoomDocId, - uid: adminUid, - isAdmin: true, - ); - await joinLiveKitRoom(livekitSocketUrl, livekitToken); - - return [appwriteRoomDocId, myDocId]; - } - static Future> createLiveChapterRoom({ required String appwriteRoomId, required String adminUid, @@ -136,7 +39,6 @@ class RoomService { ? localhostLivekitEndpoint : response["livekit_socket_url"]; - // Store Livekit Url and Token in Secure Storage const storage = FlutterSecureStorage(); await storage.write(key: "createdRoomAdminToken", value: livekitToken); await storage.write(key: "createdRoomLivekitUrl", value: livekitSocketUrl); @@ -162,111 +64,10 @@ class RoomService { static Future deleteLiveChapterRoom({required roomId}) async { const storage = FlutterSecureStorage(); - - // Delete room on livekit and roomdoc on appwrite String? livekitToken = await storage.read(key: "createdRoomAdminToken"); await apiService.deleteLiveChapterRoom(roomId, livekitToken!); } - static Future deleteRoom({required roomId}) async { - RoomsController roomsController = Get.find(); - const storage = FlutterSecureStorage(); - - // Delete room on livekit and roomdoc on appwrite - String? livekitToken = await storage.read(key: "createdRoomAdminToken"); - await apiService.deleteRoom(roomId, livekitToken!); - - // Get all participant documents and delete them - RowList participantDocsRef = await roomsController.tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [ - Query.equal('roomId', [roomId]), - ], - ); - - for (var document in participantDocsRef.rows) { - await roomsController.tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: document.$id, - ); - } - } - - static Future joinRoom({ - required roomId, - required String userId, - required bool isAdmin, - }) async { - var response = await apiService.joinRoom(roomId, userId); - String livekitToken = response["access_token"]; - String livekitSocketUrl = - response["livekit_socket_url"] == "wss://host.docker.internal:7880" - ? localhostLivekitEndpoint - : response["livekit_socket_url"]; - - String myDocId = await addParticipantToAppwriteCollection( - roomId: roomId, - uid: userId, - isAdmin: isAdmin, - ); - await joinLiveKitRoom(livekitSocketUrl, livekitToken); - return myDocId; - } - - static Future leaveRoom({required String roomId}) async { - RoomsController roomsController = Get.find(); - String userId = requireCurrentAuthUser.uid; - - Row roomDoc = await roomsController.tablesDB.getRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - ); - - // Get all documents with participant uid and roomid and delete them - RowList participantDocsRef = await roomsController.tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: participantsTableId, - queries: [ - Query.equal("uid", [userId]), - Query.equal('roomId', [roomId]), - ], - ); - for (var document in participantDocsRef.rows) { - await roomsController.tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: participantsTableId, - rowId: document.$id, - ); - } - - // Get present totalParticipants Attribute - if (roomDoc.data["totalParticipants"] - participantDocsRef.rows.length == - 0) { - // Delete the room since there are no participants - await roomsController.tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - ); - } else { - // Decrease the totalParticipants Attribute - await roomsController.tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: roomsTableId, - rowId: roomId, - data: { - "totalParticipants": - roomDoc.data["totalParticipants"] - - participantDocsRef.rows.length, - }, - ); - } - return true; - } - static Future joinLivekitPairChat({ required roomId, required String userId, diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index eade621c..47ac7fcf 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,15 +1,13 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -// import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; class AppUtils { AppUtils._(); + static void showDialog({ required BuildContext context, required String title, @@ -23,70 +21,79 @@ class AppUtils { }) { final localizations = AppLocalizations.of(context)!; - Get.defaultDialog( - title: title, - middleText: middleText, + showAdaptiveDialog( + context: context, barrierDismissible: false, - titleStyle: TextStyle( - fontWeight: FontWeight.bold, - fontSize: UiSizes.size_16, - ), - middleTextStyle: TextStyle( - fontSize: UiSizes.size_12, - overflow: TextOverflow.ellipsis, - ), - radius: UiSizes.size_20, - titlePadding: EdgeInsets.only(top: UiSizes.size_25), - contentPadding: EdgeInsets.symmetric( - horizontal: UiSizes.size_20, - vertical: UiSizes.size_20, - ), - actions: [ - ElevatedButton( - onPressed: onFirstBtnPressed, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + builder: (dialogCtx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiSizes.size_20), + ), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: UiSizes.size_16, ), - child: Text( - firstBtnText ?? localizations.confirm, - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: UiSizes.size_14, - ), + ), + content: Text( + middleText, + style: TextStyle( + fontSize: UiSizes.size_12, + overflow: TextOverflow.ellipsis, ), ), - ElevatedButton( - onPressed: onSecondBtnPressed, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - textStyle: secondBtnTextStyle, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + actionsPadding: EdgeInsets.symmetric( + horizontal: UiSizes.size_20, + vertical: UiSizes.size_8, + ), + actions: [ + ElevatedButton( + onPressed: onFirstBtnPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: Text( + firstBtnText ?? localizations.confirm, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: UiSizes.size_14, + ), + ), ), - child: Text( - secondBtnText ?? localizations.cancel, - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: UiSizes.size_14, + ElevatedButton( + onPressed: onSecondBtnPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + textStyle: secondBtnTextStyle, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: Text( + secondBtnText ?? localizations.cancel, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: UiSizes.size_14, + ), ), ), - ), - ], + ], + ), ); } static void showBlurredLoaderDialog(BuildContext context) { - Get.dialog( - BackdropFilter( + showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (dialogCtx) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Center( child: LoadingAnimationWidget.threeRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: MediaQuery.of(context).devicePixelRatio * 20, + color: Theme.of(dialogCtx).colorScheme.primary, + size: MediaQuery.of(dialogCtx).devicePixelRatio * 20, ), ), ), - barrierDismissible: false, ); } } diff --git a/lib/views/screens/create_room_screen.dart b/lib/views/screens/create_room_screen.dart deleted file mode 100644 index 9a280a21..00000000 --- a/lib/views/screens/create_room_screen.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:textfield_tags/textfield_tags.dart'; - -class CreateRoomScreen extends StatelessWidget { - final TabViewController tabViewController = Get.find(); - final CreateRoomController createRoomController = - Get.put(CreateRoomController()); - final UpcomingRoomsController upcomingRoomsController = - Get.put(UpcomingRoomsController()); - - CreateRoomScreen({super.key}); - - @override - Widget build(BuildContext context) { - final BoxDecoration kTextFieldDecoration = BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.secondary.withValues(alpha: 0.8), - const Color.fromARGB(255, 139, 134, 134), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - const BoxShadow( - color: Colors.black12, - offset: Offset(0, 3), - blurRadius: 6, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.3), - offset: const Offset(-2, -2), - blurRadius: 3, - spreadRadius: 1, - ), - ], - ); - - return PopScope( - onPopInvokedWithResult: (didPop, result) async { - tabViewController.setIndex(0); - }, - child: GetBuilder( - builder: (controller) => Obx( - () => Stack( - children: [ - GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && - currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_25), - child: Form( - key: controller.createRoomFormKey, - child: Column( - children: [ - SizedBox(height: UiSizes.height_24_6), - Text( - AppLocalizations.of(context)!.createNewRoom, - style: TextStyle( - fontSize: Get.textScaleFactor * 35, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - SizedBox(height: UiSizes.height_24_6), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Obx( - () => GestureDetector( - onTap: () => - controller.isScheduled.value = false, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_10, - horizontal: UiSizes.width_20, - ), - decoration: BoxDecoration( - color: controller.isScheduled.value - ? Theme.of(context) - .colorScheme - .secondary - .withValues(alpha: 0.5) - : Theme.of( - context, - ).colorScheme.primary, - borderRadius: BorderRadius.circular(15), - ), - child: Text( - AppLocalizations.of(context)!.live, - style: TextStyle( - fontSize: UiSizes.size_14, - color: - Theme.of(context).brightness == - Brightness.light - ? controller.isScheduled.value - ? Colors.black - : Colors.white - : Colors.white, - ), - ), - ), - ), - ), - Obx( - () => GestureDetector( - onTap: () => - controller.isScheduled.value = true, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_10, - horizontal: UiSizes.width_20, - ), - decoration: BoxDecoration( - color: controller.isScheduled.value - ? Theme.of( - context, - ).colorScheme.primary - : Theme.of( - context, - ).colorScheme.secondary, - borderRadius: BorderRadius.circular(15), - ), - child: Text( - AppLocalizations.of(context)!.scheduled, - style: TextStyle( - fontSize: UiSizes.size_14, - color: - Theme.of(context).brightness == - Brightness.light - ? controller.isScheduled.value - ? Colors.white - : Colors.black - : Colors.white, - ), - ), - ), - ), - ), - ], - ), - SizedBox(height: UiSizes.height_24_6), - Obx( - () => controller.isScheduled.value - ? SizedBox( - height: UiSizes.height_66, - child: TextFormField( - style: TextStyle( - fontSize: UiSizes.size_14, - ), - validator: (value) => value!.isNotEmpty - ? null - : AppLocalizations.of( - context, - )!.pleaseEnterScheduledDateTime, - readOnly: true, - controller: upcomingRoomsController - .dateTimeController, - keyboardType: TextInputType.text, - autocorrect: false, - decoration: InputDecoration( - icon: Icon( - Icons.calendar_month, - size: UiSizes.size_23, - ), - labelText: AppLocalizations.of( - context, - )!.scheduleDateTimeLabel, - labelStyle: TextStyle( - fontSize: UiSizes.size_14, - ), - suffix: GestureDetector( - onTap: () async { - await upcomingRoomsController - .chooseDateTime(); - }, - child: const Icon(Icons.date_range), - ), - ), - ), - ) - : const SizedBox(), - ), - Obx( - () => controller.isScheduled.value - ? SizedBox(height: UiSizes.height_33) - : const SizedBox(), - ), - Container( - decoration: kTextFieldDecoration, - child: TextFormField( - controller: controller.nameController, - style: TextStyle(fontSize: UiSizes.size_25), - cursorColor: Theme.of( - context, - ).colorScheme.primary, - maxLength: 30, - minLines: 1, - maxLines: 13, - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.giveGreatName, - - prefixIcon: Icon( - Icons.edit, - color: Theme.of(context).colorScheme.primary, - ), // Icon decoration - filled: false, - border: InputBorder - .none, // No border as it's managed by decoration - contentPadding: const EdgeInsets.all(16), - ), - ), - ), - SizedBox(height: UiSizes.height_33), - Container( - decoration: kTextFieldDecoration, - child: TextFieldTags( - textfieldTagsController: - controller.tagsController, - initialTags: const ['sample-tag'], - textSeparators: const [' ', ','], - letterCase: LetterCase.normal, - validator: (tag) { - return controller.validateTag(tag, context); - }, - inputFieldBuilder: (context, inputFieldValues) { - return TextField( - maxLength: 50, - maxLines: 1, - style: TextStyle(fontSize: UiSizes.size_20), - controller: - inputFieldValues.textEditingController, - focusNode: inputFieldValues.focusNode, - decoration: InputDecoration( - hintText: inputFieldValues.tags.isNotEmpty - ? null - : AppLocalizations.of( - context, - )!.enterTags, - filled: false, - border: InputBorder.none, - contentPadding: const EdgeInsets.all(16), - errorText: inputFieldValues.error, - prefixIconConstraints: BoxConstraints( - maxWidth: UiSizes.width_304, - ), - prefixIcon: inputFieldValues.tags.isNotEmpty - ? SingleChildScrollView( - controller: inputFieldValues - .tagScrollController, - scrollDirection: Axis.horizontal, - child: Row( - children: - inputFieldValues.tags.map(( - tag, - ) { - return Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.all( - Radius.circular( - UiSizes - .size_20, - ), - ), - color: - Colors.black54, - ), - margin: - EdgeInsets.symmetric( - horizontal: - UiSizes - .width_5, - ), - padding: - EdgeInsets.symmetric( - horizontal: - UiSizes - .width_10, - vertical: UiSizes - .height_5, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceBetween, - children: [ - Text( - '#$tag', - style: TextStyle( - color: Colors - .white, - fontSize: UiSizes - .size_18, - ), - ), - SizedBox( - width: UiSizes - .width_4, - ), - InkWell( - child: Icon( - Icons.cancel, - size: UiSizes - .size_18, - color: Colors - .red - .withValues( - alpha: - 0.7, - ), - ), - onTap: () { - inputFieldValues - .onTagRemoved( - tag, - ); - }, - ), - ], - ), - ); - }).toList() - as List, - ), - ) - : null, - ), - onChanged: inputFieldValues.onTagChanged, - onSubmitted: inputFieldValues.onTagSubmitted, - ); - }, - ), - ), - SizedBox(height: UiSizes.height_33), - Container( - decoration: kTextFieldDecoration, - child: TextFormField( - controller: controller.descriptionController, - style: TextStyle(fontSize: UiSizes.size_20), - cursorColor: Theme.of( - context, - ).colorScheme.primary, - maxLines: 10, - maxLength: 500, - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.roomDescriptionOptional, - filled: false, - border: InputBorder.none, - contentPadding: const EdgeInsets.all(16), - ), - ), - ), - ], - ), - ), - ), - ), - ), - controller.isLoading.value - ? BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Center( - child: LoadingAnimationWidget.fourRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: MediaQuery.of(context).devicePixelRatio * 50, - ), - ), - ) - : const SizedBox(), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/home_screen.dart b/lib/views/screens/home_screen.dart index 8e1a6881..7d9c2d0b 100644 --- a/lib/views/screens/home_screen.dart +++ b/lib/views/screens/home_screen.dart @@ -1,41 +1,43 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/models/appwrite_upcomming_room.dart'; -import 'package:resonate/utils/enums/room_state.dart'; -import 'package:resonate/views/screens/no_room_screen.dart'; -import 'package:resonate/views/widgets/live_room_tile.dart'; -import 'package:resonate/views/widgets/search_rooms.dart'; -import 'package:resonate/views/widgets/upcomming_room_tile.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/features/rooms/model/rooms_state.dart'; +import 'package:resonate/features/rooms/view/widgets/live_room_tile.dart'; +import 'package:resonate/features/rooms/view/widgets/no_room_view.dart'; +import 'package:resonate/features/rooms/view/widgets/search_rooms.dart'; +import 'package:resonate/features/rooms/view/widgets/upcoming_room_tile.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; bool isLiveSelected = true; -class HomeScreen extends StatefulWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends State { - final UpcomingRoomsController upcomingRoomsController = Get.put( - UpcomingRoomsController(), - ); - final RoomsController roomsController = Get.put(RoomsController()); +class _HomeScreenState extends ConsumerState { bool _showSearchOverlay = false; + String _upcomingQuery = ''; - Future pullToRefreshData() async { - await upcomingRoomsController.getUpcomingRooms(); - await roomsController.getRooms(); + Future _pullToRefresh() async { + await ref.read(upcomingRoomsProvider.notifier).refresh(); + await ref.read(roomsProvider.notifier).refresh(); } @override Widget build(BuildContext context) { + final roomsAsync = ref.watch(roomsProvider); + final upcomingAsync = ref.watch(upcomingRoomsProvider); + final isSearchingRooms = roomsAsync.value is RoomsStateReady + ? (roomsAsync.value as RoomsStateReady).isSearching + : false; + return Scaffold( body: SafeArea( child: Stack( @@ -47,74 +49,54 @@ class _HomeScreenState extends State { children: [ CustomAppBarLiveRoom( isLiveSelected: isLiveSelected, - onTabSelected: (bool selectedTab) { + onTabSelected: (selectedTab) { setState(() { isLiveSelected = selectedTab; _showSearchOverlay = false; + _upcomingQuery = ''; }); - roomsController.clearLiveSearch(); - upcomingRoomsController.clearUpcomingSearch(); - }, - onSearchTapped: () { - setState(() { - _showSearchOverlay = true; - }); + ref.read(roomsProvider.notifier).clearLiveSearch(); }, + onSearchTapped: () => + setState(() => _showSearchOverlay = true), ), SizedBox(height: UiSizes.height_16), Expanded( child: RefreshIndicator( - onRefresh: pullToRefreshData, - child: Obx( - () => (isLiveSelected - ? roomsController.isLoading.value - ? Center( - child: - LoadingAnimationWidget.fourRotatingDots( - color: Theme.of( - context, - ).colorScheme.primary, - size: MediaQuery.of(context).devicePixelRatio * 20, - ), - ) - : LiveRoomListView() - : upcomingRoomsController.isLoading.value - ? Center( - child: LoadingAnimationWidget.fourRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: MediaQuery.of(context).devicePixelRatio * 20, - ), - ) - : UpcomingRoomsListView()), - ), + onRefresh: _pullToRefresh, + child: isLiveSelected + ? _LiveRoomsListView( + loading: roomsAsync.isLoading, + state: roomsAsync.value, + error: roomsAsync.hasError ? roomsAsync.error : null, + ) + : _UpcomingRoomsListView( + loading: upcomingAsync.isLoading, + rooms: upcomingAsync.value ?? const [], + query: _upcomingQuery, + ), ), ), ], ), ), - // Search bar SearchOverlay( isVisible: _showSearchOverlay, onSearchChanged: (query) { if (isLiveSelected) { - roomsController.searchLiveRooms(query); + ref.read(roomsProvider.notifier).searchLiveRooms(query); } else { - upcomingRoomsController.searchUpcomingRooms(query); + setState(() => _upcomingQuery = query); } }, onClose: () { setState(() { _showSearchOverlay = false; + _upcomingQuery = ''; }); - if (isLiveSelected) { - roomsController.clearLiveSearch(); - } else { - upcomingRoomsController.clearUpcomingSearch(); - } + ref.read(roomsProvider.notifier).clearLiveSearch(); }, - isSearching: isLiveSelected - ? roomsController.isSearching.value - : false, + isSearching: isLiveSelected ? isSearchingRooms : false, ), ], ), @@ -133,6 +115,7 @@ class CustomAppBarLiveRoom extends StatelessWidget { final Function(bool) onTabSelected; final VoidCallback onSearchTapped; final bool isLiveSelected; + @override Widget build(BuildContext context) { return Row( @@ -178,377 +161,159 @@ class CustomAppBarLiveRoom extends StatelessWidget { } } -class UpcomingRoomsListView extends StatelessWidget { - UpcomingRoomsListView({super.key}); - final UpcomingRoomsController upcomingRoomsController = Get.put( - UpcomingRoomsController(), - ); - final RoomsController roomsController = Get.find(); - @override - Widget build(BuildContext context) { - return Obx(() { - final roomsToShow = - upcomingRoomsController.filteredUpcomingRooms.isNotEmpty || - !upcomingRoomsController.searchBarIsEmpty.value - ? upcomingRoomsController.filteredUpcomingRooms - : upcomingRoomsController.upcomingRooms; - - if (roomsToShow.isNotEmpty) { - return ListView.builder( - itemCount: roomsToShow.length, - physics: const BouncingScrollPhysics(), - itemBuilder: (context, index) { - return Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), - child: UpCommingListTile( - appwriteUpcommingRoom: roomsToShow[index], - ), - ); - }, - ); - } else { - return upcomingRoomsController.upcomingRooms.isEmpty && - upcomingRoomsController.searchBarIsEmpty.value - ? const NoRoomScreen(isRoom: false) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: UiSizes.size_65, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), - ), - SizedBox(height: UiSizes.height_16), - Text( - AppLocalizations.of(context)!.noSearchResults, - style: TextStyle( - fontSize: UiSizes.size_16, - fontWeight: FontWeight.w500, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - SizedBox(height: UiSizes.height_8), - Text( - AppLocalizations.of(context)!.clearSearch, - style: TextStyle( - fontSize: UiSizes.size_14, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - ], - ), - ); - } - }); - } -} - -class LiveRoomListView extends StatelessWidget { - LiveRoomListView({super.key}); - final RoomsController roomsController = Get.put(RoomsController()); +class _LiveRoomsListView extends StatelessWidget { + const _LiveRoomsListView({ + required this.loading, + required this.state, + required this.error, + }); + final bool loading; + final RoomsState? state; + final Object? error; @override Widget build(BuildContext context) { - return Obx(() { - final roomsToShow = roomsController.searchBarIsEmpty.value - ? roomsController.rooms - : roomsController.filteredRooms; + if (loading && state == null) { + return Center( + child: LoadingAnimationWidget.fourRotatingDots( + color: Theme.of(context).colorScheme.primary, + size: MediaQuery.of(context).devicePixelRatio * 20, + ), + ); + } + if (state is! RoomsStateReady) { + // Falls through here on error too — show the empty state instead of + // rendering blank or a noisy error UI. + return const NoRoomView(isRoom: true); + } + final ready = state as RoomsStateReady; + final roomsToShow = ready.searchBarIsEmpty ? ready.rooms : ready.filteredRooms; - if (roomsController.isSearching.value) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LoadingAnimationWidget.fourRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: UiSizes.size_20, - ), - SizedBox(height: UiSizes.height_16), - Text( - AppLocalizations.of(context)!.searchingRooms, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), + if (ready.isSearching) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimationWidget.fourRotatingDots( + color: Theme.of(context).colorScheme.primary, + size: UiSizes.size_20, + ), + SizedBox(height: UiSizes.height_16), + Text( + AppLocalizations.of(context)!.searchingRooms, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), ), - ], - ), - ); - } + ), + ], + ), + ); + } - if (roomsToShow.isNotEmpty) { - return ListView.builder( - physics: const BouncingScrollPhysics(), - itemCount: roomsToShow.length, - itemBuilder: (context, index) { - return Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), - child: CustomLiveRoomTile(appwriteRoom: roomsToShow[index]), - ); - }, - ); - } else { - return roomsController.searchBarIsEmpty.value - ? const NoRoomScreen(isRoom: true) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: UiSizes.size_65, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), - ), - SizedBox(height: UiSizes.height_16), - Text( - AppLocalizations.of(context)!.noSearchResults, - style: TextStyle( - fontSize: UiSizes.size_16, - fontWeight: FontWeight.w500, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - SizedBox(height: UiSizes.height_8), - Text( - AppLocalizations.of(context)!.clearSearch, - style: TextStyle( - fontSize: UiSizes.size_14, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - ], - ), - ); - } - }); + if (roomsToShow.isNotEmpty) { + return ListView.builder( + physics: const BouncingScrollPhysics(), + itemCount: roomsToShow.length, + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), + child: CustomLiveRoomTile(appwriteRoom: roomsToShow[index]), + ); + }, + ); + } + return ready.searchBarIsEmpty + ? const NoRoomView(isRoom: true) + : _NoSearchResults(); } } -class CustomCategoryChips extends StatefulWidget { - const CustomCategoryChips({super.key, required this.categories}); - - final List categories; - - @override - State createState() => _CustomCategoryChipsState(); -} +class _UpcomingRoomsListView extends StatelessWidget { + const _UpcomingRoomsListView({ + required this.loading, + required this.rooms, + required this.query, + }); + final bool loading; + final List rooms; + final String query; -class _CustomCategoryChipsState extends State { - int selectedIndex = 0; + List _filtered() { + if (query.isEmpty) return rooms; + final lower = query.toLowerCase(); + return rooms.where((r) { + return r.name.toLowerCase().contains(lower) || + r.description.toLowerCase().contains(lower); + }).toList(); + } @override Widget build(BuildContext context) { - return SizedBox( - height: 80, - width: MediaQuery.of(context).size.width / 1.2, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: widget.categories.length, - itemBuilder: (context, index) { - return CategoryItem( - isSelected: index == selectedIndex ? true : false, - category: widget.categories[index], - onTap: () { - setState(() { - selectedIndex = index; - }); - }, - ); - }, + if (loading) { + return Center( + child: LoadingAnimationWidget.fourRotatingDots( + color: Theme.of(context).colorScheme.primary, + size: MediaQuery.of(context).devicePixelRatio * 20, ), - ), - ); + ); + } + final roomsToShow = _filtered(); + + if (roomsToShow.isNotEmpty) { + return ListView.builder( + itemCount: roomsToShow.length, + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), + child: UpcomingListTile(appwriteUpcomingRoom: roomsToShow[index]), + ); + }, + ); + } + return rooms.isEmpty && query.isEmpty + ? const NoRoomView(isRoom: false) + : _NoSearchResults(); } } -class CategoryItem extends StatelessWidget { - final String category; - final bool isSelected; - final VoidCallback? onTap; - - const CategoryItem({ - super.key, - required this.category, - this.isSelected = false, - this.onTap, - }); - +class _NoSearchResults extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: GestureDetector( - onTap: onTap, - child: Chip( - labelPadding: const EdgeInsets.symmetric(horizontal: 20), - color: isSelected - ? WidgetStatePropertyAll(Theme.of(context).colorScheme.secondary) - : const WidgetStatePropertyAll(Colors.transparent), - side: BorderSide.none, - label: Text( - category, + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: UiSizes.size_65, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + SizedBox(height: UiSizes.height_16), + Text( + AppLocalizations.of(context)!.noSearchResults, style: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onTertiary, + fontSize: UiSizes.size_16, + fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), ), ), - backgroundColor: Colors.transparent, - labelStyle: const TextStyle(color: Colors.white), - ), + SizedBox(height: UiSizes.height_8), + Text( + AppLocalizations.of(context)!.clearSearch, + style: TextStyle( + fontSize: UiSizes.size_14, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], ), ); } } - -List upcomingRoomDummyData = [ - AppwriteUpcommingRoom( - id: 'room1', - name: 'Flutter Devs Meetup', - isTime: true, - scheduledDateTime: DateTime(2024, 10, 10, 14, 30), - description: - 'A meetup for Flutter developers to discuss the latest trends in Flutter development.', - totalSubscriberCount: 2, - tags: ['flutter', 'development', 'mobile'], - subscribersAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - userIsCreator: false, - hasUserSubscribed: true, - ), - AppwriteUpcommingRoom( - id: 'room4', - name: 'Flutter Devs Meetup', - isTime: true, - scheduledDateTime: DateTime(2024, 10, 10, 14, 30), - description: - 'A meetup for Flutter developers to discuss the latest trends in Flutter development.', - totalSubscriberCount: 2, - tags: ['flutter', 'development', 'mobile'], - subscribersAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - userIsCreator: false, - hasUserSubscribed: false, - ), - AppwriteUpcommingRoom( - id: 'room2', - name: 'Appwrite Deep Dive', - isTime: true, - scheduledDateTime: DateTime(2024, 11, 5, 10, 00), - description: - 'Learn the ins and outs of Appwrite in this deep dive session.', - totalSubscriberCount: 3, - tags: ['appwrite', 'backend', 'open-source'], - subscribersAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - userIsCreator: true, - hasUserSubscribed: false, - ), - AppwriteUpcommingRoom( - id: 'room3', - name: 'Dart & Appwrite Integration', - isTime: false, - scheduledDateTime: DateTime(2024, 10, 15, 16, 00), - description: - 'Exploring the integration of Dart with Appwrite for seamless app development.', - totalSubscriberCount: 3, - tags: ['dart', 'appwrite', 'integration'], - subscribersAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - userIsCreator: true, - hasUserSubscribed: false, - ), -]; - -List appwriteRooms = [ - AppwriteRoom( - id: 'room_001', - name: 'Flutter Dev Room', - description: 'Discuss all things Flutter development.', - totalParticipants: 3, - tags: ['flutter', 'development', 'mobile'], - memberAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - state: RoomState.live, - isUserAdmin: true, - myDocId: 'doc123', - reportedUsers: [], - ), - AppwriteRoom( - id: 'room_002', - name: 'Appwrite Enthusiasts', - description: 'A room for Appwrite fans to share and learn.', - totalParticipants: 3, - tags: ['appwrite', 'open-source', 'backend'], - memberAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - state: RoomState.live, - isUserAdmin: false, - myDocId: 'doc456', - reportedUsers: [], - ), - AppwriteRoom( - id: 'room_003', - name: 'Backend Developers Hub', - description: 'Discuss modern backend architectures and APIs.', - totalParticipants: 3, - tags: ['backend', 'architecture', 'api'], - memberAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - state: RoomState.live, - isUserAdmin: false, - myDocId: null, - reportedUsers: [], - ), - AppwriteRoom( - id: 'room_004', - name: 'Mobile Dev Summit', - description: 'A discussion room for mobile development trends.', - totalParticipants: 3, - tags: ['mobile', 'development', 'summit'], - memberAvatarUrls: [ - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - 'https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp', - ], - state: RoomState.live, - isUserAdmin: true, - myDocId: 'doc789', - reportedUsers: [], - ), -]; diff --git a/lib/views/screens/pair_chat_screen.dart b/lib/views/screens/pair_chat_screen.dart index c1705df5..068e2674 100644 --- a/lib/views/screens/pair_chat_screen.dart +++ b/lib/views/screens/pair_chat_screen.dart @@ -3,8 +3,8 @@ import 'package:get/get.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/themes/theme_controller.dart'; import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/room_app_bar.dart'; -import 'package:resonate/views/widgets/room_header.dart'; +import 'package:resonate/features/rooms/view/widgets/room_app_bar.dart'; +import 'package:resonate/features/rooms/view/widgets/room_header.dart'; import 'package:resonate/l10n/app_localizations.dart'; import '../../controllers/pair_chat_controller.dart'; diff --git a/lib/views/screens/room_chat_screen.dart b/lib/views/screens/room_chat_screen.dart deleted file mode 100644 index e697f9e6..00000000 --- a/lib/views/screens/room_chat_screen.dart +++ /dev/null @@ -1,629 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/room_chat_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -import 'package:resonate/models/message.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/utils/extensions/datetime_extension.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class RoomChatScreen extends StatefulWidget { - const RoomChatScreen({super.key}); - - @override - State createState() => _RoomChatScreenState(); -} - -class _RoomChatScreenState extends State { - final ScrollController _scrollController = ScrollController(); - final RoomChatController chatController = Get.find(); - final double itemHight = 80; - late Future loadMessagesFuture; - - void scrollToMessage(int index) { - _scrollController.animateTo( - index * itemHight, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - - void scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } - - Future updateMessage(int index, String newContent) async { - await chatController.editMessage( - chatController.messages[index].messageId, - newContent, - ); - } - - @override - void initState() { - super.initState(); - - loadMessagesFuture = chatController.loadMessages(); - - // Listen to changes in messages and scroll to bottom when new messages are added - ever(chatController.messages, (messages) { - if (messages.isNotEmpty) { - scrollToBottom(); - } - }); - } - - @override - void dispose() { - Get.delete(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - Navigator.pop(context); - }, - ), - title: const Text('Room Chat'), - centerTitle: true, - actions: [ - IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), - ], - ), - body: Column( - children: [ - Expanded( - child: FutureBuilder( - future: loadMessagesFuture, - builder: (context, asyncSnapshot) { - if (asyncSnapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } else if (asyncSnapshot.hasError) { - return Center(child: Text('Error: ${asyncSnapshot.error}')); - } else { - // Scroll to bottom after messages are loaded - WidgetsBinding.instance.addPostFrameCallback((_) { - scrollToBottom(); - }); - - return Obx( - () => ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16.0), - itemCount: chatController.messages.length, - itemBuilder: (context, index) { - return ChatMessageItem( - message: chatController.messages[index], - onTapReply: (int replyIndex) => - scrollToMessage(replyIndex), - onEditMessage: (String newContent) async { - await updateMessage(index, newContent); - }, - onDeleteMessage: (String messageId) async { - try { - await chatController.deleteMessage(messageId); - customSnackbar( - AppLocalizations.of(context)!.success, - AppLocalizations.of(context)!.delete, - LogType.success, - ); - } catch (e) { - customSnackbar( - AppLocalizations.of(context)!.error, - AppLocalizations.of( - context, - )!.failedToDeleteMessage, - LogType.error, - ); - } - }, - replytoMessage: (Message message) => - chatController.setReplyingTo(message), - canEdit: - requireCurrentAuthUser.uid == - chatController.messages[index].creatorId && - !chatController.messages[index].isDeleted && - !chatController.messages[index].isEdited, - - canDelete: - requireCurrentAuthUser.uid == - chatController.messages[index].creatorId && - !chatController.messages[index].isDeleted, - ); - }, - ), - ); - } - }, - ), - ), - ChatInputField(), - ], - ), - ); - } -} - -class ChatMessageItem extends StatefulWidget { - final Message message; - final void Function(int) onTapReply; - final void Function(String) onEditMessage; - final void Function(Message) replytoMessage; - final void Function(String) onDeleteMessage; - final bool canDelete; - final bool canEdit; - - const ChatMessageItem({ - super.key, - required this.message, - required this.onTapReply, - required this.onEditMessage, - required this.replytoMessage, - required this.canEdit, - required this.onDeleteMessage, - required this.canDelete, - }); - - @override - ChatMessageItemState createState() => ChatMessageItemState(); -} - -class ChatMessageItemState extends State { - bool isEditing = false; - late TextEditingController _editingController; - - double _dragOffset = 0.0; - @override - void initState() { - super.initState(); - _editingController = TextEditingController(text: widget.message.content); - } - - @override - void dispose() { - _editingController.dispose(); - super.dispose(); - } - - void startEditing() { - if (widget.message.isDeleted) { - return; - } - setState(() { - isEditing = true; - }); - _editingController.selection = TextSelection.fromPosition( - TextPosition(offset: _editingController.text.length), - ); - } - - void saveEdit() { - widget.onEditMessage(_editingController.text); - setState(() { - isEditing = false; - }); - } - - void cancelEdit() { - setState(() { - isEditing = false; - }); - _editingController.text = widget.message.content; - } - - void _showMessageContextMenu(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Wrap( - children: [ - if (widget.canDelete) - ///delete option - ListTile( - leading: Icon( - Icons.delete, - color: Theme.of(context).colorScheme.error, - ), - title: Text(AppLocalizations.of(context)!.delete), - - onTap: () { - Navigator.pop(context); - _confirmDelete(context); - }, - ), - - ///cancel option - ListTile( - leading: const Icon(Icons.close), - title: Text(AppLocalizations.of(context)!.cancel), - onTap: () => Navigator.pop(context), - ), - ], - ), - ); - }, - ); - } - - void _confirmDelete(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.deleteMessageTitle), - content: Text(AppLocalizations.of(context)!.deleteMessageContent), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(AppLocalizations.of(context)!.cancel), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - widget.onDeleteMessage(widget.message.messageId); - }, - child: Text(AppLocalizations.of(context)!.delete), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - GestureDetector( - onLongPress: () => _showMessageContextMenu(context), - onHorizontalDragUpdate: (details) { - if (_dragOffset + details.delta.dx > 0.0 && - _dragOffset + details.delta.dx < 100) { - _dragOffset += details.delta.dx; - } else if (_dragOffset + details.delta.dx > 100) { - _dragOffset = 100; - } else if (_dragOffset + details.delta.dx < 0) { - _dragOffset = 0.0; // Reset if dragging left - } - setState(() {}); - }, - onHorizontalDragEnd: (details) { - if (_dragOffset > 70) { - // Detected swipe from left to right - widget.replytoMessage(widget.message); - } - setState(() { - _dragOffset = 0.0; // Reset offset after swipe - }); - }, - onDoubleTap: widget.canEdit ? startEditing : null, - child: Transform.translate( - offset: Offset(_dragOffset, 0), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(8.0), - - decoration: BoxDecoration( - color: widget.message.isDeleted - ? Theme.of(context).colorScheme.secondaryContainer - : Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(8.0), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 20, - backgroundImage: NetworkImage( - widget.message.creatorImgUrl, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.message.creatorName.capitalizeFirst!, - style: TextStyle( - fontWeight: FontWeight.bold, - color: widget.message.isDeleted - ? Theme.of( - context, - ).colorScheme.onSurfaceVariant - : Theme.of( - context, - ).colorScheme.secondary, - ), - ), - const SizedBox(height: 5), - if (widget.message.replyTo != null) - GestureDetector( - onTap: () => widget.onTapReply( - widget.message.replyTo!.index, - ), - child: Container( - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only( - bottom: 5, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "@${widget.message.replyTo!.creatorUsername}", - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - Text( - widget.message.replyTo!.content, - maxLines: 1, - style: TextStyle( - color: Colors.black, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - if (isEditing) - ///the message to edit it AND the message is not deleted - Focus( - onKeyEvent: (node, event) { - if (event.logicalKey == - LogicalKeyboardKey.escape) { - cancelEdit(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TextField( - controller: _editingController, - autofocus: true, - onSubmitted: (value) => saveEdit(), - onTapOutside: (event) => cancelEdit(), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - 5, - ), - ), - contentPadding: - const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - ), - ), - ) - else if (widget.message.isDeleted) - ///The message has been deleted (`isDeleted = true`) - Text( - AppLocalizations.of( - context, - )!.thisMessageWasDeleted, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ) - else - Row( - children: [ - Expanded( - child: Text( - widget.message.content, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.secondary, - ), - ), - ), - if (widget.message.isEdited) - const Text( - ' (edited)', - style: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: Colors.grey, - ), - ), - ], - ), - - const SizedBox(height: 5), - Text( - widget.message.creationDateTime - .formatDateTime(context) - .toString(), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - Positioned( - bottom: (constraints.minHeight) / 2, - top: constraints.minHeight / 2, - child: Transform.translate( - offset: Offset(_dragOffset - 70, 0), - child: Opacity( - opacity: (_dragOffset / 100).clamp(0.0, 1.0), - child: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, - child: Icon( - Icons.reply, - color: Theme.of(context).colorScheme.secondary, - ), - ), - ), - ), - ), - ], - ); - }, - ); - } -} - -class ChatInputField extends StatelessWidget { - ChatInputField({super.key}); - - final RoomChatController chatController = Get.find(); - final TextEditingController _messageController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return Obx( - () => Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - if (chatController.replyingTo.value != null) - Row( - children: [ - Expanded( - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only(bottom: 5), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "@${chatController.replyingTo.value?.creatorUsername ?? ""}", - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - Text( - chatController.replyingTo.value?.content ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - chatController.clearReplyingTo(); - }, - ), - ], - ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - onSubmitted: (value) async { - if (_messageController.text.isNotEmpty) { - await chatController.sendMessage( - _messageController.text, - ); - _messageController.clear(); - // Clear reply if user sends a message - chatController.clearReplyingTo(); - } - }, - decoration: InputDecoration( - hintText: 'Say Something', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 5, - ), - ), - ), - ), - const SizedBox(width: 10), - IconButton( - icon: const Icon(Icons.send), - onPressed: () async { - if (_messageController.text.isNotEmpty) { - await chatController.sendMessage( - _messageController.text, - ); - _messageController.clear(); - // Clear reply if user sends a message - chatController.clearReplyingTo(); - } - }, - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/views/screens/room_screen.dart b/lib/views/screens/room_screen.dart deleted file mode 100644 index d4de3317..00000000 --- a/lib/views/screens/room_screen.dart +++ /dev/null @@ -1,303 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/controllers/single_room_controller.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/participant_block.dart'; -import 'package:resonate/views/widgets/room_app_bar.dart'; -import 'package:resonate/views/widgets/room_header.dart'; -import 'package:resonate/views/widgets/audio_selector_dialog.dart'; - -class RoomScreen extends StatefulWidget { - final AppwriteRoom room; - - const RoomScreen({super.key, required this.room}); - - @override - RoomScreenState createState() => RoomScreenState(); -} - -class RoomScreenState extends State { - late final SingleRoomController controller; - - @override - void initState() { - super.initState(); - Get.put(SingleRoomController(appwriteRoom: widget.room)); - } - - Future _deleteRoomDialog(String text, Function() onTap) async { - return await Get.defaultDialog( - title: AppLocalizations.of(context)!.areYouSure, - buttonColor: Theme.of(context).colorScheme.primary, - middleText: AppLocalizations.of(context)!.toRoomAction(text), - cancelTextColor: Theme.of(context).colorScheme.primary, - textConfirm: AppLocalizations.of(context)!.confirm, - textCancel: AppLocalizations.of(context)!.cancel, - onConfirm: onTap, - onCancel: () => log(AppLocalizations.of(context)!.canceled), - ); - } - - String _getTags() { - if (widget.room.tags.isEmpty) { - return ""; - } - String tagString = widget.room.tags[0] ?? ""; - for (var tag in widget.room.tags.sublist(1)) { - tagString += " · $tag"; - } - return tagString; - } - - @override - Widget build(BuildContext context) { - SingleRoomController controller = Get.find(); - - return Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const RoomAppBar(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: RoomHeader( - roomName: widget.room.name, - roomDescription: widget.room.description, - roomTags: _getTags(), - ), - ), - SizedBox(height: UiSizes.height_7), - Expanded(child: _buildParticipantsList(controller)), - ], - ), - ); - } - - Widget _buildParticipantsList(SingleRoomController controller) { - return Obx(() { - if (controller.isLoading.value) { - return Center( - child: LoadingAnimationWidget.threeRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: MediaQuery.of(context).devicePixelRatio * 20, - ), - ); - } else { - return Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of( - context, - ).colorScheme.onSecondary.withValues(alpha: 0.15), - ), - ), - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildParticipantsSection( - title: AppLocalizations.of(context)!.participants, - controller: controller, - ), - ], - ), - ), - _buildFooter(), - ], - ); - } - }); - } - - Widget _buildParticipantsSection({ - required String title, - required SingleRoomController controller, - }) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: UiSizes.size_18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Obx(() { - return SizedBox( - height: double.maxFinite, - width: 400, - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: UiSizes.width_20, - mainAxisSpacing: UiSizes.height_5, - childAspectRatio: 2.5 / 3, - ), - itemCount: controller.participants.length, - itemBuilder: (ctx, index) { - return GetBuilder( - init: SingleRoomController(appwriteRoom: widget.room), - builder: (controller) => ParticipantBlock( - participant: controller.participants[index].value, - controller: controller, - ), - ); - }, - ), - ); - }), - ], - ), - ); - } - - Widget _buildFooter() { - return Align( - alignment: Alignment.bottomCenter, - child: Container( - height: MediaQuery.of(context).size.height * 0.07, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadiusDirectional.circular(24), - color: Theme.of(context).colorScheme.surface, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildLeaveButton(), - _buildMicButton(), - _buildRaiseHandButton(), - _buildAudioSettingsButton(), - _buildChatButton(), - ], - ), - ), - ); - } - - Widget _buildLeaveButton() { - return GetBuilder( - init: SingleRoomController(appwriteRoom: widget.room), - builder: (controller) { - return ElevatedButton( - onPressed: () async { - await _deleteRoomDialog( - controller.appwriteRoom.isUserAdmin - ? AppLocalizations.of(context)!.delete - : AppLocalizations.of(context)!.leave, - () async { - if (controller.appwriteRoom.isUserAdmin) { - await controller.deleteRoom(); - } else { - await controller.leaveRoom(); - } - }, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: const Icon(Icons.call_end, size: 24), - ); - }, - ); - } - - Widget _buildRaiseHandButton() { - return GetBuilder( - init: SingleRoomController(appwriteRoom: widget.room), - builder: (controller) { - final bool hasRequestedToBeSpeaker = - controller.me.value.hasRequestedToBeSpeaker; - - return FloatingActionButton( - onPressed: () { - if (hasRequestedToBeSpeaker) { - controller.unRaiseHand(); - } else { - controller.raiseHand(); - } - }, - backgroundColor: hasRequestedToBeSpeaker - ? Theme.of(context).colorScheme.primary - : Theme.of(context).brightness == Brightness.light - ? Colors.white - : Colors.black54, - child: Icon( - hasRequestedToBeSpeaker - ? Icons.back_hand - : Icons.back_hand_outlined, - color: hasRequestedToBeSpeaker - ? Colors.black - : Theme.of(context).brightness == Brightness.light - ? Colors.black - : Colors.white54, - ), - ); - }, - ); - } - - Widget _buildMicButton() { - return GetBuilder( - init: SingleRoomController(appwriteRoom: widget.room), - builder: (controller) { - final bool isMicOn = controller.me.value.isMicOn; - final bool isSpeaker = controller.me.value.isSpeaker; - - return FloatingActionButton( - onPressed: () { - if (isSpeaker) { - if (isMicOn) { - controller.turnOffMic(); - } else { - controller.turnOnMic(); - } - } - }, - backgroundColor: isMicOn ? Colors.lightGreen : Colors.redAccent, - child: Icon(isMicOn ? Icons.mic : Icons.mic_off, color: Colors.black), - ); - }, - ); - } - - Widget _buildChatButton() { - return GetBuilder( - init: SingleRoomController(appwriteRoom: widget.room), - builder: (controller) { - return FloatingActionButton( - onPressed: () { - controller.openChatSheet(); - }, - backgroundColor: Colors.redAccent, - child: Icon(Icons.chat, color: Colors.black), - ); - }, - ); - } - - Widget _buildAudioSettingsButton() { - return FloatingActionButton( - onPressed: () async => await showAudioDeviceSelector(context), - backgroundColor: Theme.of(context).colorScheme.onSecondary, - child: const Icon(Icons.volume_up), - ); - } -} diff --git a/lib/views/screens/tabview_screen.dart b/lib/views/screens/tabview_screen.dart index 474b3387..d5467057 100644 --- a/lib/views/screens/tabview_screen.dart +++ b/lib/views/screens/tabview_screen.dart @@ -2,44 +2,68 @@ import 'dart:developer'; import 'package:animated_bottom_navigation_bar/animated_bottom_navigation_bar.dart'; import 'package:flutter/material.dart'; - +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:get/get.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; +import 'package:go_router/go_router.dart'; import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:go_router/go_router.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; +import 'package:resonate/features/rooms/view/pages/create_room_page.dart'; +import 'package:resonate/features/rooms/view/pages/room_page.dart'; +import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/themes/theme_controller.dart'; import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/screens/create_room_screen.dart'; +import 'package:resonate/utils/utils.dart'; import 'package:resonate/views/screens/explore_screen.dart'; import 'package:resonate/views/screens/home_screen.dart'; +import 'package:resonate/views/widgets/pair_chat_dialog.dart'; import 'package:resonate/views/widgets/profile_avatar.dart'; -import '../../utils/utils.dart'; -import '../widgets/pair_chat_dialog.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class TabViewScreen extends StatelessWidget { - final CreateRoomController createRoomController = - Get.find(); - final TabViewController controller = Get.find(); - final RoomsController roomsController = Get.find(); - final upcomingRoomsController = Get.put( - UpcomingRoomsController(), - ); +class TabViewScreen extends ConsumerStatefulWidget { + const TabViewScreen({super.key}); - final ThemeController themeController = Get.find(); + @override + ConsumerState createState() => _TabViewScreenState(); +} - // Add a reactive variable to track room creation state - final RxBool isRoomCreating = false.obs; +class _TabViewScreenState extends ConsumerState { + final TabViewController _tabController = Get.find(); + bool _isRoomCreating = false; - TabViewScreen({super.key}); + Future _onDonePressed(BuildContext context) async { + final index = _tabController.getIndex(); + if (index == 2) { + if (_isRoomCreating) return; + setState(() => _isRoomCreating = true); + try { + final room = await createRoomFormKey.currentState?.submit(); + if (!context.mounted) return; + if (room != null) { + await openRoomSheet(context, room); + } + _tabController.setIndex(0); + if (createRoomFormKey.currentState?.isScheduled ?? false) { + isLiveSelected = false; + } + } catch (e) { + log('Room creation error: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create room: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isRoomCreating = false); + } + } else { + context.push(RoutePaths.createStoryScreen); + } + } @override Widget build(BuildContext context) { @@ -49,7 +73,6 @@ class TabViewScreen extends StatelessWidget { toolbarHeight: UiSizes.size_56, automaticallyImplyLeading: false, title: Text( - // "Resonate", AppLocalizations.of(context)!.title, style: TextStyle( fontSize: UiSizes.size_26, @@ -57,21 +80,9 @@ class TabViewScreen extends StatelessWidget { ), ), centerTitle: false, - actions: [ - // Add the notification icon when notification feature is done - - // SizedBox( - // height: UiSizes.height_45, - // child: const Icon( - // Icons.notifications_none_rounded, - // size: 30, - // ), - // ), - // const SizedBox(width: 15), - profileAvatar(context), - ], + actions: [profileAvatar(context)], ), - floatingActionButton: (controller.getIndex() == 0) + floatingActionButton: (_tabController.getIndex() == 0) ? SpeedDial( icon: Icons.add_call, childrenButtonSize: Size(UiSizes.width_56, UiSizes.height_56), @@ -87,7 +98,7 @@ class TabViewScreen extends StatelessWidget { labelStyle: TextStyle(fontSize: UiSizes.size_14), onTap: () async { if (requireCurrentAuthUser.isEmailVerified) { - controller.setIndex(2); + _tabController.setIndex(2); } else { AppUtils.showDialog( context: context, @@ -98,13 +109,15 @@ class TabViewScreen extends StatelessWidget { context, )!.emailVerificationMessage, onFirstBtnPressed: () { - Get.back(); + Navigator.of(context).pop(); rootContainer .read(emailVerifyProvider.notifier) - .sendOtp(email: requireCurrentAuthUser.email); + .sendOtp( + email: requireCurrentAuthUser.email, + ); AppUtils.showBlurredLoaderDialog(context); }, - onSecondBtnPressed: () => Get.back(), + onSecondBtnPressed: () => Navigator.of(context).pop(), firstBtnText: AppLocalizations.of(context)!.verify, ); } @@ -126,66 +139,8 @@ class TabViewScreen extends StatelessWidget { ) : FloatingActionButton( shape: const CircleBorder(), - // Disable the button during room creation - onPressed: isRoomCreating.value - ? null - : () async { - if (controller.getIndex() == 2) { - // Validate form before creation - if (!createRoomController - .createRoomFormKey - .currentState! - .validate()) { - return; - } - - // Prevent multiple room creations - if (isRoomCreating.value) return; - - try { - // Set room creation state to true - isRoomCreating.value = true; - - if (createRoomController.isScheduled.value) { - createRoomController.isLoading.value = true; - await upcomingRoomsController - .createUpcomingRoom(); - await upcomingRoomsController.getUpcomingRooms(); - createRoomController.isLoading.value = false; - isLiveSelected = false; - controller.setIndex(0); - } else { - await createRoomController.createRoom( - createRoomController.nameController.text, - createRoomController.descriptionController.text, - createRoomController.tagsController.getTags! - .map((item) => item.toString()) - .toList(), - true, - ); - await roomsController.getRooms(); - controller.setIndex(0); - } - } catch (e) { - // Handle any errors during room creation - log('Room creation error: $e'); - // Show an error dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to create room: $e'), - backgroundColor: Colors.red, - ), - ); - } finally { - // Always reset the room creation state - isRoomCreating.value = false; - } - } else { - context.push(RoutePaths.createStoryScreen); - } - }, - // Change the button's appearance when creating a room - child: isRoomCreating.value + onPressed: _isRoomCreating ? null : () => _onDonePressed(context), + child: _isRoomCreating ? SizedBox( width: 24, height: 24, @@ -195,7 +150,7 @@ class TabViewScreen extends StatelessWidget { ), ) : Icon( - controller.getIndex() == 2 + _tabController.getIndex() == 2 ? Icons.done : Icons.audiotrack_rounded, size: UiSizes.size_24, @@ -207,8 +162,8 @@ class TabViewScreen extends StatelessWidget { activeColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.secondary, inactiveColor: Theme.of(context).brightness == Brightness.light - ? Colors.black.withAlpha(30) // Fixed withValues to withOpacity - : Colors.white.withAlpha(30), // Fixed withValues to withOpacity + ? Colors.black.withAlpha(30) + : Colors.white.withAlpha(30), splashRadius: 0, shadow: const Shadow(color: Colors.transparent), iconSize: UiSizes.size_30, @@ -216,18 +171,18 @@ class TabViewScreen extends StatelessWidget { leftCornerRadius: 30.0, rightCornerRadius: 30.0, notchMargin: UiSizes.size_8, - activeIndex: controller.getIndex(), + activeIndex: _tabController.getIndex(), gapLocation: GapLocation.center, notchSmoothness: NotchSmoothness.defaultEdge, borderWidth: 0.0, borderColor: Colors.transparent, - onTap: (index) => controller.setIndex(index), + onTap: (index) => _tabController.setIndex(index), ), - body: (controller.getIndex() == 0) + body: (_tabController.getIndex() == 0) ? const HomeScreen() - : (controller.getIndex() == 2) - ? CreateRoomScreen() - : const ExploreScreen(), + : (_tabController.getIndex() == 2) + ? CreateRoomPage() + : const ExploreScreen(), ), ); } diff --git a/lib/views/widgets/participant_block.dart b/lib/views/widgets/participant_block.dart deleted file mode 100644 index 87321665..00000000 --- a/lib/views/widgets/participant_block.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:focused_menu/focused_menu.dart'; -import 'package:focused_menu/modals.dart'; -import 'package:resonate/controllers/single_room_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -import '../../models/participant.dart'; - -class FocusedMenuItemData { - final String textContent; - final Function action; - - FocusedMenuItemData(this.textContent, this.action); -} - -class ParticipantBlock extends StatelessWidget { - const ParticipantBlock({ - super.key, - required this.participant, - required this.controller, - }); - - final Participant participant; - final SingleRoomController controller; - String getUserRole(BuildContext context) { - if (participant.isAdmin) { - return AppLocalizations.of(context)!.admin; - } else if (participant.isModerator) { - return AppLocalizations.of(context)!.moderator; - } else if (participant.isSpeaker) { - return AppLocalizations.of(context)!.speaker; - } else { - return AppLocalizations.of(context)!.listener; - } - } - - List makeMenuItems( - List items, - Brightness currentBrightness, - ) { - return items - .map( - (item) => FocusedMenuItem( - title: Text( - item.textContent, - style: TextStyle(fontSize: UiSizes.size_14), - ), - trailingIcon: Icon( - Icons.remove_circle_outline, - color: Colors.red, - size: UiSizes.size_18, - ), - onPressed: item.action, - backgroundColor: currentBrightness == Brightness.light - ? Colors.white - : Colors.black, - ), - ) - .toList(); - } - - List getMenuItems( - Brightness currentBrightness, - BuildContext context, - ) { - if ((!controller.me.value.isAdmin && !controller.me.value.isModerator) || - participant.isAdmin) { - return []; - } - - if (controller.me.value.isAdmin) { - if (participant.isModerator) { - return makeMenuItems([ - FocusedMenuItemData( - AppLocalizations.of(context)!.removeModerator, - () { - controller.removeModerator(participant); - }, - ), - FocusedMenuItemData(AppLocalizations.of(context)!.kickOut, () { - controller.kickOutParticipant(participant); - }), - FocusedMenuItemData( - AppLocalizations.of(context)!.reportParticipant, - () { - controller.reportParticipant(participant); - }, - ), - ], currentBrightness); - } else { - return makeMenuItems([ - FocusedMenuItemData(AppLocalizations.of(context)!.addModerator, () { - controller.makeModerator(participant); - }), - if (participant.hasRequestedToBeSpeaker) - FocusedMenuItemData(AppLocalizations.of(context)!.addSpeaker, () { - controller.makeSpeaker(participant); - }), - if (participant.isSpeaker) - FocusedMenuItemData(AppLocalizations.of(context)!.makeListener, () { - controller.makeListener(participant); - }), - FocusedMenuItemData(AppLocalizations.of(context)!.kickOut, () { - controller.kickOutParticipant(participant); - }), - FocusedMenuItemData( - AppLocalizations.of(context)!.reportParticipant, - () { - controller.reportParticipant(participant); - }, - ), - ], currentBrightness); - } - } - - if (controller.me.value.isModerator) { - if (participant.isModerator) { - return []; - } else { - return makeMenuItems([ - if (participant.hasRequestedToBeSpeaker) - FocusedMenuItemData(AppLocalizations.of(context)!.addSpeaker, () { - controller.makeSpeaker(participant); - }), - if (participant.isSpeaker) - FocusedMenuItemData(AppLocalizations.of(context)!.makeListener, () { - controller.makeListener(participant); - }), - FocusedMenuItemData(AppLocalizations.of(context)!.kickOut, () { - controller.kickOutParticipant(participant); - }), - FocusedMenuItemData( - AppLocalizations.of(context)!.reportParticipant, - () { - controller.reportParticipant(participant); - }, - ), - ], currentBrightness); - } - } - - return []; - } - - @override - Widget build(BuildContext context) { - Brightness currentBrightness = Theme.of(context).brightness; - - return FocusedMenuHolder( - onPressed: () {}, - menuItemExtent: UiSizes.width_45, - menuWidth: UiSizes.width_200 * 1.05, - menuBoxDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(5.0), - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: UiSizes.width_1, - ), - ), - duration: const Duration(milliseconds: 100), - animateMenuItems: true, - blurBackgroundColor: currentBrightness == Brightness.light - ? Colors.white54 - : Colors.black54, - menuItems: getMenuItems(currentBrightness, context), - openWithTap: - ((controller.me.value.isAdmin || - (controller.me.value.isModerator && - !participant.isModerator)) && - !participant.isAdmin) - ? true - : false, - child: Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_2, - horizontal: UiSizes.width_2, - ), - alignment: Alignment.center, - child: Column( - children: [ - CircleAvatar( - radius: UiSizes.size_32, - backgroundColor: Theme.of(context).colorScheme.primary, - child: CircleAvatar( - backgroundImage: NetworkImage(participant.dpUrl), - radius: UiSizes.size_30, - child: (participant.hasRequestedToBeSpeaker) - ? Stack( - children: [ - Align( - alignment: Alignment.topRight, - child: Icon( - Icons.waving_hand_rounded, - color: Theme.of(context).colorScheme.primary, - size: UiSizes.size_20, - ), - ), - ], - ) - : null, - ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (participant.isSpeaker) - Icon( - (participant.isMicOn) ? Icons.mic : Icons.mic_off, - color: (participant.isMicOn) - ? Colors.lightGreen - : Colors.red, - size: UiSizes.size_16, - ), - Text( - participant.name.split(' ').first, - style: TextStyle(fontSize: UiSizes.size_16), - ), - ], - ), - ), - Text( - getUserRole(context), - style: TextStyle(color: Colors.grey, fontSize: UiSizes.size_14), - ), - ], - ), - ), - ); - } -} diff --git a/lib/views/widgets/report_widget.dart b/lib/views/widgets/report_widget.dart index 47e4a3ac..2273a536 100644 --- a/lib/views/widgets/report_widget.dart +++ b/lib/views/widgets/report_widget.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'package:appwrite/appwrite.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/core/providers/appwrite_providers.dart'; import 'package:resonate/l10n/app_localizations.dart'; @@ -38,6 +37,7 @@ class _ReportWidgetState extends State { return; } + final l10n = AppLocalizations.of(context)!; try { final UserReportModel report = UserReportModel( reporterUid: requireCurrentAuthUser.uid, @@ -54,23 +54,14 @@ class _ReportWidgetState extends State { rowId: ID.unique(), data: report.toJson(), ); - Get.back(result: true); - await Future.delayed(Duration(milliseconds: 500)); - customSnackbar( - AppLocalizations.of(Get.context!)!.success, - AppLocalizations.of(Get.context!)!.reportSubmitted, - LogType.success, - ); - + if (!mounted) return; + Navigator.of(context).pop(true); + await Future.delayed(const Duration(milliseconds: 500)); + customSnackbar(l10n.success, l10n.reportSubmitted, LogType.success); return; } catch (e) { log(e.toString()); - customSnackbar( - AppLocalizations.of(Get.context!)!.error, - '${AppLocalizations.of(Get.context!)!.reportFailed}: $e', - LogType.error, - ); - + customSnackbar(l10n.error, '${l10n.reportFailed}: $e', LogType.error); return; } } diff --git a/test/controllers/change_email_controller_test.dart b/test/controllers/change_email_controller_test.dart index 8c99f672..dff75d83 100644 --- a/test/controllers/change_email_controller_test.dart +++ b/test/controllers/change_email_controller_test.dart @@ -4,21 +4,19 @@ import 'package:flutter/material.dart' hide Row; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get_navigation/src/root/get_material_app.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:resonate/controllers/change_email_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/constants.dart'; import '../helpers/test_root_container.dart'; -import 'change_email_controller_test.mocks.dart'; +import '../helpers/test_root_container.mocks.dart'; -@GenerateMocks([TablesDB, Account]) void main() { late MockTablesDB mockTablesDB; late MockAccount mockAccount; - late FakeAuthRepository fakeRepo; + late MockFunctions mockFunctions; + late MockFirebaseMessaging mockMessaging; late ChangeEmailController controller; final User mockUser = User( @@ -46,11 +44,36 @@ void main() { setUp(() async { mockTablesDB = MockTablesDB(); mockAccount = MockAccount(); - fakeRepo = FakeAuthRepository( - AuthState.authenticated(fakeAuthUser()), - ); + mockFunctions = MockFunctions(); + mockMessaging = MockFirebaseMessaging(); + when(mockMessaging.getToken()).thenAnswer((_) async => null); + when(mockAccount.get()).thenAnswer((_) async => mockUser); + when(mockTablesDB.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: anyNamed('rowId'), + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: '123', + tableId: usersTableID, + databaseId: userDatabaseID, + data: { + 'username': 'TestUser', + 'profileImageUrl': 'https://example.com/p.jpg', + 'profileImageID': 'p1', + 'ratingTotal': 5, + 'ratingCount': 1, + 'followers': const [], + 'userReports': const [], + }, + )); - await installTestRootContainer(authRepository: fakeRepo); + await installTestRootContainer( + account: mockAccount, + tables: mockTablesDB, + functions: mockFunctions, + messaging: mockMessaging, + ); controller = ChangeEmailController( tables: mockTablesDB, @@ -105,8 +128,7 @@ void main() { (tester) async { await tester.pumpWidget(GetMaterialApp(home: Container())); await tester.pumpAndSettle(); - - final loadCountBefore = fakeRepo.loadCount; + clearInteractions(mockAccount); final result = await controller.changeEmailInDatabases( 'test2@test.com', tester.element(find.byType(Container)), @@ -129,9 +151,8 @@ void main() { ), ).called(1); expect(result, true); - // refresh() should have called loadCurrentUser exactly once more than - // the initial container-build call. - expect(fakeRepo.loadCount, loadCountBefore + 1); + // refresh() should call loadCurrentUser → account.get() exactly once. + verify(mockAccount.get()).called(1); }, ); diff --git a/test/controllers/create_room_controller_test.dart b/test/controllers/create_room_controller_test.dart deleted file mode 100644 index e241915f..00000000 --- a/test/controllers/create_room_controller_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get_navigation/src/root/get_material_app.dart'; -import 'package:mockito/annotations.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/themes/theme_controller.dart'; - -import 'create_room_controller_test.mocks.dart'; - -@GenerateMocks([ThemeController]) -void main() { - final CreateRoomController createRoomController = CreateRoomController( - themeController: MockThemeController(), - ); - - test('test Initial Values', () { - expect(createRoomController.isLoading.value, false); - expect(createRoomController.isScheduled.value, false); - expect(createRoomController.createRoomFormKey.currentState, null); - expect(createRoomController.nameController.text, ''); - expect(createRoomController.descriptionController.text, ''); - expect(createRoomController.tagsController.getTags, null); - }); - testWidgets("test validateTag", (WidgetTester tester) async { - await tester.pumpWidget( - GetMaterialApp( - localizationsDelegates: [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: [Locale('en'), Locale('hi')], - home: Container(), - ), - ); - - List testTags = [ - 'Football', - '_Football', - 'Football\$', - 'Amet irure esse cillum reprehenderit ea laboris culpa dolor proident mollit ex.', - 'Football2', - ]; - expect( - createRoomController.validateTag( - testTags[0], - tester.element(find.byType(Container)), - ), - null, - ); - expect( - createRoomController.validateTag( - testTags[1], - tester.element(find.byType(Container)), - ), - '${AppLocalizations.of(tester.element(find.byType(Container)))!.invalidTags} ${testTags[1]}', - ); - expect( - createRoomController.validateTag( - testTags[2], - tester.element(find.byType(Container)), - ), - '${AppLocalizations.of(tester.element(find.byType(Container)))!.invalidTags} ${testTags[2]}', - ); - expect( - createRoomController.validateTag( - testTags[3], - tester.element(find.byType(Container)), - ), - '${AppLocalizations.of(tester.element(find.byType(Container)))!.invalidTags} ${testTags[3]}', - ); - expect( - createRoomController.validateTag( - testTags[4], - tester.element(find.byType(Container)), - ), - null, - ); - }); -} diff --git a/test/controllers/create_room_controller_test.mocks.dart b/test/controllers/create_room_controller_test.mocks.dart deleted file mode 100644 index 27d5ad4f..00000000 --- a/test/controllers/create_room_controller_test.mocks.dart +++ /dev/null @@ -1,253 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/create_room_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:ui' as _i6; - -import 'package:get/get.dart' as _i2; -import 'package:get/get_state_manager/src/simple/list_notifier.dart' as _i5; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i4; -import 'package:resonate/themes/theme_controller.dart' as _i3; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeRx_0 extends _i1.SmartFake implements _i2.Rx { - _FakeRx_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeInternalFinalCallback_1 extends _i1.SmartFake - implements _i2.InternalFinalCallback { - _FakeInternalFinalCallback_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ThemeController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockThemeController extends _i1.Mock implements _i3.ThemeController { - MockThemeController() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Rx get currentTheme => - (super.noSuchMethod( - Invocation.getter(#currentTheme), - returnValue: _FakeRx_0( - this, - Invocation.getter(#currentTheme), - ), - ) - as _i2.Rx); - - @override - _i2.Rx get currentThemePlaceHolder => - (super.noSuchMethod( - Invocation.getter(#currentThemePlaceHolder), - returnValue: _FakeRx_0( - this, - Invocation.getter(#currentThemePlaceHolder), - ), - ) - as _i2.Rx); - - @override - String get getCurrentTheme => - (super.noSuchMethod( - Invocation.getter(#getCurrentTheme), - returnValue: _i4.dummyValue( - this, - Invocation.getter(#getCurrentTheme), - ), - ) - as String); - - @override - String get userProfileImagePlaceholderUrl => - (super.noSuchMethod( - Invocation.getter(#userProfileImagePlaceholderUrl), - returnValue: _i4.dummyValue( - this, - Invocation.getter(#userProfileImagePlaceholderUrl), - ), - ) - as String); - - @override - set currentTheme(_i2.Rx? value) => super.noSuchMethod( - Invocation.setter(#currentTheme, value), - returnValueForMissingStub: null, - ); - - @override - set currentThemePlaceHolder(_i2.Rx? value) => super.noSuchMethod( - Invocation.setter(#currentThemePlaceHolder, value), - returnValueForMissingStub: null, - ); - - @override - _i2.InternalFinalCallback get onStart => - (super.noSuchMethod( - Invocation.getter(#onStart), - returnValue: _FakeInternalFinalCallback_1( - this, - Invocation.getter(#onStart), - ), - ) - as _i2.InternalFinalCallback); - - @override - _i2.InternalFinalCallback get onDelete => - (super.noSuchMethod( - Invocation.getter(#onDelete), - returnValue: _FakeInternalFinalCallback_1( - this, - Invocation.getter(#onDelete), - ), - ) - as _i2.InternalFinalCallback); - - @override - bool get initialized => - (super.noSuchMethod(Invocation.getter(#initialized), returnValue: false) - as bool); - - @override - bool get isClosed => - (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) - as bool); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - int get listeners => - (super.noSuchMethod(Invocation.getter(#listeners), returnValue: 0) - as int); - - @override - void onInit() => super.noSuchMethod( - Invocation.method(#onInit, []), - returnValueForMissingStub: null, - ); - - @override - void updateTheme(String? theme) => super.noSuchMethod( - Invocation.method(#updateTheme, [theme]), - returnValueForMissingStub: null, - ); - - @override - void updateUserProfileImagePlaceholderUrlOnTheme() => super.noSuchMethod( - Invocation.method(#updateUserProfileImagePlaceholderUrlOnTheme, []), - returnValueForMissingStub: null, - ); - - @override - void setTheme(String? newTheme) => super.noSuchMethod( - Invocation.method(#setTheme, [newTheme]), - returnValueForMissingStub: null, - ); - - @override - void update([List? ids, bool? condition = true]) => - super.noSuchMethod( - Invocation.method(#update, [ids, condition]), - returnValueForMissingStub: null, - ); - - @override - void onReady() => super.noSuchMethod( - Invocation.method(#onReady, []), - returnValueForMissingStub: null, - ); - - @override - void onClose() => super.noSuchMethod( - Invocation.method(#onClose, []), - returnValueForMissingStub: null, - ); - - @override - void $configureLifeCycle() => super.noSuchMethod( - Invocation.method(#$configureLifeCycle, []), - returnValueForMissingStub: null, - ); - - @override - _i5.Disposer addListener(_i5.GetStateUpdate? listener) => - (super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValue: () {}, - ) - as _i5.Disposer); - - @override - void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void refresh() => super.noSuchMethod( - Invocation.method(#refresh, []), - returnValueForMissingStub: null, - ); - - @override - void refreshGroup(Object? id) => super.noSuchMethod( - Invocation.method(#refreshGroup, [id]), - returnValueForMissingStub: null, - ); - - @override - void notifyChildrens() => super.noSuchMethod( - Invocation.method(#notifyChildrens, []), - returnValueForMissingStub: null, - ); - - @override - void removeListenerId(Object? id, _i6.VoidCallback? listener) => - super.noSuchMethod( - Invocation.method(#removeListenerId, [id, listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - _i5.Disposer addListenerId(Object? key, _i5.GetStateUpdate? listener) => - (super.noSuchMethod( - Invocation.method(#addListenerId, [key, listener]), - returnValue: () {}, - ) - as _i5.Disposer); - - @override - void disposeId(Object? id) => super.noSuchMethod( - Invocation.method(#disposeId, [id]), - returnValueForMissingStub: null, - ); -} diff --git a/test/controllers/room_chat_controller_test.dart b/test/controllers/room_chat_controller_test.dart deleted file mode 100644 index 43b8ddc4..00000000 --- a/test/controllers/room_chat_controller_test.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:resonate/models/message.dart'; -import 'package:resonate/utils/constants.dart'; - -// Generate mocks for Databases class -@GenerateMocks([Databases]) -import 'room_chat_controller_test.mocks.dart'; - -void main() { - group('RoomChatController - deleteMessage', () { - late MockDatabases mockDatabases; - late List messages; - - setUp(() { - mockDatabases = MockDatabases(); - messages = []; - - messages.add( - Message( - messageId: 'test-message-1', - roomId: 'test-room', - creatorId: 'user-1', - creatorUsername: 'testuser', - creatorName: 'Test User', - hasValidTag: false, - index: 0, - creatorImgUrl: 'https://example.com/img.jpg', - isEdited: false, - content: 'Test message content', - creationDateTime: DateTime.now(), - isDeleted: false, - ), - ); - }); - - test('test to successfully delete a message', () async { - when( - mockDatabases.updateDocument( - databaseId: anyNamed('databaseId'), - collectionId: anyNamed('collectionId'), - documentId: anyNamed('documentId'), - data: anyNamed('data'), - permissions: anyNamed('permissions'), - ), - ).thenAnswer( - (_) async => Document( - $id: 'test-message-1', - $collectionId: chatMessagesTableId, - $databaseId: masterDatabaseId, - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - $permissions: [], - $sequence: 1, - data: {}, - ), - ); - - Message messageToDelete = messages.firstWhere( - (msg) => msg.messageId == 'test-message-1', - ); - messageToDelete = messageToDelete.copyWith(content: '', isDeleted: true); - - await mockDatabases.updateDocument( - databaseId: masterDatabaseId, - collectionId: chatMessagesTableId, - documentId: 'test-message-1', - data: messageToDelete.toJsonForUpload(), - ); - - verify( - mockDatabases.updateDocument( - databaseId: masterDatabaseId, - collectionId: chatMessagesTableId, - documentId: 'test-message-1', - data: argThat( - predicate>( - (data) => data['content'] == '' && data['isDeleted'] == true, - ), - named: 'data', - ), - ), - ).called(1); - }); - - test('test to handle error when message not found', () async { - try { - messages.firstWhere((msg) => msg.messageId == 'non-existent-message'); - fail('Should have thrown StateError'); - } catch (e) { - expect(e, isA()); - } - - verifyNever( - mockDatabases.updateDocument( - databaseId: anyNamed('databaseId'), - collectionId: anyNamed('collectionId'), - documentId: anyNamed('documentId'), - data: anyNamed('data'), - ), - ); - }); - - test('test to handle database errors', () async { - when( - mockDatabases.updateDocument( - databaseId: anyNamed('databaseId'), - collectionId: anyNamed('collectionId'), - documentId: anyNamed('documentId'), - data: anyNamed('data'), - permissions: anyNamed('permissions'), - ), - ).thenThrow(AppwriteException('Database error')); - - Message messageToDelete = messages.firstWhere( - (msg) => msg.messageId == 'test-message-1', - ); - messageToDelete = messageToDelete.copyWith(content: '', isDeleted: true); - - try { - await mockDatabases.updateDocument( - databaseId: masterDatabaseId, - collectionId: chatMessagesTableId, - documentId: 'test-message-1', - data: messageToDelete.toJsonForUpload(), - ); - fail('Should have thrown AppwriteException'); - } catch (e) { - expect(e, isA()); - } - - verify( - mockDatabases.updateDocument( - databaseId: masterDatabaseId, - collectionId: chatMessagesTableId, - documentId: 'test-message-1', - data: anyNamed('data'), - ), - ).called(1); - }); - }); -} diff --git a/test/controllers/room_chat_controller_test.mocks.dart b/test/controllers/room_chat_controller_test.mocks.dart deleted file mode 100644 index 2988f34e..00000000 --- a/test/controllers/room_chat_controller_test.mocks.dart +++ /dev/null @@ -1,427 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/room_chat_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; - -import 'package:appwrite/appwrite.dart' as _i4; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeDocumentList_3 extends _i1.SmartFake implements _i3.DocumentList { - _FakeDocumentList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeDocument_4 extends _i1.SmartFake implements _i3.Document { - _FakeDocument_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [Databases]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDatabases extends _i1.Mock implements _i4.Databases { - MockDatabases() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i5.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i5.Future<_i3.TransactionList>); - - @override - _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.DocumentList> listDocuments({ - required String? databaseId, - required String? collectionId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listDocuments, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i5.Future<_i3.DocumentList>.value( - _FakeDocumentList_3( - this, - Invocation.method(#listDocuments, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i5.Future<_i3.DocumentList>); - - @override - _i5.Future<_i3.Document> createDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#createDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); - - @override - _i5.Future<_i3.Document> getDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#getDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); - - @override - _i5.Future<_i3.Document> upsertDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#upsertDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); - - @override - _i5.Future<_i3.Document> updateDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#updateDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); - - @override - _i5.Future deleteDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #transactionId: transactionId, - }), - returnValue: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i3.Document> decrementDocumentAttribute({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required String? attribute, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#decrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); - - @override - _i5.Future<_i3.Document> incrementDocumentAttribute({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required String? attribute, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#incrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Document>); -} diff --git a/test/controllers/rooms_controller_test.dart b/test/controllers/rooms_controller_test.dart deleted file mode 100644 index b612aec7..00000000 --- a/test/controllers/rooms_controller_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:io'; - -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mockito/annotations.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/models/appwrite_room.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/enums/room_state.dart'; - -@GenerateMocks([Databases, Client]) -void main() { - Get.testMode = true; - TestWidgetsFlutterBinding.ensureInitialized(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/path_provider'), - (MethodCall methodCall) async { - if (methodCall.method == 'getApplicationDocumentsDirectory') { - return Directory.systemTemp.path; - } - return null; - }, - ); - - group('RoomsController - Search Functionality', () { - late RoomsController controller; - late ThemeController themeController; - - final mockRooms = [ - AppwriteRoom( - id: 'room1', - name: 'Flutter Development', - description: 'Discussing Flutter app development and best practices', - totalParticipants: 5, - tags: ['flutter', 'development'], - state: RoomState.live, - memberAvatarUrls: ['url1', 'url2'], - isUserAdmin: false, - reportedUsers: [], - ), - AppwriteRoom( - id: 'room2', - name: 'Music Discussion', - description: 'Talk about your favorite music and artists', - totalParticipants: 3, - tags: ['music', 'discussion'], - state: RoomState.live, - memberAvatarUrls: ['url1'], - isUserAdmin: false, - reportedUsers: [], - ), - AppwriteRoom( - id: 'room3', - name: 'Tech Talk', - description: 'General technology discussions and news', - totalParticipants: 7, - tags: ['tech', 'news'], - state: RoomState.live, - memberAvatarUrls: ['url1', 'url2', 'url3'], - isUserAdmin: false, - reportedUsers: [], - ), - ]; - - setUpAll(() async { - await GetStorage.init('test_storage_rooms'); - }); - - setUp(() { - themeController = ThemeController(); - Get.put(themeController); - controller = RoomsController(); - controller.rooms.value = List.from(mockRooms); - }); - - tearDown(() { - Get.reset(); - }); - - test('should filter rooms by name', () { - controller.searchLiveRooms('Flutter'); - expect(controller.filteredRooms.length, 1); - expect(controller.filteredRooms[0].name, 'Flutter Development'); - }); - - test('should filter rooms by description', () { - controller.searchLiveRooms('music'); - expect(controller.filteredRooms.length, 1); - expect(controller.filteredRooms[0].name, 'Music Discussion'); - }); - - test('should be case-insensitive', () { - controller.searchLiveRooms('FLUTTER'); - expect(controller.filteredRooms.length, 1); - expect(controller.filteredRooms[0].name, 'Flutter Development'); - }); - - test('should return all rooms when query is empty', () { - controller.searchLiveRooms(''); - expect(controller.filteredRooms.length, 3); - }); - - test('should return empty list when no matches found', () { - controller.searchLiveRooms('NonExistentRoom'); - expect(controller.filteredRooms.length, 0); - }); - - test('should match partial strings', () { - controller.searchLiveRooms('Tech'); - expect(controller.filteredRooms.length, 1); - expect(controller.filteredRooms[0].name, 'Tech Talk'); - }); - - test('should set searchBarIsEmpty flag correctly', () { - expect(controller.searchBarIsEmpty.value, true); - controller.searchLiveRooms('Flutter'); - expect(controller.searchBarIsEmpty.value, false); - }); - - test('should clear search results', () { - controller.searchLiveRooms('Flutter'); - expect(controller.searchBarIsEmpty.value, false); - expect(controller.filteredRooms.length, 1); - - controller.clearLiveSearch(); - expect(controller.searchBarIsEmpty.value, true); - expect(controller.filteredRooms.length, 3); - }); - - test('should handle special characters in search query', () { - controller.searchLiveRooms('Development & practices'); - expect(controller.filteredRooms.length, 0); - }); - - test('should match across multiple fields', () { - controller.searchLiveRooms('discuss'); - expect(controller.filteredRooms.length, 3); - expect( - controller.filteredRooms.any( - (room) => room.name == 'Flutter Development', - ), - true, - ); - expect( - controller.filteredRooms.any((room) => room.name == 'Music Discussion'), - true, - ); - expect( - controller.filteredRooms.any((room) => room.name == 'Tech Talk'), - true, - ); - }); - }); -} diff --git a/test/controllers/rooms_controller_test.mocks.dart b/test/controllers/rooms_controller_test.mocks.dart deleted file mode 100644 index 370fe2da..00000000 --- a/test/controllers/rooms_controller_test.mocks.dart +++ /dev/null @@ -1,662 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/rooms_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; - -import 'package:appwrite/appwrite.dart' as _i5; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/enums.dart' as _i9; -import 'package:appwrite/src/response.dart' as _i4; -import 'package:appwrite/src/upload_progress.dart' as _i8; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeDocumentList_3 extends _i1.SmartFake implements _i3.DocumentList { - _FakeDocumentList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeDocument_4 extends _i1.SmartFake implements _i3.Document { - _FakeDocument_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeResponse_5 extends _i1.SmartFake implements _i4.Response { - _FakeResponse_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [Databases]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDatabases extends _i1.Mock implements _i5.Databases { - MockDatabases() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i6.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i6.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i6.Future<_i3.TransactionList>); - - @override - _i6.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.DocumentList> listDocuments({ - required String? databaseId, - required String? collectionId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listDocuments, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i6.Future<_i3.DocumentList>.value( - _FakeDocumentList_3( - this, - Invocation.method(#listDocuments, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i6.Future<_i3.DocumentList>); - - @override - _i6.Future<_i3.Document> createDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#createDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); - - @override - _i6.Future<_i3.Document> getDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#getDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); - - @override - _i6.Future<_i3.Document> upsertDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#upsertDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); - - @override - _i6.Future<_i3.Document> updateDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#updateDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); - - @override - _i6.Future deleteDocument({ - required String? databaseId, - required String? collectionId, - required String? documentId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteDocument, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Document> decrementDocumentAttribute({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required String? attribute, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#decrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); - - @override - _i6.Future<_i3.Document> incrementDocumentAttribute({ - required String? databaseId, - required String? collectionId, - required String? documentId, - required String? attribute, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Document>.value( - _FakeDocument_4( - this, - Invocation.method(#incrementDocumentAttribute, [], { - #databaseId: databaseId, - #collectionId: collectionId, - #documentId: documentId, - #attribute: attribute, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Document>); -} - -/// A class which mocks [Client]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockClient extends _i1.Mock implements _i2.Client { - MockClient() { - _i1.throwOnMissingStub(this); - } - - @override - Map get config => - (super.noSuchMethod( - Invocation.getter(#config), - returnValue: {}, - ) - as Map); - - @override - String get endPoint => - (super.noSuchMethod( - Invocation.getter(#endPoint), - returnValue: _i7.dummyValue( - this, - Invocation.getter(#endPoint), - ), - ) - as String); - - @override - set config(Map? value) => super.noSuchMethod( - Invocation.setter(#config, value), - returnValueForMissingStub: null, - ); - - @override - _i6.Future webAuth(Uri? url, {String? callbackUrlScheme}) => - (super.noSuchMethod( - Invocation.method( - #webAuth, - [url], - {#callbackUrlScheme: callbackUrlScheme}, - ), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i4.Response> chunkedUpload({ - required String? path, - required Map? params, - required String? paramName, - required String? idParamName, - required Map? headers, - dynamic Function(_i8.UploadProgress)? onProgress, - }) => - (super.noSuchMethod( - Invocation.method(#chunkedUpload, [], { - #path: path, - #params: params, - #paramName: paramName, - #idParamName: idParamName, - #headers: headers, - #onProgress: onProgress, - }), - returnValue: _i6.Future<_i4.Response>.value( - _FakeResponse_5( - this, - Invocation.method(#chunkedUpload, [], { - #path: path, - #params: params, - #paramName: paramName, - #idParamName: idParamName, - #headers: headers, - #onProgress: onProgress, - }), - ), - ), - ) - as _i6.Future<_i4.Response>); - - @override - _i2.Client setSelfSigned({bool? status = true}) => - (super.noSuchMethod( - Invocation.method(#setSelfSigned, [], {#status: status}), - returnValue: _FakeClient_0( - this, - Invocation.method(#setSelfSigned, [], {#status: status}), - ), - ) - as _i2.Client); - - @override - _i2.Client setEndpoint(String? endPoint) => - (super.noSuchMethod( - Invocation.method(#setEndpoint, [endPoint]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setEndpoint, [endPoint]), - ), - ) - as _i2.Client); - - @override - _i2.Client setEndPointRealtime(String? endPoint) => - (super.noSuchMethod( - Invocation.method(#setEndPointRealtime, [endPoint]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setEndPointRealtime, [endPoint]), - ), - ) - as _i2.Client); - - @override - _i2.Client setProject(String? value) => - (super.noSuchMethod( - Invocation.method(#setProject, [value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setProject, [value]), - ), - ) - as _i2.Client); - - @override - _i2.Client setJWT(String? value) => - (super.noSuchMethod( - Invocation.method(#setJWT, [value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setJWT, [value]), - ), - ) - as _i2.Client); - - @override - _i2.Client setLocale(String? value) => - (super.noSuchMethod( - Invocation.method(#setLocale, [value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setLocale, [value]), - ), - ) - as _i2.Client); - - @override - _i2.Client setSession(String? value) => - (super.noSuchMethod( - Invocation.method(#setSession, [value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setSession, [value]), - ), - ) - as _i2.Client); - - @override - _i2.Client setDevKey(String? value) => - (super.noSuchMethod( - Invocation.method(#setDevKey, [value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#setDevKey, [value]), - ), - ) - as _i2.Client); - - @override - _i2.Client addHeader(String? key, String? value) => - (super.noSuchMethod( - Invocation.method(#addHeader, [key, value]), - returnValue: _FakeClient_0( - this, - Invocation.method(#addHeader, [key, value]), - ), - ) - as _i2.Client); - - @override - _i6.Future ping() => - (super.noSuchMethod( - Invocation.method(#ping, []), - returnValue: _i6.Future.value( - _i7.dummyValue(this, Invocation.method(#ping, [])), - ), - ) - as _i6.Future); - - @override - _i6.Future<_i4.Response> call( - _i9.HttpMethod? method, { - String? path = '', - Map? headers = const {}, - Map? params = const {}, - _i9.ResponseType? responseType, - }) => - (super.noSuchMethod( - Invocation.method( - #call, - [method], - { - #path: path, - #headers: headers, - #params: params, - #responseType: responseType, - }, - ), - returnValue: _i6.Future<_i4.Response>.value( - _FakeResponse_5( - this, - Invocation.method( - #call, - [method], - { - #path: path, - #headers: headers, - #params: params, - #responseType: responseType, - }, - ), - ), - ), - ) - as _i6.Future<_i4.Response>); -} diff --git a/test/controllers/upcoming_controller_tests.dart b/test/controllers/upcoming_controller_tests.dart deleted file mode 100644 index 13225913..00000000 --- a/test/controllers/upcoming_controller_tests.dart +++ /dev/null @@ -1,292 +0,0 @@ -import 'dart:io'; -import 'package:appwrite/appwrite.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mockito/annotations.dart'; -import 'package:resonate/controllers/create_room_controller.dart'; -import 'package:resonate/controllers/rooms_controller.dart'; -import 'package:resonate/controllers/tabview_controller.dart'; -import 'package:resonate/controllers/upcomming_rooms_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/models/appwrite_upcomming_room.dart'; -import 'package:resonate/themes/theme_controller.dart'; - -import '../helpers/test_root_container.dart'; -import 'upcoming_controller_tests.mocks.dart'; - -@GenerateMocks([TablesDB, FirebaseMessaging, Realtime]) -void main() { - Get.testMode = true; - TestWidgetsFlutterBinding.ensureInitialized(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/path_provider'), - (methodCall) async { - if (methodCall.method == 'getApplicationDocumentsDirectory') { - return Directory.systemTemp.path; - } - return null; - }, - ); - final mockRooms = [ - AppwriteUpcommingRoom( - id: 'room1', - name: 'Future Tech Discussion', - description: 'Exploring upcoming technology trends and innovations', - isTime: true, - scheduledDateTime: DateTime.now().add(const Duration(days: 1)), - totalSubscriberCount: 10, - tags: ['tech', 'future'], - subscribersAvatarUrls: ['url1', 'url2'], - userIsCreator: true, - hasUserSubscribed: false, - ), - AppwriteUpcommingRoom( - id: 'room2', - name: 'Music Jam Session', - description: 'Live music performance and collaboration', - isTime: true, - scheduledDateTime: DateTime.now().add(const Duration(days: 2)), - totalSubscriberCount: 5, - tags: ['music', 'live'], - subscribersAvatarUrls: ['url1'], - userIsCreator: false, - hasUserSubscribed: true, - ), - AppwriteUpcommingRoom( - id: 'room3', - name: 'Book Club Meeting', - description: 'Discussing the latest bestseller books', - isTime: false, - scheduledDateTime: DateTime.now().add(const Duration(days: 3)), - totalSubscriberCount: 8, - tags: ['books', 'discussion'], - subscribersAvatarUrls: ['url1', 'url2', 'url3'], - userIsCreator: true, - hasUserSubscribed: false, - ), - ]; - - group('UpcomingRoomsController - Remove Room Functionality', () { - late UpcomingRoomsController controller; - late GetStorage testStorage; - late MockFirebaseMessaging mockMessaging; - late CreateRoomController createRoomController; - late TabViewController tabViewController; - late ThemeController themeController; - late RoomsController roomsController; - - setUpAll(() async { - await GetStorage.init('test_storage'); - }); - - setUp(() async { - // Auth state needed by the controller's table-fetch paths even though - // these tests exercise local search/remove logic only. - await installTestRootContainer( - authState: AuthState.authenticated(fakeAuthUser()), - ); - testStorage = GetStorage('test_storage'); - await testStorage.erase(); - mockMessaging = MockFirebaseMessaging(); - tabViewController = TabViewController(); - themeController = ThemeController(); - Get.put(themeController); - createRoomController = CreateRoomController( - themeController: themeController, - ); - roomsController = RoomsController(); - controller = UpcomingRoomsController( - createRoomController: createRoomController, - tabViewController: tabViewController, - themeController: themeController, - roomsController: roomsController, - messaging: mockMessaging, - storage: testStorage, - ); - }); - - tearDown(() async { - Get.reset(); - await testStorage.erase(); - }); - - test('removes the room from the upcomingRooms list', () async { - controller.upcomingRooms.value = List.from(mockRooms); - expect(controller.upcomingRooms.length, 3); - await controller.removeUpcomingRoom('room2'); - expect(controller.upcomingRooms.length, 2); - expect( - controller.upcomingRooms.any((room) => room.id == 'room2'), - false, - ); - expect(controller.upcomingRooms.any((room) => room.id == 'room1'), true); - expect(controller.upcomingRooms.any((room) => room.id == 'room3'), true); - }); - - test('persists the removed room ID in storage', () async { - controller.upcomingRooms.value = List.from(mockRooms); - await controller.removeUpcomingRoom('room1'); - final removedRooms = testStorage.read('removed_upcoming_rooms'); - expect(removedRooms, isNotNull); - expect(removedRooms, contains('room1')); - }); - - test('does not add duplicate IDs to storage', () async { - controller.upcomingRooms.value = List.from(mockRooms); - await controller.removeUpcomingRoom('room1'); - controller.upcomingRooms.add(mockRooms[0]); - await controller.removeUpcomingRoom('room1'); - final removedRooms = testStorage.read('removed_upcoming_rooms'); - expect((removedRooms as List?)?.where((id) => id == 'room1').length, 1); - }); - - test('removes multiple rooms independently', () async { - controller.upcomingRooms.value = List.from(mockRooms); - await controller.removeUpcomingRoom('room1'); - await controller.removeUpcomingRoom('room3'); - expect(controller.upcomingRooms.length, 1); - expect(controller.upcomingRooms[0].id, 'room2'); - final removedRooms = testStorage.read('removed_upcoming_rooms'); - expect((removedRooms as List?)?.length, 2); - expect(removedRooms, containsAll(['room1', 'room3'])); - }); - - test('handles removing a non-existent room without throwing', () async { - controller.upcomingRooms.value = List.from(mockRooms); - await controller.removeUpcomingRoom('non-existent-room'); - expect(controller.upcomingRooms.length, 3); - final removedRooms = testStorage.read('removed_upcoming_rooms'); - expect(removedRooms, contains('non-existent-room')); - }); - - test('persists removed rooms across controller instances', () async { - controller.upcomingRooms.value = List.from(mockRooms); - await controller.removeUpcomingRoom('room2'); - var removedRooms = testStorage.read('removed_upcoming_rooms'); - expect(removedRooms, contains('room2')); - Get.delete(); - UpcomingRoomsController( - createRoomController: createRoomController, - tabViewController: tabViewController, - themeController: themeController, - roomsController: roomsController, - messaging: mockMessaging, - storage: testStorage, - ); - removedRooms = testStorage.read('removed_upcoming_rooms'); - expect(removedRooms, contains('room2')); - Get.delete(); - }); - }); - - group('UpcomingRoomsController - Search Functionality', () { - late UpcomingRoomsController controller; - late GetStorage testStorage; - late MockFirebaseMessaging mockMessaging; - late CreateRoomController createRoomController; - late TabViewController tabViewController; - late ThemeController themeController; - late RoomsController roomsController; - - setUpAll(() async { - await GetStorage.init('test_storage_search'); - }); - - setUp(() async { - await installTestRootContainer( - authState: AuthState.authenticated(fakeAuthUser()), - ); - testStorage = GetStorage('test_storage_search'); - await testStorage.erase(); - mockMessaging = MockFirebaseMessaging(); - tabViewController = TabViewController(); - themeController = ThemeController(); - Get.put(themeController); - createRoomController = CreateRoomController( - themeController: themeController, - ); - roomsController = RoomsController(); - controller = UpcomingRoomsController( - createRoomController: createRoomController, - tabViewController: tabViewController, - themeController: themeController, - roomsController: roomsController, - messaging: mockMessaging, - storage: testStorage, - ); - controller.upcomingRooms.value = List.from(mockRooms); - }); - - tearDown(() async { - Get.reset(); - await testStorage.erase(); - }); - - test('filters by name', () { - controller.searchUpcomingRooms('Tech'); - expect(controller.filteredUpcomingRooms.length, 1); - expect( - controller.filteredUpcomingRooms[0].name, - 'Future Tech Discussion', - ); - }); - - test('filters by description', () { - controller.searchUpcomingRooms('music'); - expect(controller.filteredUpcomingRooms.length, 1); - expect(controller.filteredUpcomingRooms[0].name, 'Music Jam Session'); - }); - - test('is case-insensitive', () { - controller.searchUpcomingRooms('BOOK'); - expect(controller.filteredUpcomingRooms.length, 1); - expect(controller.filteredUpcomingRooms[0].name, 'Book Club Meeting'); - }); - - test('returns all rooms when query is empty', () { - controller.searchUpcomingRooms(''); - expect(controller.filteredUpcomingRooms.length, 3); - }); - - test('returns empty list when no matches', () { - controller.searchUpcomingRooms('NonExistent'); - expect(controller.filteredUpcomingRooms.length, 0); - }); - - test('matches partial strings', () { - controller.searchUpcomingRooms('Disc'); - expect(controller.filteredUpcomingRooms.length, 2); - }); - - test('clears search results', () { - controller.searchUpcomingRooms('Music'); - expect(controller.searchBarIsEmpty.value, false); - expect(controller.filteredUpcomingRooms.length, 1); - - controller.clearUpcomingSearch(); - expect(controller.searchBarIsEmpty.value, true); - expect(controller.filteredUpcomingRooms.length, 3); - }); - - test('matches across multiple fields', () { - controller.searchUpcomingRooms('discuss'); - expect(controller.filteredUpcomingRooms.length, 2); - expect( - controller.filteredUpcomingRooms.any( - (room) => room.name == 'Future Tech Discussion', - ), - true, - ); - expect( - controller.filteredUpcomingRooms.any( - (room) => room.name == 'Book Club Meeting', - ), - true, - ); - }); - }); -} diff --git a/test/features/auth/viewmodel/auth_notifier_test.dart b/test/features/auth/viewmodel/auth_notifier_test.dart index 21eeddbd..658584e2 100644 --- a/test/features/auth/viewmodel/auth_notifier_test.dart +++ b/test/features/auth/viewmodel/auth_notifier_test.dart @@ -1,110 +1,199 @@ +import 'package:appwrite/models.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +User _buildUser({ + String id = 'u1', + bool emailVerified = true, + bool profileComplete = true, +}) => + User( + $id: id, + name: 'Foo', + email: 'foo@example.com', + emailVerification: emailVerified, + prefs: Preferences(data: {'isUserProfileComplete': profileComplete}), + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + accessedAt: DateTime.now().toIso8601String(), + registration: DateTime.now().toIso8601String(), + phone: '', + phoneVerification: false, + mfa: false, + passwordUpdate: DateTime.now().toIso8601String(), + status: true, + password: '', + labels: const [], + hash: 'Argon2', + targets: const [], + hashOptions: const {}, + ); + +Row _buildUserRow({String id = 'u1', int reportsCount = 0}) => buildRow( + id: id, + tableId: usersTableID, + databaseId: userDatabaseID, + data: { + 'username': 'foo', + 'profileImageUrl': 'https://example.com/p.jpg', + 'profileImageID': 'p1', + 'ratingTotal': 5, + 'ratingCount': 1, + 'followers': const [], + 'userReports': List.filled(reportsCount, {'reason': 'spam'}), + }, + ); -class _FakeAuthRepository implements AuthRepository { - _FakeAuthRepository(this._next); - - AuthState _next; - int loadCalls = 0; - int loginCalls = 0; - int logoutCalls = 0; - - void setNext(AuthState state) => _next = state; - - @override - Future loadCurrentUser() async { - loadCalls++; - return _next; - } - - @override - Future login({required String email, required String password}) async { - loginCalls++; - } +void main() { + late MockAccount account; + late MockTablesDB tables; + late MockFunctions functions; + late MockFirebaseMessaging messaging; + + setUp(() { + account = MockAccount(); + tables = MockTablesDB(); + functions = MockFunctions(); + messaging = MockFirebaseMessaging(); + // Default: no FCM token, nothing in the upcoming-room tables. + when(messaging.getToken()).thenAnswer((_) async => null); + }); - @override - Future logout() async { - logoutCalls++; + ProviderContainer makeContainer() { + final c = ProviderContainer( + overrides: [ + appwriteAccountProvider.overrideWithValue(account), + appwriteTablesProvider.overrideWithValue(tables), + appwriteFunctionsProvider.overrideWithValue(functions), + firebaseMessagingProvider.overrideWithValue(messaging), + ], + ); + addTearDown(c.dispose); + return c; } - @override - Future addRegistrationToken({required String uid}) async {} - - @override - Future removeRegistrationToken({required String uid}) async {} - - @override - dynamic noSuchMethod(Invocation invocation) => - throw UnimplementedError('${invocation.memberName} not stubbed'); -} - -void main() { - AuthUser sampleUser({String uid = 'u1', bool profileComplete = true}) => - AuthUser( - uid: uid, - email: 'foo@example.com', - displayName: 'Foo', - isEmailVerified: true, - isProfileComplete: profileComplete, - ); - group('AuthNotifier', () { test('build() resolves to whatever the repository returns', () async { - final repo = _FakeAuthRepository( - AuthState.authenticated(sampleUser()), - ); - final container = ProviderContainer( - overrides: [authRepositoryProvider.overrideWithValue(repo)], - ); - addTearDown(container.dispose); - + when(account.get()).thenAnswer((_) async => _buildUser()); + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => _buildUserRow()); + + final container = makeContainer(); final state = await container.read(authProvider.future); expect(state, isA()); expect((state as AuthStateAuthenticated).user.uid, 'u1'); - expect(repo.loadCalls, 1); }); test('login() reloads the user and adds an FCM token', () async { - final repo = _FakeAuthRepository( - const AuthState.unauthenticated(), - ); - final container = ProviderContainer( - overrides: [authRepositoryProvider.overrideWithValue(repo)], - ); - addTearDown(container.dispose); - + // First load: unauthenticated. + var hasSession = false; + when(account.get()).thenAnswer((_) async { + if (!hasSession) throw Exception('no session'); + return _buildUser(); + }); + when(account.createEmailPasswordSession( + email: 'a@b.c', + password: 'pw', + )).thenAnswer((_) async { + hasSession = true; + return Session( + $id: 'sess', + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + userId: 'u1', + expire: DateTime.now() + .add(const Duration(days: 30)) + .toIso8601String(), + provider: 'email', + providerUid: 'a@b.c', + providerAccessToken: '', + providerAccessTokenExpiry: '', + providerRefreshToken: '', + ip: '', + osCode: '', + osName: '', + osVersion: '', + clientType: '', + clientCode: '', + clientName: '', + clientVersion: '', + clientEngine: '', + clientEngineVersion: '', + deviceName: '', + deviceBrand: '', + deviceModel: '', + countryCode: '', + countryName: '', + current: true, + factors: const [], + secret: '', + mfaUpdatedAt: '', + ); + }); + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => _buildUserRow()); + + final container = makeContainer(); await container.read(authProvider.future); - repo.setNext(AuthState.authenticated(sampleUser())); + expect(container.read(authProvider).value, + isA()); await container .read(authProvider.notifier) .login(email: 'a@b.c', password: 'pw'); - expect(repo.loginCalls, 1); - final state = container.read(authProvider).requireValue; - expect(state, isA()); + expect(container.read(authProvider).requireValue, + isA()); + verify(account.createEmailPasswordSession( + email: 'a@b.c', + password: 'pw', + )).called(1); }); test('logout() flips state to unauthenticated', () async { - final repo = _FakeAuthRepository( - AuthState.authenticated(sampleUser()), - ); - final container = ProviderContainer( - overrides: [authRepositoryProvider.overrideWithValue(repo)], - ); - addTearDown(container.dispose); - + var hasSession = true; + when(account.get()).thenAnswer((_) async { + if (!hasSession) throw Exception('no session'); + return _buildUser(); + }); + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => _buildUserRow()); + when(account.deleteSession(sessionId: 'current')).thenAnswer((_) async { + hasSession = false; + }); + + final container = makeContainer(); await container.read(authProvider.future); + expect(container.read(authProvider).requireValue, + isA()); + await container.read(authProvider.notifier).logout(); - expect(repo.logoutCalls, 1); expect(container.read(authProvider).requireValue, isA()); + verify(account.deleteSession(sessionId: 'current')).called(1); }); }); } diff --git a/test/features/auth/viewmodel/forgot_password_notifier_test.dart b/test/features/auth/viewmodel/forgot_password_notifier_test.dart index 29ab67f0..cc0b318b 100644 --- a/test/features/auth/viewmodel/forgot_password_notifier_test.dart +++ b/test/features/auth/viewmodel/forgot_password_notifier_test.dart @@ -1,81 +1,89 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/features/auth/viewmodel/forgot_password_notifier.dart'; -class _FakeRepo extends FakeAuthRepositoryBase { - bool fail = false; - int sendCount = 0; - String? lastEmail; - String? lastRedirect; +import '../../../helpers/test_root_container.mocks.dart'; - @override - Future sendPasswordRecovery({ - required String email, - required String redirectUrl, - }) async { - sendCount++; - lastEmail = email; - lastRedirect = redirectUrl; - if (fail) throw Exception('boom'); - } -} +void main() { + late MockAccount account; + late MockTablesDB tables; + late MockFunctions functions; + late MockFirebaseMessaging messaging; -// Minimal base so the fake doesn't have to implement every method. -class FakeAuthRepositoryBase implements AuthRepository { - @override - Future loadCurrentUser() async => - const AuthState.unauthenticated(); + setUp(() { + account = MockAccount(); + tables = MockTablesDB(); + functions = MockFunctions(); + messaging = MockFirebaseMessaging(); + }); - @override - dynamic noSuchMethod(Invocation invocation) => - throw UnimplementedError('${invocation.memberName} not stubbed'); -} + ProviderContainer makeContainer() { + final c = ProviderContainer( + overrides: [ + appwriteAccountProvider.overrideWithValue(account), + appwriteTablesProvider.overrideWithValue(tables), + appwriteFunctionsProvider.overrideWithValue(functions), + firebaseMessagingProvider.overrideWithValue(messaging), + ], + ); + addTearDown(c.dispose); + return c; + } -void main() { group('ForgotPassword', () { test('initial state is AsyncData(false)', () { - final container = ProviderContainer( - overrides: [ - authRepositoryProvider.overrideWithValue(_FakeRepo()), - ], - ); - addTearDown(container.dispose); + final container = makeContainer(); final state = container.read(forgotPasswordProvider); expect(state, isA>()); expect(state.value, false); }); - test('sendRecoveryEmail success → state becomes AsyncData(true)', - () async { - final repo = _FakeRepo(); - final container = ProviderContainer( - overrides: [authRepositoryProvider.overrideWithValue(repo)], - ); - addTearDown(container.dispose); + test( + 'sendRecoveryEmail success → state becomes AsyncData(true)', + () async { + when(account.createRecovery( + email: 'x@y.z', + url: 'https://app/reset', + )).thenAnswer((_) async => Token( + $id: 't1', + $createdAt: DateTime.now().toIso8601String(), + userId: 'u1', + secret: '', + expire: DateTime.now() + .add(const Duration(minutes: 10)) + .toIso8601String(), + phrase: '', + )); - final ok = await container - .read(forgotPasswordProvider.notifier) - .sendRecoveryEmail( - email: 'x@y.z', - redirectUrl: 'https://app/reset', - ); + final container = makeContainer(); + final ok = await container + .read(forgotPasswordProvider.notifier) + .sendRecoveryEmail( + email: 'x@y.z', + redirectUrl: 'https://app/reset', + ); - expect(ok, true); - expect(repo.sendCount, 1); - expect(repo.lastEmail, 'x@y.z'); - expect(repo.lastRedirect, 'https://app/reset'); - expect(container.read(forgotPasswordProvider).value, true); - }); + expect(ok, true); + expect(container.read(forgotPasswordProvider).value, true); + verify(account.createRecovery( + email: 'x@y.z', + url: 'https://app/reset', + )).called(1); + }, + ); test('sendRecoveryEmail error → state becomes AsyncError', () async { - final repo = _FakeRepo()..fail = true; - final container = ProviderContainer( - overrides: [authRepositoryProvider.overrideWithValue(repo)], - ); - addTearDown(container.dispose); + when(account.createRecovery( + email: 'x@y.z', + url: 'u', + )).thenThrow(AppwriteException('boom', 500)); + final container = makeContainer(); final ok = await container .read(forgotPasswordProvider.notifier) .sendRecoveryEmail(email: 'x@y.z', redirectUrl: 'u'); diff --git a/test/features/rooms/data/rooms_repository_test.dart b/test/features/rooms/data/rooms_repository_test.dart new file mode 100644 index 00000000..4eadee4f --- /dev/null +++ b/test/features/rooms/data/rooms_repository_test.dart @@ -0,0 +1,342 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/model/room_failure.dart'; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; + +import 'rooms_repository_test.mocks.dart'; + +@GenerateMocks([TablesDB, Realtime, Functions]) +Row roomRow({ + String id = 'room-1', + String name = 'Sample Room', + String description = 'A nice room', + String adminUid = 'admin-uid', + int totalParticipants = 3, + List tags = const ['tag1', 'tag2'], + List reportedUsers = const [], +}) { + return Row( + $id: id, + $sequence: 0, + $tableId: roomsTableId, + $databaseId: masterDatabaseId, + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + $permissions: const [], + data: { + 'name': name, + 'description': description, + 'totalParticipants': totalParticipants, + 'tags': tags, + 'adminUid': adminUid, + 'reportedUsers': reportedUsers, + }, + ); +} + +Row userRow({ + String id = 'user-1', + String name = 'A User', + String email = 'u@test.com', + String profileImageUrl = 'https://example.com/u.jpg', +}) { + return Row( + $id: id, + $sequence: 0, + $tableId: usersTableID, + $databaseId: userDatabaseID, + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + $permissions: const [], + data: { + 'name': name, + 'email': email, + 'profileImageUrl': profileImageUrl, + }, + ); +} + +Row participantRow({ + String id = 'p-1', + String uid = 'user-1', + String roomId = 'room-1', + bool isAdmin = false, + bool isMicOn = false, + bool isModerator = false, + bool isSpeaker = false, +}) { + return Row( + $id: id, + $sequence: 0, + $tableId: participantsTableId, + $databaseId: masterDatabaseId, + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + $permissions: const [], + data: { + 'roomId': roomId, + 'uid': uid, + 'isAdmin': isAdmin, + 'isMicOn': isMicOn, + 'isModerator': isModerator, + 'isSpeaker': isSpeaker, + }, + ); +} + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late RoomsRepository repo; + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + repo = RoomsRepository( + tables: tables, + realtime: realtime, + apiService: ApiService(functions: functions), + ); + }); + + group('loadRooms', () { + test('returns rooms and filters out reported users', () async { + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + ), + ).thenAnswer( + (_) async => RowList( + total: 2, + rows: [ + roomRow(id: 'r1', name: 'Visible'), + roomRow( + id: 'r2', + name: 'Reported', + reportedUsers: const ['admin-uid'], + ), + ], + ), + ); + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + ), + ).thenAnswer((_) async => RowList(total: 0, rows: [])); + + final rooms = await repo.loadRooms('admin-uid'); + + expect(rooms, hasLength(1)); + expect(rooms.first.id, 'r1'); + expect(rooms.first.isUserAdmin, isTrue); + }); + }); + + group('getRoomById', () { + test('returns null on 404', () async { + when( + tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'missing', + ), + ).thenThrow(AppwriteException('not found', 404)); + + final result = await repo.getRoomById('missing', 'u1'); + expect(result, isNull); + }); + + test('builds AppwriteRoom on success', () async { + when( + tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'r1', + ), + ).thenAnswer((_) async => roomRow(id: 'r1', adminUid: 'someone-else')); + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + ), + ).thenAnswer((_) async => RowList(total: 0, rows: [])); + + final room = await repo.getRoomById('r1', 'u1'); + + expect(room, isNotNull); + expect(room!.id, 'r1'); + expect(room.isUserAdmin, isFalse); + }); + }); + + group('loadParticipants', () { + test('joins participant + user data', () async { + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + ), + ).thenAnswer( + (_) async => RowList( + total: 1, + rows: [participantRow(uid: 'user-1', isAdmin: true)], + ), + ); + when( + tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'user-1', + ), + ).thenAnswer((_) async => userRow(id: 'user-1', name: 'Alice')); + + final participants = await repo.loadParticipants('room-1'); + + expect(participants, hasLength(1)); + expect(participants.first.name, 'Alice'); + expect(participants.first.isAdmin, isTrue); + }); + }); + + group('leaveRoom', () { + test('decrements totalParticipants when others remain', () async { + when( + tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'room-1', + ), + ).thenAnswer((_) async => roomRow(totalParticipants: 3)); + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + ), + ).thenAnswer( + (_) async => RowList( + total: 1, + rows: [participantRow(id: 'p-99', uid: 'user-leaving')], + ), + ); + when( + tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + ), + ).thenAnswer((_) async => ''); + when( + tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + ), + ).thenAnswer((_) async => roomRow()); + + final ok = await repo.leaveRoom(roomId: 'room-1', userId: 'user-leaving'); + + expect(ok, isTrue); + verify( + tables.updateRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'room-1', + data: {'totalParticipants': 2}, + ), + ).called(1); + }); + + test('deletes the room when last participant leaves', () async { + when( + tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'room-1', + ), + ).thenAnswer((_) async => roomRow(totalParticipants: 1)); + when( + tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + ), + ).thenAnswer( + (_) async => RowList( + total: 1, + rows: [participantRow(id: 'p-only', uid: 'last-user')], + ), + ); + when( + tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + ), + ).thenAnswer((_) async => ''); + + final ok = await repo.leaveRoom(roomId: 'room-1', userId: 'last-user'); + + expect(ok, isTrue); + verify( + tables.deleteRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'room-1', + ), + ).called(1); + }); + }); + + group('kickParticipant', () { + test('deletes the participant row', () async { + when( + tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: 'p-7', + ), + ).thenAnswer((_) async => ''); + + await repo.kickParticipant('p-7'); + + verify( + tables.deleteRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: 'p-7', + ), + ).called(1); + }); + }); + + group('error mapping', () { + test('getRoomById maps 401 to permissionDenied', () async { + when( + tables.getRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + ), + ).thenThrow(AppwriteException('unauthorized', 401)); + + expect( + repo.getRoomById('r1', 'u1'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/controllers/upcoming_controller_tests.mocks.dart b/test/features/rooms/data/rooms_repository_test.mocks.dart similarity index 58% rename from test/controllers/upcoming_controller_tests.mocks.dart rename to test/features/rooms/data/rooms_repository_test.mocks.dart index 65a74c4c..c3a64d7f 100644 --- a/test/controllers/upcoming_controller_tests.mocks.dart +++ b/test/features/rooms/data/rooms_repository_test.mocks.dart @@ -1,19 +1,16 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/upcoming_controller_tests.dart. +// in resonate/test/features/rooms/data/rooms_repository_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i8; +import 'dart:async' as _i6; -import 'package:appwrite/appwrite.dart' as _i7; +import 'package:appwrite/appwrite.dart' as _i5; +import 'package:appwrite/enums.dart' as _i8; import 'package:appwrite/models.dart' as _i3; import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/realtime.dart' as _i10; -import 'package:appwrite/src/realtime_subscription.dart' as _i6; -import 'package:firebase_core/firebase_core.dart' as _i4; -import 'package:firebase_messaging/firebase_messaging.dart' as _i9; -import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' - as _i5; +import 'package:appwrite/src/realtime.dart' as _i7; +import 'package:appwrite/src/realtime_subscription.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -57,27 +54,26 @@ class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { : super(parent, parentInvocation); } -class _FakeFirebaseApp_5 extends _i1.SmartFake implements _i4.FirebaseApp { - _FakeFirebaseApp_5(Object parent, Invocation parentInvocation) +class _FakeRealtimeSubscription_5 extends _i1.SmartFake + implements _i4.RealtimeSubscription { + _FakeRealtimeSubscription_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeNotificationSettings_6 extends _i1.SmartFake - implements _i5.NotificationSettings { - _FakeNotificationSettings_6(Object parent, Invocation parentInvocation) +class _FakeExecutionList_6 extends _i1.SmartFake implements _i3.ExecutionList { + _FakeExecutionList_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeRealtimeSubscription_7 extends _i1.SmartFake - implements _i6.RealtimeSubscription { - _FakeRealtimeSubscription_7(Object parent, Invocation parentInvocation) +class _FakeExecution_7 extends _i1.SmartFake implements _i3.Execution { + _FakeExecution_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [TablesDB]. /// /// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i7.TablesDB { +class MockTablesDB extends _i1.Mock implements _i5.TablesDB { MockTablesDB() { _i1.throwOnMissingStub(this); } @@ -91,40 +87,40 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { as _i2.Client); @override - _i8.Future<_i3.TransactionList> listTransactions({List? queries}) => + _i6.Future<_i3.TransactionList> listTransactions({List? queries}) => (super.noSuchMethod( Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i8.Future<_i3.TransactionList>.value( + returnValue: _i6.Future<_i3.TransactionList>.value( _FakeTransactionList_1( this, Invocation.method(#listTransactions, [], {#queries: queries}), ), ), ) - as _i8.Future<_i3.TransactionList>); + as _i6.Future<_i3.TransactionList>); @override - _i8.Future<_i3.Transaction> createTransaction({int? ttl}) => + _i6.Future<_i3.Transaction> createTransaction({int? ttl}) => (super.noSuchMethod( Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i8.Future<_i3.Transaction>.value( + returnValue: _i6.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#createTransaction, [], {#ttl: ttl}), ), ), ) - as _i8.Future<_i3.Transaction>); + as _i6.Future<_i3.Transaction>); @override - _i8.Future<_i3.Transaction> getTransaction({ + _i6.Future<_i3.Transaction> getTransaction({ required String? transactionId, }) => (super.noSuchMethod( Invocation.method(#getTransaction, [], { #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Transaction>.value( + returnValue: _i6.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#getTransaction, [], { @@ -133,10 +129,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Transaction>); + as _i6.Future<_i3.Transaction>); @override - _i8.Future<_i3.Transaction> updateTransaction({ + _i6.Future<_i3.Transaction> updateTransaction({ required String? transactionId, bool? commit, bool? rollback, @@ -147,7 +143,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #commit: commit, #rollback: rollback, }), - returnValue: _i8.Future<_i3.Transaction>.value( + returnValue: _i6.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#updateTransaction, [], { @@ -158,20 +154,20 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Transaction>); + as _i6.Future<_i3.Transaction>); @override - _i8.Future deleteTransaction({required String? transactionId}) => + _i6.Future deleteTransaction({required String? transactionId}) => (super.noSuchMethod( Invocation.method(#deleteTransaction, [], { #transactionId: transactionId, }), - returnValue: _i8.Future.value(), + returnValue: _i6.Future.value(), ) - as _i8.Future); + as _i6.Future); @override - _i8.Future<_i3.Transaction> createOperations({ + _i6.Future<_i3.Transaction> createOperations({ required String? transactionId, List>? operations, }) => @@ -180,7 +176,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #transactionId: transactionId, #operations: operations, }), - returnValue: _i8.Future<_i3.Transaction>.value( + returnValue: _i6.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#createOperations, [], { @@ -190,10 +186,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Transaction>); + as _i6.Future<_i3.Transaction>); @override - _i8.Future<_i3.RowList> listRows({ + _i6.Future<_i3.RowList> listRows({ required String? databaseId, required String? tableId, List? queries, @@ -208,7 +204,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #transactionId: transactionId, #total: total, }), - returnValue: _i8.Future<_i3.RowList>.value( + returnValue: _i6.Future<_i3.RowList>.value( _FakeRowList_3( this, Invocation.method(#listRows, [], { @@ -221,10 +217,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.RowList>); + as _i6.Future<_i3.RowList>); @override - _i8.Future<_i3.Row> createRow({ + _i6.Future<_i3.Row> createRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -241,7 +237,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#createRow, [], { @@ -255,10 +251,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); @override - _i8.Future<_i3.Row> getRow({ + _i6.Future<_i3.Row> getRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -273,7 +269,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #queries: queries, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#getRow, [], { @@ -286,10 +282,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); @override - _i8.Future<_i3.Row> upsertRow({ + _i6.Future<_i3.Row> upsertRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -306,7 +302,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#upsertRow, [], { @@ -320,10 +316,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); @override - _i8.Future<_i3.Row> updateRow({ + _i6.Future<_i3.Row> updateRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -340,7 +336,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#updateRow, [], { @@ -354,10 +350,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); @override - _i8.Future deleteRow({ + _i6.Future deleteRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -370,12 +366,12 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #rowId: rowId, #transactionId: transactionId, }), - returnValue: _i8.Future.value(), + returnValue: _i6.Future.value(), ) - as _i8.Future); + as _i6.Future); @override - _i8.Future<_i3.Row> decrementRowColumn({ + _i6.Future<_i3.Row> decrementRowColumn({ required String? databaseId, required String? tableId, required String? rowId, @@ -394,7 +390,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #min: min, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#decrementRowColumn, [], { @@ -409,10 +405,10 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); @override - _i8.Future<_i3.Row> incrementRowColumn({ + _i6.Future<_i3.Row> incrementRowColumn({ required String? databaseId, required String? tableId, required String? rowId, @@ -431,7 +427,7 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { #max: max, #transactionId: transactionId, }), - returnValue: _i8.Future<_i3.Row>.value( + returnValue: _i6.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#incrementRowColumn, [], { @@ -446,228 +442,134 @@ class MockTablesDB extends _i1.Mock implements _i7.TablesDB { ), ), ) - as _i8.Future<_i3.Row>); + as _i6.Future<_i3.Row>); } -/// A class which mocks [FirebaseMessaging]. +/// A class which mocks [Realtime]. /// /// See the documentation for Mockito's code generation for more information. -class MockFirebaseMessaging extends _i1.Mock implements _i9.FirebaseMessaging { - MockFirebaseMessaging() { +class MockRealtime extends _i1.Mock implements _i7.Realtime { + MockRealtime() { _i1.throwOnMissingStub(this); } @override - _i4.FirebaseApp get app => - (super.noSuchMethod( - Invocation.getter(#app), - returnValue: _FakeFirebaseApp_5(this, Invocation.getter(#app)), - ) - as _i4.FirebaseApp); - - @override - bool get isAutoInitEnabled => - (super.noSuchMethod( - Invocation.getter(#isAutoInitEnabled), - returnValue: false, - ) - as bool); - - @override - _i8.Stream get onTokenRefresh => - (super.noSuchMethod( - Invocation.getter(#onTokenRefresh), - returnValue: _i8.Stream.empty(), - ) - as _i8.Stream); - - @override - set app(_i4.FirebaseApp? value) => super.noSuchMethod( - Invocation.setter(#app, value), - returnValueForMissingStub: null, - ); - - @override - Map get pluginConstants => - (super.noSuchMethod( - Invocation.getter(#pluginConstants), - returnValue: {}, - ) - as Map); - - @override - _i8.Future<_i5.RemoteMessage?> getInitialMessage() => - (super.noSuchMethod( - Invocation.method(#getInitialMessage, []), - returnValue: _i8.Future<_i5.RemoteMessage?>.value(), - ) - as _i8.Future<_i5.RemoteMessage?>); - - @override - _i8.Future deleteToken() => + _i2.Client get client => (super.noSuchMethod( - Invocation.method(#deleteToken, []), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), ) - as _i8.Future); + as _i2.Client); @override - _i8.Future getAPNSToken() => + _i4.RealtimeSubscription subscribe(List? channels) => (super.noSuchMethod( - Invocation.method(#getAPNSToken, []), - returnValue: _i8.Future.value(), + Invocation.method(#subscribe, [channels]), + returnValue: _FakeRealtimeSubscription_5( + this, + Invocation.method(#subscribe, [channels]), + ), ) - as _i8.Future); + as _i4.RealtimeSubscription); +} - @override - _i8.Future getToken({String? vapidKey}) => - (super.noSuchMethod( - Invocation.method(#getToken, [], {#vapidKey: vapidKey}), - returnValue: _i8.Future.value(), - ) - as _i8.Future); +/// A class which mocks [Functions]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFunctions extends _i1.Mock implements _i5.Functions { + MockFunctions() { + _i1.throwOnMissingStub(this); + } @override - _i8.Future isSupported() => + _i2.Client get client => (super.noSuchMethod( - Invocation.method(#isSupported, []), - returnValue: _i8.Future.value(false), + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), ) - as _i8.Future); + as _i2.Client); @override - _i8.Future<_i5.NotificationSettings> getNotificationSettings() => + _i6.Future<_i3.ExecutionList> listExecutions({ + required String? functionId, + List? queries, + bool? total, + }) => (super.noSuchMethod( - Invocation.method(#getNotificationSettings, []), - returnValue: _i8.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_6( + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), + returnValue: _i6.Future<_i3.ExecutionList>.value( + _FakeExecutionList_6( this, - Invocation.method(#getNotificationSettings, []), + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), ), ), ) - as _i8.Future<_i5.NotificationSettings>); + as _i6.Future<_i3.ExecutionList>); @override - _i8.Future<_i5.NotificationSettings> requestPermission({ - bool? alert = true, - bool? announcement = false, - bool? badge = true, - bool? carPlay = false, - bool? criticalAlert = false, - bool? provisional = false, - bool? sound = true, - bool? providesAppNotificationSettings = false, + _i6.Future<_i3.Execution> createExecution({ + required String? functionId, + String? body, + bool? xasync, + String? path, + _i8.ExecutionMethod? method, + Map? headers, + String? scheduledAt, }) => (super.noSuchMethod( - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: providesAppNotificationSettings, + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, }), - returnValue: _i8.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_6( + returnValue: _i6.Future<_i3.Execution>.value( + _FakeExecution_7( this, - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: - providesAppNotificationSettings, + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, }), ), ), ) - as _i8.Future<_i5.NotificationSettings>); - - @override - _i8.Future setAutoInitEnabled(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setAutoInitEnabled, [enabled]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); + as _i6.Future<_i3.Execution>); @override - _i8.Future setForegroundNotificationPresentationOptions({ - bool? alert = false, - bool? badge = false, - bool? sound = false, + _i6.Future<_i3.Execution> getExecution({ + required String? functionId, + required String? executionId, }) => (super.noSuchMethod( - Invocation.method( - #setForegroundNotificationPresentationOptions, - [], - {#alert: alert, #badge: badge, #sound: sound}, - ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future subscribeToTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#subscribeToTopic, [topic]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future unsubscribeFromTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#unsubscribeFromTopic, [topic]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); -} - -/// A class which mocks [Realtime]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockRealtime extends _i1.Mock implements _i10.Realtime { - MockRealtime() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i6.RealtimeSubscription subscribe(List? channels) => - (super.noSuchMethod( - Invocation.method(#subscribe, [channels]), - returnValue: _FakeRealtimeSubscription_7( - this, - Invocation.method(#subscribe, [channels]), + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, + }), + returnValue: _i6.Future<_i3.Execution>.value( + _FakeExecution_7( + this, + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, + }), + ), ), ) - as _i6.RealtimeSubscription); + as _i6.Future<_i3.Execution>); } diff --git a/test/features/rooms/viewmodel/create_room_notifier_test.dart b/test/features/rooms/viewmodel/create_room_notifier_test.dart new file mode 100644 index 00000000..8ac23b81 --- /dev/null +++ b/test/features/rooms/viewmodel/create_room_notifier_test.dart @@ -0,0 +1,143 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/rooms/viewmodel/create_room_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +MockExecution _execution(String json) { + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn(json); + return exec; +} + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late MockFirebaseMessaging messaging; + + setUp(() { + stubFlutterSecureStorageChannel(); + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + messaging = MockFirebaseMessaging(); + }); + + group('CreateRoomNotifier', () { + test('createLiveRoom returns room with myDocId on success', () async { + when(functions.createExecution( + functionId: createRoomServiceId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution( + '{"livekit_room":{"name":"r-new"},' + '"access_token":"tok","livekit_socket_url":"wss://example.com"}', + )); + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 'doc-mine', + tableId: participantsTableId, + databaseId: masterDatabaseId, + data: const {}, + )); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + messaging: messaging, + ); + + final room = await container + .read(createRoomProvider.notifier) + .createLiveRoom( + name: 'My Room', + description: 'desc', + tags: const ['t1'], + ); + + expect(room, isNotNull); + expect(room!.id, 'r-new'); + expect(room.myDocId, 'doc-mine'); + expect(room.isUserAdmin, isTrue); + expect(container.read(createRoomProvider), isFalse); + }); + + test( + 'createScheduledRoom triggers an upcoming-rooms refresh via invalidation', + () async { + when(messaging.getToken()).thenAnswer((_) async => 'fcm-token'); + when(tables.createRow( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 'u-new', + tableId: upcomingRoomsTableId, + databaseId: upcomingRoomsDatabaseId, + data: const {}, + )); + // upcomingRoomsProvider.build() reads this table — count invocations + // to confirm a refresh happened. + var listRowsCount = 0; + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + )).thenAnswer((_) async { + listRowsCount++; + return RowList(total: 0, rows: []); + }); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + messaging: messaging, + getStorageBox: FakeGetStorage(), + ); + await container.read(upcomingRoomsProvider.future); + final initialCount = listRowsCount; + + final ok = await container + .read(createRoomProvider.notifier) + .createScheduledRoom( + name: 'Later', + description: 'desc', + tags: const ['t1'], + scheduledDateTime: '2026-12-31T10:00:00Z', + ); + + expect(ok, isTrue); + // Invalidation triggers a re-fetch on the next read. + await container.read(upcomingRoomsProvider.future); + expect(listRowsCount, greaterThan(initialCount)); + }, + ); + + test('isValidTag accepts alphanumeric and rejects symbols', () { + expect('flutter'.isValidTag, isTrue); + expect('flutter_dev'.isValidTag, isTrue); + expect('flutter dev'.isValidTag, isFalse); + expect(''.isValidTag, isFalse); + expect(('a' * 31).isValidTag, isFalse); + }); + }); +} diff --git a/test/features/rooms/viewmodel/room_chat_notifier_test.dart b/test/features/rooms/viewmodel/room_chat_notifier_test.dart new file mode 100644 index 00000000..dc2cf3d2 --- /dev/null +++ b/test/features/rooms/viewmodel/room_chat_notifier_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/rooms/model/room_message.dart'; +import 'package:resonate/features/rooms/viewmodel/room_chat_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late MockRealtimeSubscription subscription; + late StreamController chatEvents; + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + subscription = MockRealtimeSubscription(); + chatEvents = StreamController.broadcast(); + + when(subscription.stream).thenAnswer((_) => chatEvents.stream); + when(subscription.close).thenReturn(() async {}); + when(realtime.subscribe(any)).thenReturn(subscription); + + // Default: no historical messages, no replyTo rows. + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.getRow( + databaseId: masterDatabaseId, + tableId: chatMessageReplyTableId, + rowId: anyNamed('rowId'), + )).thenThrow(AppwriteException('not found', 404)); + }); + + tearDown(() => chatEvents.close()); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + + group('RoomChatNotifier', () { + test('build loads messages from the repository', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 1, + rows: [ + buildRow( + id: 'm1', + tableId: chatMessagesTableId, + databaseId: masterDatabaseId, + data: { + 'roomId': 'room-1', + 'messageId': 'm1', + 'creatorId': 'someone-else', + 'creatorUsername': 'other', + 'creatorName': 'Other', + 'creatorImgUrl': '', + 'hasValidTag': false, + 'index': 0, + 'isEdited': false, + 'content': 'hello', + 'creationDateTime': + DateTime.now().toUtc().toIso8601String(), + 'isDeleted': false, + }, + ), + ], + )); + + final container = await install(); + final state = await container + .read(roomChatProvider('room-1', 'Room 1', false).future); + + expect(state.messages, hasLength(1)); + expect(state.messages.first.messageId, 'm1'); + }); + + test( + 'sendMessage inserts optimistically and flips to sent on POST success', + () async { + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 'mid', + tableId: chatMessagesTableId, + databaseId: masterDatabaseId, + data: const {}, + )); + + final container = await install(); + final providerKey = roomChatProvider('room-1', 'Room 1', false); + container.listen(providerKey, (_, _) {}); + await container.read(providerKey.future); + + final ok = await container.read(providerKey.notifier).sendMessage( + roomId: 'room-1', + roomName: 'Room 1', + isUpcoming: false, + content: 'hi', + ); + + expect(ok, isTrue); + final messages = container.read(providerKey).value!.messages; + expect(messages, hasLength(1)); + expect(messages.first.content, 'hi'); + expect(messages.first.status, RoomMessageStatus.sent); + }, + ); + + test('sendMessage marks message as failed when POST throws', () async { + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenThrow(Exception('network down')); + + final container = await install(); + final providerKey = roomChatProvider('room-1', 'Room 1', false); + container.listen(providerKey, (_, _) {}); + await container.read(providerKey.future); + + final ok = await container.read(providerKey.notifier).sendMessage( + roomId: 'room-1', + roomName: 'Room 1', + isUpcoming: false, + content: 'oops', + ); + + expect(ok, isFalse); + final messages = container.read(providerKey).value!.messages; + expect(messages, hasLength(1)); + expect(messages.first.status, RoomMessageStatus.failed); + }); + + test('retrySend flips a failed message back to sent on success', () async { + var shouldFail = true; + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async { + if (shouldFail) throw Exception('network down'); + return buildRow( + id: 'mid', + tableId: chatMessagesTableId, + databaseId: masterDatabaseId, + data: const {}, + ); + }); + + final container = await install(); + final providerKey = roomChatProvider('room-1', 'Room 1', false); + container.listen(providerKey, (_, _) {}); + await container.read(providerKey.future); + + await container.read(providerKey.notifier).sendMessage( + roomId: 'room-1', + roomName: 'Room 1', + isUpcoming: false, + content: 'oops', + ); + final failedId = + container.read(providerKey).value!.messages.first.messageId; + + shouldFail = false; + final ok = await container.read(providerKey.notifier).retrySend( + messageId: failedId, + roomName: 'Room 1', + isUpcoming: false, + ); + + expect(ok, isTrue); + final retried = container + .read(providerKey) + .value! + .messages + .firstWhere((m) => m.messageId == failedId); + expect(retried.status, RoomMessageStatus.sent); + }); + + test( + 'realtime create echo dedupes by messageId — no duplicate appended', + () async { + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: chatMessagesTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 'mid', + tableId: chatMessagesTableId, + databaseId: masterDatabaseId, + data: const {}, + )); + + final container = await install(); + final providerKey = roomChatProvider('room-1', 'Room 1', false); + container.listen(providerKey, (_, _) {}); + await container.read(providerKey.future); + + await container.read(providerKey.notifier).sendMessage( + roomId: 'room-1', + roomName: 'Room 1', + isUpcoming: false, + content: 'hi', + ); + final sentId = + container.read(providerKey).value!.messages.first.messageId; + + // Server echoes the same message back via realtime. + final channel = + 'databases.$masterDatabaseId.tables.$chatMessagesTableId.rows'; + chatEvents.add(RealtimeMessage( + events: ['$channel.$sentId.create'], + payload: { + '\$id': sentId, + 'roomId': 'room-1', + 'messageId': sentId, + 'creatorId': 'me', + 'creatorUsername': 'me', + 'creatorName': 'Me', + 'creatorImgUrl': '', + 'hasValidTag': false, + 'index': 0, + 'isEdited': false, + 'content': 'hi', + 'creationDateTime': DateTime.now().toUtc().toIso8601String(), + 'isDeleted': false, + }, + channels: [channel], + timestamp: DateTime.now().toIso8601String(), + )); + await Future.delayed(Duration.zero); + + final messages = container.read(providerKey).value!.messages; + expect(messages, hasLength(1)); + expect(messages.first.messageId, sentId); + }, + ); + }); +} diff --git a/test/features/rooms/viewmodel/rooms_notifier_test.dart b/test/features/rooms/viewmodel/rooms_notifier_test.dart new file mode 100644 index 00000000..d79cfe0f --- /dev/null +++ b/test/features/rooms/viewmodel/rooms_notifier_test.dart @@ -0,0 +1,244 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/rooms/model/rooms_state.dart'; +import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _roomRow({ + String id = 'r1', + String name = 'Room', + String description = '', + int totalParticipants = 1, + List tags = const [], + String adminUid = 'me', + List reportedUsers = const [], +}) => + buildRow( + id: id, + tableId: roomsTableId, + databaseId: masterDatabaseId, + data: { + 'name': name, + 'description': description, + 'totalParticipants': totalParticipants, + 'tags': tags, + 'adminUid': adminUid, + 'reportedUsers': reportedUsers, + }, + ); + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + // Default: no participants for any room (avoids per-test boilerplate). + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: participantsTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + }); + + group('RoomsNotifier', () { + test('build loads rooms from the repository', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async => RowList( + total: 2, + rows: [_roomRow(id: 'r1'), _roomRow(id: 'r2')], + )); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + + final state = await container.read(roomsProvider.future); + + expect(state, isA()); + expect((state as RoomsStateReady).rooms, hasLength(2)); + }); + + test('joinRoom returns room with myDocId populated', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + // joinRoom internally calls the cloud function + addParticipant. + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async => ''); + when(tables.createRow( + databaseId: masterDatabaseId, + tableId: participantsTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((inv) async => buildRow( + id: 'doc-mine', + tableId: participantsTableId, + databaseId: masterDatabaseId, + data: const {}, + )); + when(tables.getRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: 'r1', + )).thenAnswer((_) async => _roomRow(id: 'r1', totalParticipants: 1)); + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => _roomRow()); + // The cloud-function call from ApiService.joinRoom. + when(functions.createExecution( + functionId: joinRoomServiceId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution({ + 'access_token': 'tok', + 'livekit_socket_url': 'wss://example.com', + })); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + await container.read(roomsProvider.future); + + final joined = await container + .read(roomsProvider.notifier) + .joinRoom(fakeAppwriteRoom(id: 'r1', isUserAdmin: false)); + + expect(joined.myDocId, 'doc-mine'); + }); + + test('refresh re-invokes repository load', () async { + var callCount = 0; + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async { + callCount++; + return RowList(total: 0, rows: []); + }); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + await container.read(roomsProvider.future); + expect(callCount, 1); + + await container.read(roomsProvider.notifier).refresh(); + expect(callCount, 2); + }); + + test('joinRoom rethrows when the cloud function fails', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(functions.createExecution( + functionId: joinRoomServiceId, + body: anyNamed('body'), + )).thenThrow(Exception('appwrite down')); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + await container.read(roomsProvider.future); + + expect( + () => container + .read(roomsProvider.notifier) + .joinRoom(fakeAppwriteRoom(id: 'r1')), + throwsA(isA()), + ); + }); + + test('searchLiveRooms filters loaded rooms by name', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async => RowList( + total: 2, + rows: [ + _roomRow(id: 'r1', name: 'Flutter Devs'), + _roomRow(id: 'r2', name: 'Backend Talk'), + ], + )); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + await container.read(roomsProvider.future); + + container.read(roomsProvider.notifier).searchLiveRooms('flutter'); + + final ready = container.read(roomsProvider).value! as RoomsStateReady; + expect(ready.isSearching, isTrue); + expect(ready.filteredRooms, hasLength(1)); + expect(ready.filteredRooms.first.name, 'Flutter Devs'); + }); + + test('clearLiveSearch resets filter state', () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: roomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + final container = await installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + await container.read(roomsProvider.future); + + container.read(roomsProvider.notifier).searchLiveRooms('hello'); + container.read(roomsProvider.notifier).clearLiveSearch(); + + final ready = container.read(roomsProvider).value! as RoomsStateReady; + expect(ready.isSearching, isFalse); + expect(ready.searchBarIsEmpty, isTrue); + expect(ready.filteredRooms, isEmpty); + }); + }); +} + +MockExecution _execution(Map body) { + final json = '{' + '"livekit_room":{"name":"r1"},' + '"access_token":"${body['access_token']}",' + '"livekit_socket_url":"${body['livekit_socket_url']}"' + '}'; + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn(json); + return exec; +} diff --git a/test/features/rooms/viewmodel/upcoming_rooms_notifier_test.dart b/test/features/rooms/viewmodel/upcoming_rooms_notifier_test.dart new file mode 100644 index 00000000..6df7840e --- /dev/null +++ b/test/features/rooms/viewmodel/upcoming_rooms_notifier_test.dart @@ -0,0 +1,204 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _upcomingRow({ + String id = 'u1', + String name = 'Upcoming', + bool isTime = false, + String description = '', + String creatorUid = 'me', + List tags = const [], +}) => + buildRow( + id: id, + tableId: upcomingRoomsTableId, + databaseId: upcomingRoomsDatabaseId, + data: { + 'name': name, + 'isTime': isTime, + 'scheduledDateTime': DateTime.now().toUtc().toIso8601String(), + 'description': description, + 'creatorUid': creatorUid, + 'tags': tags, + }, + ); + +void main() { + late MockTablesDB tables; + late MockFirebaseMessaging messaging; + + setUp(() { + tables = MockTablesDB(); + messaging = MockFirebaseMessaging(); + when(messaging.getToken()).thenAnswer((_) async => 'fcm-token'); + // Default: no subscribers for any upcoming room. + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + }); + + Future installWith({ + required List upcomingRows, + FakeGetStorage? storage, + }) { + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + )).thenAnswer((_) async => RowList(total: upcomingRows.length, rows: upcomingRows)); + + return installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + messaging: messaging, + getStorageBox: storage ?? FakeGetStorage(), + ); + } + + group('UpcomingRoomsNotifier', () { + test('build loads and hydrates upcoming rooms from the repo', () async { + final container = await installWith( + upcomingRows: [ + _upcomingRow(id: 'u1', name: 'Morning sync'), + _upcomingRow(id: 'u2', name: 'Demo'), + ], + ); + + final rooms = await container.read(upcomingRoomsProvider.future); + + expect(rooms, hasLength(2)); + expect(rooms.map((r) => r.id), ['u1', 'u2']); + }); + + test('subscribe calls repo.addSubscriber and refreshes', () async { + final container = await installWith( + upcomingRows: [_upcomingRow(id: 'u1')], + ); + await container.read(upcomingRoomsProvider.future); + clearInteractions(tables); + + when(tables.createRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 's-new', + tableId: subscribedUserTableId, + databaseId: upcomingRoomsDatabaseId, + data: const {}, + )); + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + await container.read(upcomingRoomsProvider.notifier).subscribe('u1'); + + verify(tables.createRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + }); + + test('unsubscribe calls repo.removeSubscriber and refreshes', () async { + final container = await installWith( + upcomingRows: [_upcomingRow(id: 'u1')], + ); + await container.read(upcomingRoomsProvider.future); + clearInteractions(tables); + + // The repo's removeSubscriber first lists rows to find the subscription. + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 1, + rows: [ + buildRow( + id: 'sub-doc-1', + tableId: subscribedUserTableId, + databaseId: upcomingRoomsDatabaseId, + data: const {'userID': 'me', 'upcomingRoomId': 'u1'}, + ), + ], + )); + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: 'sub-doc-1', + )).thenAnswer((_) async => ''); + + await container.read(upcomingRoomsProvider.notifier).unsubscribe('u1'); + + verify(tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + rowId: 'sub-doc-1', + )).called(1); + }); + + test('deleteUpcoming deletes the row + its subscribers', () async { + final container = await installWith( + upcomingRows: [_upcomingRow(id: 'u1')], + ); + await container.read(upcomingRoomsProvider.future); + clearInteractions(tables); + + when(tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + rowId: 'u1', + )).thenAnswer((_) async => ''); + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: subscribedUserTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.listRows( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + await container + .read(upcomingRoomsProvider.notifier) + .deleteUpcoming('u1'); + + verify(tables.deleteRow( + databaseId: upcomingRoomsDatabaseId, + tableId: upcomingRoomsTableId, + rowId: 'u1', + )).called(1); + }); + + test('hideLocally persists to storage and filters out from state', () async { + final storage = FakeGetStorage(); + final container = await installWith( + upcomingRows: [_upcomingRow(id: 'u1'), _upcomingRow(id: 'u2')], + storage: storage, + ); + await container.read(upcomingRoomsProvider.future); + + await container.read(upcomingRoomsProvider.notifier).hideLocally('u1'); + + final visible = container.read(upcomingRoomsProvider).value!; + expect(visible.map((r) => r.id), ['u2']); + expect(storage.read('removed_upcoming_rooms'), ['u1']); + }); + }); +} diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index 2d09d74b..64edb053 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -1,16 +1,37 @@ import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:mockito/annotations.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/core/providers/appwrite_providers.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/core/providers/get_storage_provider.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; +import 'package:resonate/features/rooms/model/livekit_state.dart'; +import 'package:resonate/features/rooms/model/participant.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/utils/enums/room_state.dart'; + +// Mocks shared by every notifier/repo test in the suite. +@GenerateMocks([ + Account, + TablesDB, + Realtime, + Functions, + RealtimeSubscription, + FirebaseMessaging, + Execution, +]) +// Data builders -/// Reusable AuthUser for tests. AuthUser fakeAuthUser({ String uid = '123', String email = 'test@test.com', @@ -36,63 +57,172 @@ AuthUser fakeAuthUser({ ratingCount: ratingCount, ); -class FakeAuthRepository implements AuthRepository { - FakeAuthRepository(this.state); +AppwriteRoom fakeAppwriteRoom({ + String id = 'room-1', + String name = 'Test Room', + String description = 'A room for testing', + int totalParticipants = 1, + List tags = const ['test'], + List memberAvatarUrls = const [], + bool isUserAdmin = true, + List reportedUsers = const [], + String? myDocId, +}) => + AppwriteRoom( + id: id, + name: name, + description: description, + totalParticipants: totalParticipants, + tags: tags, + memberAvatarUrls: memberAvatarUrls, + state: RoomState.live, + isUserAdmin: isUserAdmin, + reportedUsers: reportedUsers, + myDocId: myDocId, + ); + +AppwriteUpcomingRoom fakeUpcomingRoom({ + String id = 'upcoming-1', + String name = 'Test Upcoming', + bool isTime = false, + DateTime? scheduledDateTime, + String description = 'A scheduled room', + int totalSubscriberCount = 0, + List tags = const ['test'], + List subscribersAvatarUrls = const [], + bool userIsCreator = true, + bool hasUserSubscribed = false, +}) => + AppwriteUpcomingRoom( + id: id, + name: name, + isTime: isTime, + scheduledDateTime: scheduledDateTime ?? DateTime.now(), + description: description, + totalSubscriberCount: totalSubscriberCount, + tags: tags, + subscribersAvatarUrls: subscribersAvatarUrls, + userIsCreator: userIsCreator, + hasUserSubscribed: hasUserSubscribed, + ); + +Participant fakeParticipant({ + String uid = 'p-1', + String email = 'p@test.com', + String name = 'Person', + String dpUrl = 'https://example.com/dp.jpg', + bool isAdmin = false, + bool isMicOn = false, + bool isModerator = false, + bool isSpeaker = false, + bool hasRequestedToBeSpeaker = false, +}) => + Participant( + uid: uid, + email: email, + name: name, + dpUrl: dpUrl, + isAdmin: isAdmin, + isMicOn: isMicOn, + isModerator: isModerator, + isSpeaker: isSpeaker, + hasRequestedToBeSpeaker: hasRequestedToBeSpeaker, + ); + +void stubFlutterSecureStorageChannel() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); +} + +Row buildRow({ + required String id, + required Map data, + String tableId = 'test-table', + String databaseId = 'test-db', +}) => + Row( + $id: id, + $sequence: 0, + $tableId: tableId, + $databaseId: databaseId, + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + $permissions: const [], + data: data, + ); + +class FakeGetStorage implements GetStorage { + final Map _data = {}; - AuthState state; - int loadCount = 0; - int loginCount = 0; - int signupCount = 0; - int logoutCount = 0; - int addTokenCount = 0; - int removeTokenCount = 0; + @override + T? read(String key) => _data[key] as T?; @override - Future loadCurrentUser() async { - loadCount++; - return state; + Future write(String key, dynamic value) async { + _data[key] = value; } @override - Future login({required String email, required String password}) async { - loginCount++; + Future remove(String key) async { + _data.remove(key); } @override - Future signup({required String email, required String password}) async { - signupCount++; + Future erase() async { + _data.clear(); } @override - Future logout() async { - logoutCount++; - } + bool hasData(String key) => _data.containsKey(key); @override - Future loginWithGoogle() async {} + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError( + '${invocation.memberName} not stubbed in FakeGetStorage', + ); +} +class FakeLiveKitNotifier extends LiveKitNotifier { @override - Future loginWithGithub() async {} + LiveKitState build() => const LiveKitState(); @override - Future addRegistrationToken({required String uid}) async { - addTokenCount++; + Future connect({ + required String liveKitUri, + required String roomToken, + bool isLiveChapter = false, + }) async { + state = const LiveKitState(hasSession: true, isConnected: true); + return true; } @override - Future removeRegistrationToken({required String uid}) async { - removeTokenCount++; + Future disconnect() async { + state = const LiveKitState(); } @override - dynamic noSuchMethod(Invocation invocation) => - throw UnimplementedError( - '${invocation.memberName} not stubbed in FakeAuthRepository', - ); + Future setMicrophoneEnabled(bool enabled) async {} + + @override + Future setRecording(bool recording) async { + state = state.copyWith(isRecording: recording); + } +} + +class _StubAuthNotifier extends AuthNotifier { + _StubAuthNotifier(this._initial); + final AuthState _initial; + + @override + Future build() async => _initial; } Future installTestRootContainer({ - AuthState authState = const AuthState.unauthenticated(), + AuthState? authState, Account? account, TablesDB? tables, Client? client, @@ -100,13 +230,15 @@ Future installTestRootContainer({ Functions? functions, Realtime? realtime, FirebaseMessaging? messaging, - FakeAuthRepository? authRepository, + GetStorage? getStorageBox, }) async { - final fakeRepo = authRepository ?? FakeAuthRepository(authState); - final container = ProviderContainer( overrides: [ - authRepositoryProvider.overrideWithValue(fakeRepo), + if (authState != null) + authProvider.overrideWith(() => _StubAuthNotifier(authState)), + if (getStorageBox != null) + getStorageBoxProvider.overrideWithValue(getStorageBox), + liveKitProvider.overrideWith(FakeLiveKitNotifier.new), if (account != null) appwriteAccountProvider.overrideWithValue(account), if (tables != null) appwriteTablesProvider.overrideWithValue(tables), if (client != null) appwriteClientProvider.overrideWithValue(client), @@ -120,7 +252,9 @@ Future installTestRootContainer({ ], ); setRootContainerForTesting(container); - await container.read(authProvider.future); + if (authState != null || account != null) { + await container.read(authProvider.future); + } addTearDown(container.dispose); return container; } diff --git a/test/controllers/change_email_controller_test.mocks.dart b/test/helpers/test_root_container.mocks.dart similarity index 63% rename from test/controllers/change_email_controller_test.mocks.dart rename to test/helpers/test_root_container.mocks.dart index d89a677a..128e0b80 100644 --- a/test/controllers/change_email_controller_test.mocks.dart +++ b/test/helpers/test_root_container.mocks.dart @@ -1,15 +1,23 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/change_email_controller_test.dart. +// in resonate/test/helpers/test_root_container.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'package:appwrite/appwrite.dart' as _i4; -import 'package:appwrite/enums.dart' as _i6; +import 'package:appwrite/appwrite.dart' as _i8; +import 'package:appwrite/enums.dart' as _i9; import 'package:appwrite/models.dart' as _i3; import 'package:appwrite/src/client.dart' as _i2; +import 'package:appwrite/src/realtime.dart' as _i10; +import 'package:appwrite/src/realtime_message.dart' as _i11; +import 'package:appwrite/src/realtime_subscription.dart' as _i4; +import 'package:firebase_core/firebase_core.dart' as _i6; +import 'package:firebase_messaging/firebase_messaging.dart' as _i12; +import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' + as _i7; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i13; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -31,98 +39,131 @@ class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { : super(parent, parentInvocation); } -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) +class _FakeUser_1 extends _i1.SmartFake implements _i3.User { + _FakeUser_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) +class _FakeIdentityList_2 extends _i1.SmartFake implements _i3.IdentityList { + _FakeIdentityList_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) +class _FakeJwt_3 extends _i1.SmartFake implements _i3.Jwt { + _FakeJwt_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) +class _FakeLogList_4 extends _i1.SmartFake implements _i3.LogList { + _FakeLogList_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeUser_5 extends _i1.SmartFake implements _i3.User { - _FakeUser_5(Object parent, Invocation parentInvocation) +class _FakeMfaType_5 extends _i1.SmartFake implements _i3.MfaType { + _FakeMfaType_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeIdentityList_6 extends _i1.SmartFake implements _i3.IdentityList { - _FakeIdentityList_6(Object parent, Invocation parentInvocation) +class _FakeMfaChallenge_6 extends _i1.SmartFake implements _i3.MfaChallenge { + _FakeMfaChallenge_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeJwt_7 extends _i1.SmartFake implements _i3.Jwt { - _FakeJwt_7(Object parent, Invocation parentInvocation) +class _FakeSession_7 extends _i1.SmartFake implements _i3.Session { + _FakeSession_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeLogList_8 extends _i1.SmartFake implements _i3.LogList { - _FakeLogList_8(Object parent, Invocation parentInvocation) +class _FakeMfaFactors_8 extends _i1.SmartFake implements _i3.MfaFactors { + _FakeMfaFactors_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaType_9 extends _i1.SmartFake implements _i3.MfaType { - _FakeMfaType_9(Object parent, Invocation parentInvocation) +class _FakeMfaRecoveryCodes_9 extends _i1.SmartFake + implements _i3.MfaRecoveryCodes { + _FakeMfaRecoveryCodes_9(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaChallenge_10 extends _i1.SmartFake implements _i3.MfaChallenge { - _FakeMfaChallenge_10(Object parent, Invocation parentInvocation) +class _FakePreferences_10 extends _i1.SmartFake implements _i3.Preferences { + _FakePreferences_10(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSession_11 extends _i1.SmartFake implements _i3.Session { - _FakeSession_11(Object parent, Invocation parentInvocation) +class _FakeToken_11 extends _i1.SmartFake implements _i3.Token { + _FakeToken_11(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaFactors_12 extends _i1.SmartFake implements _i3.MfaFactors { - _FakeMfaFactors_12(Object parent, Invocation parentInvocation) +class _FakeSessionList_12 extends _i1.SmartFake implements _i3.SessionList { + _FakeSessionList_12(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaRecoveryCodes_13 extends _i1.SmartFake - implements _i3.MfaRecoveryCodes { - _FakeMfaRecoveryCodes_13(Object parent, Invocation parentInvocation) +class _FakeTarget_13 extends _i1.SmartFake implements _i3.Target { + _FakeTarget_13(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransactionList_14 extends _i1.SmartFake + implements _i3.TransactionList { + _FakeTransactionList_14(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakePreferences_14 extends _i1.SmartFake implements _i3.Preferences { - _FakePreferences_14(Object parent, Invocation parentInvocation) +class _FakeTransaction_15 extends _i1.SmartFake implements _i3.Transaction { + _FakeTransaction_15(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeToken_15 extends _i1.SmartFake implements _i3.Token { - _FakeToken_15(Object parent, Invocation parentInvocation) +class _FakeRowList_16 extends _i1.SmartFake implements _i3.RowList { + _FakeRowList_16(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSessionList_16 extends _i1.SmartFake implements _i3.SessionList { - _FakeSessionList_16(Object parent, Invocation parentInvocation) +class _FakeRow_17 extends _i1.SmartFake implements _i3.Row { + _FakeRow_17(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeTarget_17 extends _i1.SmartFake implements _i3.Target { - _FakeTarget_17(Object parent, Invocation parentInvocation) +class _FakeRealtimeSubscription_18 extends _i1.SmartFake + implements _i4.RealtimeSubscription { + _FakeRealtimeSubscription_18(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -/// A class which mocks [TablesDB]. +class _FakeExecutionList_19 extends _i1.SmartFake implements _i3.ExecutionList { + _FakeExecutionList_19(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeExecution_20 extends _i1.SmartFake implements _i3.Execution { + _FakeExecution_20(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStreamController_21 extends _i1.SmartFake + implements _i5.StreamController { + _FakeStreamController_21(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFirebaseApp_22 extends _i1.SmartFake implements _i6.FirebaseApp { + _FakeFirebaseApp_22(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeNotificationSettings_23 extends _i1.SmartFake + implements _i7.NotificationSettings { + _FakeNotificationSettings_23(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Account]. /// /// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i4.TablesDB { - MockTablesDB() { +class MockAccount extends _i1.Mock implements _i8.Account { + MockAccount() { _i1.throwOnMissingStub(this); } @@ -135,555 +176,429 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { as _i2.Client); @override - _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => + _i5.Future<_i3.User> get() => (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i5.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), + Invocation.method(#get, []), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#get, [])), ), ) - as _i5.Future<_i3.TransactionList>); + as _i5.Future<_i3.User>); @override - _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => + _i5.Future<_i3.User> create({ + required String? userId, + required String? email, + required String? password, + String? name, + }) => (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), ), ), ) - as _i5.Future<_i3.Transaction>); + as _i5.Future<_i3.User>); @override - _i5.Future<_i3.Transaction> getTransaction({ - required String? transactionId, + _i5.Future<_i3.User> updateEmail({ + required String? email, + required String? password, }) => (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, }), ), ), ) - as _i5.Future<_i3.Transaction>); + as _i5.Future<_i3.User>); @override - _i5.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, + _i5.Future<_i3.IdentityList> listIdentities({ + List? queries, + bool? total, }) => (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( + returnValue: _i5.Future<_i3.IdentityList>.value( + _FakeIdentityList_2( this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, }), ), ), ) - as _i5.Future<_i3.Transaction>); + as _i5.Future<_i3.IdentityList>); @override - _i5.Future deleteTransaction({required String? transactionId}) => + _i5.Future deleteIdentity({required String? identityId}) => (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), + Invocation.method(#deleteIdentity, [], {#identityId: identityId}), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => + _i5.Future<_i3.Jwt> createJWT() => (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), + Invocation.method(#createJWT, []), + returnValue: _i5.Future<_i3.Jwt>.value( + _FakeJwt_3(this, Invocation.method(#createJWT, [])), ), ) - as _i5.Future<_i3.Transaction>); + as _i5.Future<_i3.Jwt>); @override - _i5.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => + _i5.Future<_i3.LogList> listLogs({List? queries, bool? total}) => (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, + Invocation.method(#listLogs, [], { #queries: queries, - #transactionId: transactionId, #total: total, }), - returnValue: _i5.Future<_i3.RowList>.value( - _FakeRowList_3( + returnValue: _i5.Future<_i3.LogList>.value( + _FakeLogList_4( this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, + Invocation.method(#listLogs, [], { #queries: queries, - #transactionId: transactionId, #total: total, }), ), ), ) - as _i5.Future<_i3.RowList>); + as _i5.Future<_i3.LogList>); @override - _i5.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, + _i5.Future<_i3.User> updateMFA({required bool? mfa}) => + (super.noSuchMethod( + Invocation.method(#updateMFA, [], {#mfa: mfa}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.MfaType> createMfaAuthenticator({ + required _i9.AuthenticatorType? type, }) => (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( + Invocation.method(#createMfaAuthenticator, [], {#type: type}), + returnValue: _i5.Future<_i3.MfaType>.value( + _FakeMfaType_5( this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), + Invocation.method(#createMfaAuthenticator, [], {#type: type}), ), ), ) - as _i5.Future<_i3.Row>); + as _i5.Future<_i3.MfaType>); @override - _i5.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, + _i5.Future<_i3.MfaType> createMFAAuthenticator({ + required _i9.AuthenticatorType? type, }) => (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( + Invocation.method(#createMFAAuthenticator, [], {#type: type}), + returnValue: _i5.Future<_i3.MfaType>.value( + _FakeMfaType_5( this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), + Invocation.method(#createMFAAuthenticator, [], {#type: type}), ), ), ) - as _i5.Future<_i3.Row>); + as _i5.Future<_i3.MfaType>); @override - _i5.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, + _i5.Future<_i3.User> updateMfaAuthenticator({ + required _i9.AuthenticatorType? type, + required String? otp, }) => (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, }), ), ), ) - as _i5.Future<_i3.Row>); + as _i5.Future<_i3.User>); @override - _i5.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, + _i5.Future<_i3.User> updateMFAAuthenticator({ + required _i9.AuthenticatorType? type, + required String? otp, }) => (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, }), ), ), ) - as _i5.Future<_i3.Row>); + as _i5.Future<_i3.User>); @override - _i5.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, + _i5.Future deleteMfaAuthenticator({ + required _i9.AuthenticatorType? type, }) => (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), + Invocation.method(#deleteMfaAuthenticator, [], {#type: type}), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, + _i5.Future deleteMFAAuthenticator({ + required _i9.AuthenticatorType? type, }) => (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), + Invocation.method(#deleteMFAAuthenticator, [], {#type: type}), + returnValue: _i5.Future.value(), ) - as _i5.Future<_i3.Row>); + as _i5.Future); @override - _i5.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, + _i5.Future<_i3.MfaChallenge> createMfaChallenge({ + required _i9.AuthenticationFactor? factor, }) => (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( + Invocation.method(#createMfaChallenge, [], {#factor: factor}), + returnValue: _i5.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_6( this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), + Invocation.method(#createMfaChallenge, [], {#factor: factor}), ), ), ) - as _i5.Future<_i3.Row>); -} - -/// A class which mocks [Account]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAccount extends _i1.Mock implements _i4.Account { - MockAccount() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i5.Future<_i3.User> get() => - (super.noSuchMethod( - Invocation.method(#get, []), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#get, [])), - ), - ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.MfaChallenge>); @override - _i5.Future<_i3.User> create({ - required String? userId, - required String? email, - required String? password, - String? name, + _i5.Future<_i3.MfaChallenge> createMFAChallenge({ + required _i9.AuthenticationFactor? factor, }) => (super.noSuchMethod( - Invocation.method(#create, [], { - #userId: userId, - #email: email, - #password: password, - #name: name, - }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + Invocation.method(#createMFAChallenge, [], {#factor: factor}), + returnValue: _i5.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_6( this, - Invocation.method(#create, [], { - #userId: userId, - #email: email, - #password: password, - #name: name, - }), + Invocation.method(#createMFAChallenge, [], {#factor: factor}), ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.MfaChallenge>); @override - _i5.Future<_i3.User> updateEmail({ - required String? email, - required String? password, + _i5.Future<_i3.Session> updateMfaChallenge({ + required String? challengeId, + required String? otp, }) => (super.noSuchMethod( - Invocation.method(#updateEmail, [], { - #email: email, - #password: password, + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( this, - Invocation.method(#updateEmail, [], { - #email: email, - #password: password, + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, }), ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.Session>); @override - _i5.Future<_i3.IdentityList> listIdentities({ - List? queries, - bool? total, + _i5.Future<_i3.Session> updateMFAChallenge({ + required String? challengeId, + required String? otp, }) => (super.noSuchMethod( - Invocation.method(#listIdentities, [], { - #queries: queries, - #total: total, + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, }), - returnValue: _i5.Future<_i3.IdentityList>.value( - _FakeIdentityList_6( + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( this, - Invocation.method(#listIdentities, [], { - #queries: queries, - #total: total, + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, }), ), ), ) - as _i5.Future<_i3.IdentityList>); + as _i5.Future<_i3.Session>); @override - _i5.Future deleteIdentity({required String? identityId}) => + _i5.Future<_i3.MfaFactors> listMfaFactors() => (super.noSuchMethod( - Invocation.method(#deleteIdentity, [], {#identityId: identityId}), - returnValue: _i5.Future.value(), + Invocation.method(#listMfaFactors, []), + returnValue: _i5.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_8(this, Invocation.method(#listMfaFactors, [])), + ), ) - as _i5.Future); + as _i5.Future<_i3.MfaFactors>); @override - _i5.Future<_i3.Jwt> createJWT() => + _i5.Future<_i3.MfaFactors> listMFAFactors() => (super.noSuchMethod( - Invocation.method(#createJWT, []), - returnValue: _i5.Future<_i3.Jwt>.value( - _FakeJwt_7(this, Invocation.method(#createJWT, [])), + Invocation.method(#listMFAFactors, []), + returnValue: _i5.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_8(this, Invocation.method(#listMFAFactors, [])), ), ) - as _i5.Future<_i3.Jwt>); + as _i5.Future<_i3.MfaFactors>); @override - _i5.Future<_i3.LogList> listLogs({List? queries, bool? total}) => + _i5.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => (super.noSuchMethod( - Invocation.method(#listLogs, [], { - #queries: queries, - #total: total, - }), - returnValue: _i5.Future<_i3.LogList>.value( - _FakeLogList_8( + Invocation.method(#getMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( this, - Invocation.method(#listLogs, [], { - #queries: queries, - #total: total, - }), + Invocation.method(#getMfaRecoveryCodes, []), ), ), ) - as _i5.Future<_i3.LogList>); + as _i5.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.User> updateMFA({required bool? mfa}) => + _i5.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => (super.noSuchMethod( - Invocation.method(#updateMFA, [], {#mfa: mfa}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), + Invocation.method(#getMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#getMFARecoveryCodes, []), + ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaType> createMfaAuthenticator({ - required _i6.AuthenticatorType? type, - }) => + _i5.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => (super.noSuchMethod( - Invocation.method(#createMfaAuthenticator, [], {#type: type}), - returnValue: _i5.Future<_i3.MfaType>.value( - _FakeMfaType_9( + Invocation.method(#createMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( this, - Invocation.method(#createMfaAuthenticator, [], {#type: type}), + Invocation.method(#createMfaRecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaType>); + as _i5.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaType> createMFAAuthenticator({ - required _i6.AuthenticatorType? type, - }) => + _i5.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => (super.noSuchMethod( - Invocation.method(#createMFAAuthenticator, [], {#type: type}), - returnValue: _i5.Future<_i3.MfaType>.value( - _FakeMfaType_9( + Invocation.method(#createMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( this, - Invocation.method(#createMFAAuthenticator, [], {#type: type}), + Invocation.method(#createMFARecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaType>); + as _i5.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.User> updateMfaAuthenticator({ - required _i6.AuthenticatorType? type, - required String? otp, + _i5.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#updateMfaRecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#updateMFARecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.User> updateName({required String? name}) => + (super.noSuchMethod( + Invocation.method(#updateName, [], {#name: name}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updateName, [], {#name: name}), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> updatePassword({ + required String? password, + String? oldPassword, }) => (super.noSuchMethod( - Invocation.method(#updateMfaAuthenticator, [], { - #type: type, - #otp: otp, + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, }), returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + _FakeUser_1( this, - Invocation.method(#updateMfaAuthenticator, [], { - #type: type, - #otp: otp, + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, }), ), ), @@ -691,21 +606,21 @@ class MockAccount extends _i1.Mock implements _i4.Account { as _i5.Future<_i3.User>); @override - _i5.Future<_i3.User> updateMFAAuthenticator({ - required _i6.AuthenticatorType? type, - required String? otp, + _i5.Future<_i3.User> updatePhone({ + required String? phone, + required String? password, }) => (super.noSuchMethod( - Invocation.method(#updateMFAAuthenticator, [], { - #type: type, - #otp: otp, + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, }), returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + _FakeUser_1( this, - Invocation.method(#updateMFAAuthenticator, [], { - #type: type, - #otp: otp, + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, }), ), ), @@ -713,93 +628,119 @@ class MockAccount extends _i1.Mock implements _i4.Account { as _i5.Future<_i3.User>); @override - _i5.Future deleteMfaAuthenticator({ - required _i6.AuthenticatorType? type, - }) => + _i5.Future<_i3.Preferences> getPrefs() => (super.noSuchMethod( - Invocation.method(#deleteMfaAuthenticator, [], {#type: type}), - returnValue: _i5.Future.value(), + Invocation.method(#getPrefs, []), + returnValue: _i5.Future<_i3.Preferences>.value( + _FakePreferences_10(this, Invocation.method(#getPrefs, [])), + ), ) - as _i5.Future); + as _i5.Future<_i3.Preferences>); @override - _i5.Future deleteMFAAuthenticator({ - required _i6.AuthenticatorType? type, - }) => + _i5.Future<_i3.User> updatePrefs({required Map? prefs}) => (super.noSuchMethod( - Invocation.method(#deleteMFAAuthenticator, [], {#type: type}), - returnValue: _i5.Future.value(), + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + ), + ), ) - as _i5.Future); + as _i5.Future<_i3.User>); @override - _i5.Future<_i3.MfaChallenge> createMfaChallenge({ - required _i6.AuthenticationFactor? factor, + _i5.Future<_i3.Token> createRecovery({ + required String? email, + required String? url, }) => (super.noSuchMethod( - Invocation.method(#createMfaChallenge, [], {#factor: factor}), - returnValue: _i5.Future<_i3.MfaChallenge>.value( - _FakeMfaChallenge_10( + Invocation.method(#createRecovery, [], {#email: email, #url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( this, - Invocation.method(#createMfaChallenge, [], {#factor: factor}), + Invocation.method(#createRecovery, [], { + #email: email, + #url: url, + }), ), ), ) - as _i5.Future<_i3.MfaChallenge>); + as _i5.Future<_i3.Token>); @override - _i5.Future<_i3.MfaChallenge> createMFAChallenge({ - required _i6.AuthenticationFactor? factor, + _i5.Future<_i3.Token> updateRecovery({ + required String? userId, + required String? secret, + required String? password, }) => (super.noSuchMethod( - Invocation.method(#createMFAChallenge, [], {#factor: factor}), - returnValue: _i5.Future<_i3.MfaChallenge>.value( - _FakeMfaChallenge_10( + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( this, - Invocation.method(#createMFAChallenge, [], {#factor: factor}), + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), ), ), ) - as _i5.Future<_i3.MfaChallenge>); + as _i5.Future<_i3.Token>); @override - _i5.Future<_i3.Session> updateMfaChallenge({ - required String? challengeId, - required String? otp, - }) => + _i5.Future<_i3.SessionList> listSessions() => (super.noSuchMethod( - Invocation.method(#updateMfaChallenge, [], { - #challengeId: challengeId, - #otp: otp, - }), + Invocation.method(#listSessions, []), + returnValue: _i5.Future<_i3.SessionList>.value( + _FakeSessionList_12(this, Invocation.method(#listSessions, [])), + ), + ) + as _i5.Future<_i3.SessionList>); + + @override + _i5.Future deleteSessions() => + (super.noSuchMethod( + Invocation.method(#deleteSessions, []), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Session> createAnonymousSession() => + (super.noSuchMethod( + Invocation.method(#createAnonymousSession, []), returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + _FakeSession_7( this, - Invocation.method(#updateMfaChallenge, [], { - #challengeId: challengeId, - #otp: otp, - }), + Invocation.method(#createAnonymousSession, []), ), ), ) as _i5.Future<_i3.Session>); @override - _i5.Future<_i3.Session> updateMFAChallenge({ - required String? challengeId, - required String? otp, + _i5.Future<_i3.Session> createEmailPasswordSession({ + required String? email, + required String? password, }) => (super.noSuchMethod( - Invocation.method(#updateMFAChallenge, [], { - #challengeId: challengeId, - #otp: otp, + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, }), returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + _FakeSession_7( this, - Invocation.method(#updateMFAChallenge, [], { - #challengeId: challengeId, - #otp: otp, + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, }), ), ), @@ -807,658 +748,1307 @@ class MockAccount extends _i1.Mock implements _i4.Account { as _i5.Future<_i3.Session>); @override - _i5.Future<_i3.MfaFactors> listMfaFactors() => + _i5.Future<_i3.Session> updateMagicURLSession({ + required String? userId, + required String? secret, + }) => (super.noSuchMethod( - Invocation.method(#listMfaFactors, []), - returnValue: _i5.Future<_i3.MfaFactors>.value( - _FakeMfaFactors_12(this, Invocation.method(#listMfaFactors, [])), + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + ), ), ) - as _i5.Future<_i3.MfaFactors>); + as _i5.Future<_i3.Session>); @override - _i5.Future<_i3.MfaFactors> listMFAFactors() => + _i5.Future createOAuth2Session({ + required _i9.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => (super.noSuchMethod( - Invocation.method(#listMFAFactors, []), - returnValue: _i5.Future<_i3.MfaFactors>.value( - _FakeMfaFactors_12(this, Invocation.method(#listMFAFactors, [])), - ), + Invocation.method(#createOAuth2Session, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i5.Future.value(), ) - as _i5.Future<_i3.MfaFactors>); + as _i5.Future); @override - _i5.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => + _i5.Future<_i3.Session> updatePhoneSession({ + required String? userId, + required String? secret, + }) => (super.noSuchMethod( - Invocation.method(#getMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( this, - Invocation.method(#getMfaRecoveryCodes, []), + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i5.Future<_i3.Session>); @override - _i5.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => + _i5.Future<_i3.Session> createSession({ + required String? userId, + required String? secret, + }) => (super.noSuchMethod( - Invocation.method(#getMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> getSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#getSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#getSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> updateSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future deleteSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#deleteSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.User> updateStatus() => + (super.noSuchMethod( + Invocation.method(#updateStatus, []), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#updateStatus, [])), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.Target> createPushTarget({ + required String? targetId, + required String? identifier, + String? providerId, + }) => + (super.noSuchMethod( + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + returnValue: _i5.Future<_i3.Target>.value( + _FakeTarget_13( + this, + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + ), + ), + ) + as _i5.Future<_i3.Target>); + + @override + _i5.Future<_i3.Target> updatePushTarget({ + required String? targetId, + required String? identifier, + }) => + (super.noSuchMethod( + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + returnValue: _i5.Future<_i3.Target>.value( + _FakeTarget_13( + this, + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + ), + ), + ) + as _i5.Future<_i3.Target>); + + @override + _i5.Future deletePushTarget({required String? targetId}) => + (super.noSuchMethod( + Invocation.method(#deletePushTarget, [], {#targetId: targetId}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Token> createEmailToken({ + required String? userId, + required String? email, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createMagicURLToken({ + required String? userId, + required String? email, + String? url, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future createOAuth2Token({ + required _i9.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => + (super.noSuchMethod( + Invocation.method(#createOAuth2Token, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Token> createPhoneToken({ + required String? userId, + required String? phone, + }) => + (super.noSuchMethod( + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createEmailVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createEmailVerification, [], {#url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createEmailVerification, [], {#url: url}), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createVerification, [], {#url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createVerification, [], {#url: url}), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updateEmailVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updateVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createPhoneVerification() => + (super.noSuchMethod( + Invocation.method(#createPhoneVerification, []), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createPhoneVerification, []), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updatePhoneVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); +} + +/// A class which mocks [TablesDB]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTablesDB extends _i1.Mock implements _i8.TablesDB { + MockTablesDB() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => + (super.noSuchMethod( + Invocation.method(#listTransactions, [], {#queries: queries}), + returnValue: _i5.Future<_i3.TransactionList>.value( + _FakeTransactionList_14( + this, + Invocation.method(#listTransactions, [], {#queries: queries}), + ), + ), + ) + as _i5.Future<_i3.TransactionList>); + + @override + _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => + (super.noSuchMethod( + Invocation.method(#createTransaction, [], {#ttl: ttl}), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#createTransaction, [], {#ttl: ttl}), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.Transaction> getTransaction({ + required String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.Transaction> updateTransaction({ + required String? transactionId, + bool? commit, + bool? rollback, + }) => + (super.noSuchMethod( + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( this, - Invocation.method(#getMFARecoveryCodes, []), + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future deleteTransaction({required String? transactionId}) => + (super.noSuchMethod( + Invocation.method(#deleteTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Transaction> createOperations({ + required String? transactionId, + List>? operations, + }) => + (super.noSuchMethod( + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.RowList> listRows({ + required String? databaseId, + required String? tableId, + List? queries, + String? transactionId, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + returnValue: _i5.Future<_i3.RowList>.value( + _FakeRowList_16( + this, + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.RowList>); + + @override + _i5.Future<_i3.Row> createRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + required Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> getRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + List? queries, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> upsertRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> updateRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future deleteRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #transactionId: transactionId, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Row> decrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? min, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i5.Future<_i3.Row>); @override - _i5.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => + _i5.Future<_i3.Row> incrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? max, + String? transactionId, + }) => (super.noSuchMethod( - Invocation.method(#createMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( this, - Invocation.method(#createMfaRecoveryCodes, []), + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i5.Future<_i3.Row>); +} + +/// A class which mocks [Realtime]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRealtime extends _i1.Mock implements _i10.Realtime { + MockRealtime() { + _i1.throwOnMissingStub(this); + } @override - _i5.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => + _i2.Client get client => (super.noSuchMethod( - Invocation.method(#createMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( - this, - Invocation.method(#createMFARecoveryCodes, []), - ), - ), + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i2.Client); @override - _i5.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => + _i4.RealtimeSubscription subscribe(List? channels) => (super.noSuchMethod( - Invocation.method(#updateMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( - this, - Invocation.method(#updateMfaRecoveryCodes, []), - ), + Invocation.method(#subscribe, [channels]), + returnValue: _FakeRealtimeSubscription_18( + this, + Invocation.method(#subscribe, [channels]), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i4.RealtimeSubscription); +} + +/// A class which mocks [Functions]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFunctions extends _i1.Mock implements _i8.Functions { + MockFunctions() { + _i1.throwOnMissingStub(this); + } @override - _i5.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => + _i2.Client get client => (super.noSuchMethod( - Invocation.method(#updateMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( - this, - Invocation.method(#updateMFARecoveryCodes, []), - ), - ), + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i2.Client); @override - _i5.Future<_i3.User> updateName({required String? name}) => + _i5.Future<_i3.ExecutionList> listExecutions({ + required String? functionId, + List? queries, + bool? total, + }) => (super.noSuchMethod( - Invocation.method(#updateName, [], {#name: name}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), + returnValue: _i5.Future<_i3.ExecutionList>.value( + _FakeExecutionList_19( this, - Invocation.method(#updateName, [], {#name: name}), + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.ExecutionList>); @override - _i5.Future<_i3.User> updatePassword({ - required String? password, - String? oldPassword, + _i5.Future<_i3.Execution> createExecution({ + required String? functionId, + String? body, + bool? xasync, + String? path, + _i9.ExecutionMethod? method, + Map? headers, + String? scheduledAt, }) => (super.noSuchMethod( - Invocation.method(#updatePassword, [], { - #password: password, - #oldPassword: oldPassword, + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i5.Future<_i3.Execution>.value( + _FakeExecution_20( this, - Invocation.method(#updatePassword, [], { - #password: password, - #oldPassword: oldPassword, + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, }), ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.Execution>); @override - _i5.Future<_i3.User> updatePhone({ - required String? phone, - required String? password, + _i5.Future<_i3.Execution> getExecution({ + required String? functionId, + required String? executionId, }) => (super.noSuchMethod( - Invocation.method(#updatePhone, [], { - #phone: phone, - #password: password, + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i5.Future<_i3.Execution>.value( + _FakeExecution_20( this, - Invocation.method(#updatePhone, [], { - #phone: phone, - #password: password, + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, }), ), ), ) - as _i5.Future<_i3.User>); + as _i5.Future<_i3.Execution>); +} + +/// A class which mocks [RealtimeSubscription]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRealtimeSubscription extends _i1.Mock + implements _i4.RealtimeSubscription { + MockRealtimeSubscription() { + _i1.throwOnMissingStub(this); + } @override - _i5.Future<_i3.Preferences> getPrefs() => + _i5.Stream<_i11.RealtimeMessage> get stream => (super.noSuchMethod( - Invocation.method(#getPrefs, []), - returnValue: _i5.Future<_i3.Preferences>.value( - _FakePreferences_14(this, Invocation.method(#getPrefs, [])), - ), + Invocation.getter(#stream), + returnValue: _i5.Stream<_i11.RealtimeMessage>.empty(), ) - as _i5.Future<_i3.Preferences>); + as _i5.Stream<_i11.RealtimeMessage>); @override - _i5.Future<_i3.User> updatePrefs({required Map? prefs}) => + _i5.StreamController<_i11.RealtimeMessage> get controller => (super.noSuchMethod( - Invocation.method(#updatePrefs, [], {#prefs: prefs}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( - this, - Invocation.method(#updatePrefs, [], {#prefs: prefs}), - ), + Invocation.getter(#controller), + returnValue: _FakeStreamController_21<_i11.RealtimeMessage>( + this, + Invocation.getter(#controller), ), ) - as _i5.Future<_i3.User>); + as _i5.StreamController<_i11.RealtimeMessage>); @override - _i5.Future<_i3.Token> createRecovery({ - required String? email, - required String? url, - }) => + List get channels => + (super.noSuchMethod(Invocation.getter(#channels), returnValue: []) + as List); + + @override + _i5.Future Function() get close => (super.noSuchMethod( - Invocation.method(#createRecovery, [], {#email: email, #url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createRecovery, [], { - #email: email, - #url: url, - }), - ), - ), + Invocation.getter(#close), + returnValue: () => _i5.Future.value(), ) - as _i5.Future<_i3.Token>); + as _i5.Future Function()); @override - _i5.Future<_i3.Token> updateRecovery({ - required String? userId, - required String? secret, - required String? password, - }) => + set channels(List? value) => super.noSuchMethod( + Invocation.setter(#channels, value), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [FirebaseMessaging]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { + MockFirebaseMessaging() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.FirebaseApp get app => (super.noSuchMethod( - Invocation.method(#updateRecovery, [], { - #userId: userId, - #secret: secret, - #password: password, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#updateRecovery, [], { - #userId: userId, - #secret: secret, - #password: password, - }), - ), - ), + Invocation.getter(#app), + returnValue: _FakeFirebaseApp_22(this, Invocation.getter(#app)), ) - as _i5.Future<_i3.Token>); + as _i6.FirebaseApp); @override - _i5.Future<_i3.SessionList> listSessions() => + bool get isAutoInitEnabled => (super.noSuchMethod( - Invocation.method(#listSessions, []), - returnValue: _i5.Future<_i3.SessionList>.value( - _FakeSessionList_16(this, Invocation.method(#listSessions, [])), - ), + Invocation.getter(#isAutoInitEnabled), + returnValue: false, ) - as _i5.Future<_i3.SessionList>); + as bool); @override - _i5.Future deleteSessions() => + _i5.Stream get onTokenRefresh => (super.noSuchMethod( - Invocation.method(#deleteSessions, []), - returnValue: _i5.Future.value(), + Invocation.getter(#onTokenRefresh), + returnValue: _i5.Stream.empty(), ) - as _i5.Future); + as _i5.Stream); @override - _i5.Future<_i3.Session> createAnonymousSession() => + set app(_i6.FirebaseApp? value) => super.noSuchMethod( + Invocation.setter(#app, value), + returnValueForMissingStub: null, + ); + + @override + Map get pluginConstants => (super.noSuchMethod( - Invocation.method(#createAnonymousSession, []), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( - this, - Invocation.method(#createAnonymousSession, []), - ), - ), + Invocation.getter(#pluginConstants), + returnValue: {}, ) - as _i5.Future<_i3.Session>); + as Map); @override - _i5.Future<_i3.Session> createEmailPasswordSession({ - required String? email, - required String? password, - }) => + _i5.Future<_i7.RemoteMessage?> getInitialMessage() => (super.noSuchMethod( - Invocation.method(#createEmailPasswordSession, [], { - #email: email, - #password: password, - }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( - this, - Invocation.method(#createEmailPasswordSession, [], { - #email: email, - #password: password, - }), - ), - ), + Invocation.method(#getInitialMessage, []), + returnValue: _i5.Future<_i7.RemoteMessage?>.value(), ) - as _i5.Future<_i3.Session>); + as _i5.Future<_i7.RemoteMessage?>); @override - _i5.Future<_i3.Session> updateMagicURLSession({ - required String? userId, - required String? secret, - }) => + _i5.Future deleteToken() => (super.noSuchMethod( - Invocation.method(#updateMagicURLSession, [], { - #userId: userId, - #secret: secret, - }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( - this, - Invocation.method(#updateMagicURLSession, [], { - #userId: userId, - #secret: secret, - }), - ), - ), + Invocation.method(#deleteToken, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future<_i3.Session>); + as _i5.Future); @override - _i5.Future createOAuth2Session({ - required _i6.OAuthProvider? provider, - String? success, - String? failure, - List? scopes, - }) => + _i5.Future getAPNSToken() => (super.noSuchMethod( - Invocation.method(#createOAuth2Session, [], { - #provider: provider, - #success: success, - #failure: failure, - #scopes: scopes, - }), - returnValue: _i5.Future.value(), + Invocation.method(#getAPNSToken, []), + returnValue: _i5.Future.value(), ) - as _i5.Future); + as _i5.Future); @override - _i5.Future<_i3.Session> updatePhoneSession({ - required String? userId, - required String? secret, - }) => + _i5.Future getToken({String? vapidKey}) => + (super.noSuchMethod( + Invocation.method(#getToken, [], {#vapidKey: vapidKey}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future isSupported() => + (super.noSuchMethod( + Invocation.method(#isSupported, []), + returnValue: _i5.Future.value(false), + ) + as _i5.Future); + + @override + _i5.Future<_i7.NotificationSettings> getNotificationSettings() => (super.noSuchMethod( - Invocation.method(#updatePhoneSession, [], { - #userId: userId, - #secret: secret, - }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + Invocation.method(#getNotificationSettings, []), + returnValue: _i5.Future<_i7.NotificationSettings>.value( + _FakeNotificationSettings_23( this, - Invocation.method(#updatePhoneSession, [], { - #userId: userId, - #secret: secret, - }), + Invocation.method(#getNotificationSettings, []), ), ), ) - as _i5.Future<_i3.Session>); + as _i5.Future<_i7.NotificationSettings>); @override - _i5.Future<_i3.Session> createSession({ - required String? userId, - required String? secret, + _i5.Future<_i7.NotificationSettings> requestPermission({ + bool? alert = true, + bool? announcement = false, + bool? badge = true, + bool? carPlay = false, + bool? criticalAlert = false, + bool? provisional = false, + bool? sound = true, + bool? providesAppNotificationSettings = false, }) => (super.noSuchMethod( - Invocation.method(#createSession, [], { - #userId: userId, - #secret: secret, + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: providesAppNotificationSettings, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i5.Future<_i7.NotificationSettings>.value( + _FakeNotificationSettings_23( this, - Invocation.method(#createSession, [], { - #userId: userId, - #secret: secret, + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: + providesAppNotificationSettings, }), ), ), ) - as _i5.Future<_i3.Session>); + as _i5.Future<_i7.NotificationSettings>); @override - _i5.Future<_i3.Session> getSession({required String? sessionId}) => + _i5.Future setAutoInitEnabled(bool? enabled) => (super.noSuchMethod( - Invocation.method(#getSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( - this, - Invocation.method(#getSession, [], {#sessionId: sessionId}), - ), - ), + Invocation.method(#setAutoInitEnabled, [enabled]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future<_i3.Session>); + as _i5.Future); @override - _i5.Future<_i3.Session> updateSession({required String? sessionId}) => + _i5.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => (super.noSuchMethod( - Invocation.method(#updateSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( - this, - Invocation.method(#updateSession, [], {#sessionId: sessionId}), - ), + Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setForegroundNotificationPresentationOptions({ + bool? alert = false, + bool? badge = false, + bool? sound = false, + }) => + (super.noSuchMethod( + Invocation.method( + #setForegroundNotificationPresentationOptions, + [], + {#alert: alert, #badge: badge, #sound: sound}, ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future<_i3.Session>); + as _i5.Future); @override - _i5.Future deleteSession({required String? sessionId}) => + _i5.Future subscribeToTopic(String? topic) => (super.noSuchMethod( - Invocation.method(#deleteSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future.value(), + Invocation.method(#subscribeToTopic, [topic]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future); + as _i5.Future); @override - _i5.Future<_i3.User> updateStatus() => + _i5.Future unsubscribeFromTopic(String? topic) => (super.noSuchMethod( - Invocation.method(#updateStatus, []), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#updateStatus, [])), - ), + Invocation.method(#unsubscribeFromTopic, [topic]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future<_i3.User>); + as _i5.Future); +} + +/// A class which mocks [Execution]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockExecution extends _i1.Mock implements _i3.Execution { + MockExecution() { + _i1.throwOnMissingStub(this); + } @override - _i5.Future<_i3.Target> createPushTarget({ - required String? targetId, - required String? identifier, - String? providerId, - }) => + String get $id => (super.noSuchMethod( - Invocation.method(#createPushTarget, [], { - #targetId: targetId, - #identifier: identifier, - #providerId: providerId, - }), - returnValue: _i5.Future<_i3.Target>.value( - _FakeTarget_17( - this, - Invocation.method(#createPushTarget, [], { - #targetId: targetId, - #identifier: identifier, - #providerId: providerId, - }), - ), + Invocation.getter(#$id), + returnValue: _i13.dummyValue(this, Invocation.getter(#$id)), + ) + as String); + + @override + String get $createdAt => + (super.noSuchMethod( + Invocation.getter(#$createdAt), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#$createdAt), ), ) - as _i5.Future<_i3.Target>); + as String); @override - _i5.Future<_i3.Target> updatePushTarget({ - required String? targetId, - required String? identifier, - }) => + String get $updatedAt => (super.noSuchMethod( - Invocation.method(#updatePushTarget, [], { - #targetId: targetId, - #identifier: identifier, - }), - returnValue: _i5.Future<_i3.Target>.value( - _FakeTarget_17( - this, - Invocation.method(#updatePushTarget, [], { - #targetId: targetId, - #identifier: identifier, - }), - ), + Invocation.getter(#$updatedAt), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#$updatedAt), ), ) - as _i5.Future<_i3.Target>); + as String); @override - _i5.Future deletePushTarget({required String? targetId}) => + List get $permissions => (super.noSuchMethod( - Invocation.method(#deletePushTarget, [], {#targetId: targetId}), - returnValue: _i5.Future.value(), + Invocation.getter(#$permissions), + returnValue: [], ) - as _i5.Future); + as List); @override - _i5.Future<_i3.Token> createEmailToken({ - required String? userId, - required String? email, - bool? phrase, - }) => + String get functionId => (super.noSuchMethod( - Invocation.method(#createEmailToken, [], { - #userId: userId, - #email: email, - #phrase: phrase, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createEmailToken, [], { - #userId: userId, - #email: email, - #phrase: phrase, - }), - ), + Invocation.getter(#functionId), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#functionId), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future<_i3.Token> createMagicURLToken({ - required String? userId, - required String? email, - String? url, - bool? phrase, - }) => + String get deploymentId => (super.noSuchMethod( - Invocation.method(#createMagicURLToken, [], { - #userId: userId, - #email: email, - #url: url, - #phrase: phrase, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createMagicURLToken, [], { - #userId: userId, - #email: email, - #url: url, - #phrase: phrase, - }), - ), + Invocation.getter(#deploymentId), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#deploymentId), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future createOAuth2Token({ - required _i6.OAuthProvider? provider, - String? success, - String? failure, - List? scopes, - }) => + _i9.ExecutionTrigger get trigger => (super.noSuchMethod( - Invocation.method(#createOAuth2Token, [], { - #provider: provider, - #success: success, - #failure: failure, - #scopes: scopes, - }), - returnValue: _i5.Future.value(), + Invocation.getter(#trigger), + returnValue: _i9.ExecutionTrigger.http, ) - as _i5.Future); + as _i9.ExecutionTrigger); @override - _i5.Future<_i3.Token> createPhoneToken({ - required String? userId, - required String? phone, - }) => + _i9.ExecutionStatus get status => (super.noSuchMethod( - Invocation.method(#createPhoneToken, [], { - #userId: userId, - #phone: phone, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createPhoneToken, [], { - #userId: userId, - #phone: phone, - }), - ), - ), + Invocation.getter(#status), + returnValue: _i9.ExecutionStatus.waiting, ) - as _i5.Future<_i3.Token>); + as _i9.ExecutionStatus); @override - _i5.Future<_i3.Token> createEmailVerification({required String? url}) => + String get requestMethod => (super.noSuchMethod( - Invocation.method(#createEmailVerification, [], {#url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createEmailVerification, [], {#url: url}), - ), + Invocation.getter(#requestMethod), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#requestMethod), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future<_i3.Token> createVerification({required String? url}) => + String get requestPath => (super.noSuchMethod( - Invocation.method(#createVerification, [], {#url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createVerification, [], {#url: url}), - ), + Invocation.getter(#requestPath), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#requestPath), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future<_i3.Token> updateEmailVerification({ - required String? userId, - required String? secret, - }) => + List<_i3.Headers> get requestHeaders => (super.noSuchMethod( - Invocation.method(#updateEmailVerification, [], { - #userId: userId, - #secret: secret, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#updateEmailVerification, [], { - #userId: userId, - #secret: secret, - }), - ), - ), + Invocation.getter(#requestHeaders), + returnValue: <_i3.Headers>[], ) - as _i5.Future<_i3.Token>); + as List<_i3.Headers>); @override - _i5.Future<_i3.Token> updateVerification({ - required String? userId, - required String? secret, - }) => + int get responseStatusCode => (super.noSuchMethod( - Invocation.method(#updateVerification, [], { - #userId: userId, - #secret: secret, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#updateVerification, [], { - #userId: userId, - #secret: secret, - }), - ), + Invocation.getter(#responseStatusCode), + returnValue: 0, + ) + as int); + + @override + String get responseBody => + (super.noSuchMethod( + Invocation.getter(#responseBody), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#responseBody), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future<_i3.Token> createPhoneVerification() => + List<_i3.Headers> get responseHeaders => (super.noSuchMethod( - Invocation.method(#createPhoneVerification, []), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#createPhoneVerification, []), - ), + Invocation.getter(#responseHeaders), + returnValue: <_i3.Headers>[], + ) + as List<_i3.Headers>); + + @override + String get logs => + (super.noSuchMethod( + Invocation.getter(#logs), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#logs), ), ) - as _i5.Future<_i3.Token>); + as String); @override - _i5.Future<_i3.Token> updatePhoneVerification({ - required String? userId, - required String? secret, - }) => + String get errors => (super.noSuchMethod( - Invocation.method(#updatePhoneVerification, [], { - #userId: userId, - #secret: secret, - }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( - this, - Invocation.method(#updatePhoneVerification, [], { - #userId: userId, - #secret: secret, - }), - ), + Invocation.getter(#errors), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#errors), ), ) - as _i5.Future<_i3.Token>); + as String); + + @override + double get duration => + (super.noSuchMethod(Invocation.getter(#duration), returnValue: 0.0) + as double); + + @override + Map toMap() => + (super.noSuchMethod( + Invocation.method(#toMap, []), + returnValue: {}, + ) + as Map); } From b00d39bf4b6505a29e3d052c2b2d969ae09ba099 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:36:27 +0530 Subject: [PATCH 03/12] feat: Refactor Profile feature to Riverpod and MVVM --- lib/controllers/change_email_controller.dart | 196 --- .../delete_account_controller.dart | 66 -- lib/controllers/edit_profile_controller.dart | 360 ------ lib/controllers/friends_controller.dart | 10 +- lib/controllers/live_chapter_controller.dart | 18 +- lib/controllers/onboarding_controller.dart | 203 ---- lib/controllers/pair_chat_controller.dart | 6 +- lib/controllers/user_profile_controller.dart | 178 --- lib/core/container.dart | 2 +- lib/core/legacy_dependencies.dart | 2 - lib/features/auth/view/string_validators.dart | 11 + .../data/generated/profile_repository.g.dart | 57 + .../profile/data/profile_repository.dart | 392 ++++++ .../profile/model/change_email_state.dart | 27 + .../profile/model/edit_profile_state.dart | 34 + .../profile/model/onboarding_state.dart | 51 + .../profile/model/profile_view_data.dart | 37 + lib/features/profile/profile_routes.dart | 30 + .../profile/view/pages/change_email_page.dart | 177 +++ .../view/pages/delete_account_page.dart | 104 ++ .../profile/view/pages/edit_profile_page.dart | 455 +++++++ .../profile/view/pages/onboarding_page.dart | 284 +++++ .../profile/view/pages/profile_page.dart | 608 ++++++++++ .../viewmodel/change_email_notifier.dart | 56 + .../viewmodel/delete_account_notifier.dart | 12 + .../viewmodel/edit_profile_notifier.dart | 121 ++ .../generated/change_email_notifier.g.dart | 62 + .../generated/delete_account_notifier.g.dart | 62 + .../generated/edit_profile_notifier.g.dart | 62 + .../generated/onboarding_notifier.g.dart | 62 + .../generated/profile_view_notifier.g.dart | 99 ++ .../viewmodel/onboarding_notifier.dart | 81 ++ .../viewmodel/profile_view_notifier.dart | 92 ++ lib/routes/app_router.dart | 31 +- lib/views/screens/change_email_screen.dart | 130 -- lib/views/screens/delete_account_screen.dart | 109 -- lib/views/screens/edit_profile_screen.dart | 414 ------- lib/views/screens/onboarding_screen.dart | 216 ---- lib/views/screens/profile_screen.dart | 653 ---------- lib/views/widgets/filtered_list_tile.dart | 4 +- .../widgets/friend_request_list_tile.dart | 4 +- lib/views/widgets/profile_avatar.dart | 2 +- .../change_email_controller_test.dart | 195 --- .../edit_profile_controller_test.dart | 147 --- .../edit_profile_controller_test.mocks.dart | 882 -------------- .../user_profile_controller_test.dart | 320 ----- .../user_profile_controller_test.mocks.dart | 637 ---------- .../profile/data/profile_repository_test.dart | 580 +++++++++ .../data/profile_repository_test.mocks.dart} | 1049 ++++++++++++----- .../profile/fake_profile_repository.dart | 197 ++++ .../profile/view/change_email_page_test.dart | 67 ++ .../view/delete_account_page_test.dart | 38 + .../profile/view/profile_test_helpers.dart | 72 ++ .../viewmodel/change_email_notifier_test.dart | 97 ++ .../delete_account_notifier_test.dart | 25 + .../viewmodel/edit_profile_notifier_test.dart | 130 ++ .../viewmodel/onboarding_notifier_test.dart | 94 ++ .../viewmodel/profile_view_notifier_test.dart | 151 +++ test/helpers/test_root_container.dart | 2 +- 59 files changed, 5195 insertions(+), 5068 deletions(-) delete mode 100644 lib/controllers/change_email_controller.dart delete mode 100644 lib/controllers/delete_account_controller.dart delete mode 100644 lib/controllers/edit_profile_controller.dart delete mode 100644 lib/controllers/onboarding_controller.dart delete mode 100644 lib/controllers/user_profile_controller.dart create mode 100644 lib/features/profile/data/generated/profile_repository.g.dart create mode 100644 lib/features/profile/data/profile_repository.dart create mode 100644 lib/features/profile/model/change_email_state.dart create mode 100644 lib/features/profile/model/edit_profile_state.dart create mode 100644 lib/features/profile/model/onboarding_state.dart create mode 100644 lib/features/profile/model/profile_view_data.dart create mode 100644 lib/features/profile/profile_routes.dart create mode 100644 lib/features/profile/view/pages/change_email_page.dart create mode 100644 lib/features/profile/view/pages/delete_account_page.dart create mode 100644 lib/features/profile/view/pages/edit_profile_page.dart create mode 100644 lib/features/profile/view/pages/onboarding_page.dart create mode 100644 lib/features/profile/view/pages/profile_page.dart create mode 100644 lib/features/profile/viewmodel/change_email_notifier.dart create mode 100644 lib/features/profile/viewmodel/delete_account_notifier.dart create mode 100644 lib/features/profile/viewmodel/edit_profile_notifier.dart create mode 100644 lib/features/profile/viewmodel/generated/change_email_notifier.g.dart create mode 100644 lib/features/profile/viewmodel/generated/delete_account_notifier.g.dart create mode 100644 lib/features/profile/viewmodel/generated/edit_profile_notifier.g.dart create mode 100644 lib/features/profile/viewmodel/generated/onboarding_notifier.g.dart create mode 100644 lib/features/profile/viewmodel/generated/profile_view_notifier.g.dart create mode 100644 lib/features/profile/viewmodel/onboarding_notifier.dart create mode 100644 lib/features/profile/viewmodel/profile_view_notifier.dart delete mode 100644 lib/views/screens/change_email_screen.dart delete mode 100644 lib/views/screens/delete_account_screen.dart delete mode 100644 lib/views/screens/edit_profile_screen.dart delete mode 100644 lib/views/screens/onboarding_screen.dart delete mode 100644 lib/views/screens/profile_screen.dart delete mode 100644 test/controllers/change_email_controller_test.dart delete mode 100644 test/controllers/edit_profile_controller_test.dart delete mode 100644 test/controllers/edit_profile_controller_test.mocks.dart delete mode 100644 test/controllers/user_profile_controller_test.dart delete mode 100644 test/controllers/user_profile_controller_test.mocks.dart create mode 100644 test/features/profile/data/profile_repository_test.dart rename test/{controllers/change_email_controller_test.mocks.dart => features/profile/data/profile_repository_test.mocks.dart} (52%) create mode 100644 test/features/profile/fake_profile_repository.dart create mode 100644 test/features/profile/view/change_email_page_test.dart create mode 100644 test/features/profile/view/delete_account_page_test.dart create mode 100644 test/features/profile/view/profile_test_helpers.dart create mode 100644 test/features/profile/viewmodel/change_email_notifier_test.dart create mode 100644 test/features/profile/viewmodel/delete_account_notifier_test.dart create mode 100644 test/features/profile/viewmodel/edit_profile_notifier_test.dart create mode 100644 test/features/profile/viewmodel/onboarding_notifier_test.dart create mode 100644 test/features/profile/viewmodel/profile_view_notifier_test.dart diff --git a/lib/controllers/change_email_controller.dart b/lib/controllers/change_email_controller.dart deleted file mode 100644 index 54bc16f6..00000000 --- a/lib/controllers/change_email_controller.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/semantics.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/services/appwrite_service.dart'; - -import '../utils/constants.dart'; -import '../utils/enums/log_type.dart'; -import '../views/widgets/snackbar.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class ChangeEmailController extends GetxController { - AuthUser get authStateController => requireCurrentAuthUser; - - final emailController = TextEditingController(); - final passwordController = TextEditingController(); - ChangeEmailController({TablesDB? tables, Account? account}) - : tables = tables ?? AppwriteService.getTables(), - account = account ?? AppwriteService.getAccount(); - - RxBool isPasswordFieldVisible = false.obs; - RxBool isLoading = false.obs; - - final changeEmailFormKey = GlobalKey(); - - late final TablesDB tables; - late final Account account; - - Future isEmailAvailable(String changedEmail) async { - final docs = await tables.listRows( - databaseId: userDatabaseID, - tableId: usernameTableID, - queries: [ - Query.equal('email', changedEmail), - ], - ); - - if (docs.total > 0) { - return false; - } else { - return true; - } - } - - Future changeEmailInDatabases( - String changedEmail, - BuildContext context, - ) async { - try { - // change in user info collection - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: {'email': changedEmail}, - ); - - // change in username - email collection - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: authStateController.userName!, - data: {'email': changedEmail}, - ); - - // Set user profile in authStateController - await rootContainer.read(authProvider.notifier).refresh(); - - return true; - } on AppwriteException catch (e) { - log(e.toString()); - customSnackbar( - AppLocalizations.of(context)!.tryAgain, - e.toString(), - LogType.error, - ); - - SemanticsService.announce(e.toString(), TextDirection.ltr); - - return false; - } catch (e) { - log(e.toString()); - return false; - } - } - - Future changeEmailInAuth( - String changedEmail, - String password, - BuildContext context, - ) async { - try { - // change in auth section - await account.updateEmail(email: changedEmail, password: password); - - return true; - } on AppwriteException catch (e) { - log(e.toString()); - if (e.type == userInvalidCredentials) { - customSnackbar( - AppLocalizations.of(context)!.tryAgain, - AppLocalizations.of(context)!.incorrectEmailOrPassword, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(context)!.incorrectEmailOrPassword, - TextDirection.ltr, - ); - } else if (e.type == generalArgumentInvalid) { - customSnackbar( - AppLocalizations.of(context)!.tryAgain, - AppLocalizations.of(context)!.passwordShort, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(context)!.passwordShort, - TextDirection.ltr, - ); - } - - return false; - } catch (e) { - log(e.toString()); - return false; - } - } - - Future changeEmail(BuildContext context) async { - if (changeEmailFormKey.currentState!.validate()) { - isLoading.value = true; - - isEmailAvailable(emailController.text).then((status) { - if (status) { - changeEmailInAuth( - emailController.text, - passwordController.text, - context, - ).then((status) { - if (status) { - changeEmailInDatabases(emailController.text, context).then(( - status, - ) { - isLoading.value = false; - - if (status) { - customSnackbar( - AppLocalizations.of(context)!.emailChanged, - AppLocalizations.of(context)!.emailChangeSuccess, - LogType.success, - ); - - SemanticsService.announce( - AppLocalizations.of(context)!.emailChangeSuccess, - TextDirection.ltr, - ); - } else { - customSnackbar( - AppLocalizations.of(context)!.failed, - AppLocalizations.of(context)!.emailChangeFailed, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(context)!.emailChangeFailed, - TextDirection.ltr, - ); - } - }); - } else { - isLoading.value = false; - } - }); - } else { - customSnackbar( - AppLocalizations.of(context)!.oops, - AppLocalizations.of(context)!.emailExists, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(context)!.emailExists, - TextDirection.ltr, - ); - isLoading.value = false; - } - }); - } - } -} diff --git a/lib/controllers/delete_account_controller.dart b/lib/controllers/delete_account_controller.dart deleted file mode 100644 index fe4dd8de..00000000 --- a/lib/controllers/delete_account_controller.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; - -class DeleteAccountController extends GetxController { - RxBool isButtonActive = false.obs; - - AuthUser get authStateController => requireCurrentAuthUser; - - late final Storage storage; - late final TablesDB tables; - - // - //------------------------------------------------------------------- - // PLEASE DO NOT TOUCH THIS CODE WITHOUT PERMISSION - - //------------------------------------------------------------------- - // - - @override - void onInit() { - super.onInit(); - - storage = AppwriteService.getStorage(); - tables = AppwriteService.getTables(); - } - - Future deleteUserProfilePicture() async { - try { - await storage.deleteFile( - bucketId: userProfileImageBucketId, - fileId: authStateController.profileImageID!, - ); - } catch (e) { - log(e.toString()); - } - } - - Future deleteUsernamesCollectionDocument() async { - try { - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: authStateController.userName!, - ); - } catch (e) { - log(e.toString()); - } - } - - Future deleteUsersCollectionDocument() async { - try { - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - ); - } catch (e) { - log(e.toString()); - } - } -} diff --git a/lib/controllers/edit_profile_controller.dart b/lib/controllers/edit_profile_controller.dart deleted file mode 100644 index 033c947b..00000000 --- a/lib/controllers/edit_profile_controller.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:developer'; -import 'package:flutter/semantics.dart'; -import 'package:image_cropper/image_cropper.dart'; -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -import 'package:resonate/core/container.dart'; -import 'package:resonate/core/providers/appwrite_providers.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import '../utils/constants.dart'; - -class EditProfileController extends GetxController { - String? profileImagePath; - - final ImagePicker _imagePicker = ImagePicker(); - - AuthUser get authStateController => requireCurrentAuthUser; - - final ThemeController themeController; - late final Storage storage; - late final TablesDB tables; - - RxBool isLoading = false.obs; - Rx usernameAvailable = false.obs; - Rx usernameChecking = false.obs; - bool removeImage = false; - bool showSuccessSnackbar = false; - - late String oldUsername; - late String oldDisplayName; - late String uniqueIdForProfileImage; - - TextEditingController imageController = TextEditingController(); - TextEditingController nameController = TextEditingController(); - TextEditingController usernameController = TextEditingController(); - - final GlobalKey editProfileFormKey = GlobalKey(); - - EditProfileController({ - ThemeController? themeController, - Storage? storage, - TablesDB? tables, - }) : themeController = themeController ?? Get.find(), - storage = storage ?? AppwriteService.getStorage(), - tables = tables ?? AppwriteService.getTables(); - - @override - void onInit() { - super.onInit(); - oldDisplayName = authStateController.displayName!.trim(); - oldUsername = authStateController.userName!.trim(); - // Initialize as true since user starts with their own username - - usernameAvailable.value = true; - - nameController.text = authStateController.displayName!.trim(); - usernameController.text = authStateController.userName!.trim(); - log(profileImagePath.toString()); - } - - bool isThereUnsavedChanges() { - if (isProfilePictureChanged() || - isUsernameChanged() || - isDisplayNameChanged()) { - return true; - } - return false; - } - - Future pickImageFromCamera() async { - try { - XFile? file = await _imagePicker.pickImage( - source: ImageSource.camera, - // maxHeight: 400, - // maxWidth: 400, - ); - - if (file == null) return; - - // Crop the image - final croppedFile = await _cropImage(file.path); - - if (croppedFile != null) { - profileImagePath = croppedFile.path; - update(); - removeImage = false; - } - } catch (e) { - log(e.toString()); - } finally { - // Close the loading dialog - Get.back(); - } - } - - void removeProfilePicture() { - if (authStateController.profileImageUrl != null) { - removeImage = true; - } - log(authStateController.profileImageUrl.toString()); - profileImagePath = null; - update(); - } - - Future pickImageFromGallery() async { - try { - XFile? file = await _imagePicker.pickImage( - source: ImageSource.gallery, - // maxHeight: 400, - // maxWidth: 400, - ); - - if (file == null) return; - - // Crop the image - final croppedFile = await _cropImage(file.path); - - if (croppedFile != null) { - profileImagePath = croppedFile.path; - update(); - removeImage = false; - } - } catch (e) { - log(e.toString()); - } finally { - // Close the loading dialog - Get.back(); - } - } - - Future _cropImage(String imagePath) async { - CroppedFile? croppedFile = await ImageCropper().cropImage( - sourcePath: imagePath, - aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), - uiSettings: [ - AndroidUiSettings( - toolbarTitle: AppLocalizations.of(Get.context!)!.cropImage, - // toolbarColor: themeController.primaryColor.value, - // statusBarColor: themeController.primaryColor.value, - // toolbarWidgetColor: Colors.black, - // cropFrameColor: Colors.white, - // activeControlsWidgetColor: themeController.primaryColor.value, - ), - IOSUiSettings( - minimumAspectRatio: 1.0, - title: AppLocalizations.of(Get.context!)!.cropImage, - ), - ], - ); - - return croppedFile; - } - - Future isUsernameAvailable(String username) async { - // condition to check if the username is same as the current username - if (username.trim() == oldUsername) { - return true; - } - try { - await tables.getRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: username, - ); - return false; - } catch (e) { - log(e.toString()); - return true; - } - } - - bool isUsernameChanged() { - if (usernameController.text.trim() == oldUsername) { - return false; - } - return true; - } - - bool isDisplayNameChanged() { - if (nameController.text.trim() == oldDisplayName) { - return false; - } - return true; - } - - bool isProfilePictureChanged() { - if ((profileImagePath != null) || removeImage) { - return true; - } - return false; - } - - Future saveProfile() async { - if (!editProfileFormKey.currentState!.validate()) { - return; - } - - if (isProfilePictureChanged() || - isUsernameChanged() || - isDisplayNameChanged()) { - showSuccessSnackbar = true; - } - - try { - isLoading.value = true; - - // Update user PROFILE PICTURE - if (profileImagePath != null) { - try { - await storage.deleteFile( - bucketId: userProfileImageBucketId, - fileId: authStateController.profileImageID!, - ); - } catch (e) { - log(e.toString()); - } - - uniqueIdForProfileImage = - authStateController.uid! + - DateTime.now().millisecondsSinceEpoch.toString(); - - // Create new user profile picture file in Storage - final profileImage = await storage.createFile( - bucketId: userProfileImageBucketId, - fileId: uniqueIdForProfileImage, - file: InputFile.fromPath( - path: profileImagePath!, - filename: "${authStateController.email}.jpeg", - ), - ); - - imageController.text = - "$appwriteEndpoint/storage/buckets/$userProfileImageBucketId/files/${profileImage.$id}/view?project=$appwriteProjectId"; - - // Update user profile picture URL in Database - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: { - "profileImageUrl": imageController.text, - "profileImageID": uniqueIdForProfileImage, - }, - ); - } - - if (removeImage) { - imageController.text = ""; - - // Update user profile picture URL in Database - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: {"profileImageUrl": imageController.text}, - ); - } - - // Update USERNAME - if (isUsernameChanged()) { - // Create new doc of New Username - try { - await tables.createRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: usernameController.text.trim(), - data: {'email': authStateController.email}, - ); - - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: oldUsername, - ); - - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: {"username": usernameController.text.trim()}, - ); - } catch (e) { - log(e.toString()); - showSuccessSnackbar = false; - rethrow; - } - } - - //Update user DISPLAY-NAME - if (isDisplayNameChanged()) { - // Update user DISPLAY-NAME and USERNAME - await rootContainer.read(appwriteAccountProvider).updateName( - name: nameController.text.trim(), - ); - - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: {"name": nameController.text.trim()}, - ); - } - - // Set user profile in authStateController - await rootContainer.read(authProvider.notifier).refresh(); - - // Change all old values with new values - oldDisplayName = authStateController.displayName!; - oldUsername = authStateController.userName!; - - removeImage = false; - profileImagePath = - null; // The Success snackbar is only shown when there is change made, otherwise it is not shown - if (showSuccessSnackbar) { - customSnackbar( - AppLocalizations.of(Get.context!)!.profileSavedSuccessfully, - AppLocalizations.of(Get.context!)!.profileUpdatedSuccessfully, - LogType.success, - ); - - SemanticsService.announce( - AppLocalizations.of(Get.context!)!.profileUpdatedSuccessfully, - TextDirection.ltr, - ); - } else { - // This snackbar is to show user that profile is up to date and there are no changes done by user - customSnackbar( - AppLocalizations.of(Get.context!)!.profileUpToDate, - AppLocalizations.of(Get.context!)!.noChangesToSave, - LogType.info, - ); - - SemanticsService.announce( - AppLocalizations.of(Get.context!)!.noChangesToSave, - TextDirection.ltr, - ); - } - } catch (e) { - log(e.toString()); - customSnackbar( - AppLocalizations.of(Get.context!)!.error, - e.toString(), - LogType.error, - ); - - SemanticsService.announce(e.toString(), TextDirection.ltr); - } finally { - isLoading.value = false; - showSuccessSnackbar = false; - } - } -} diff --git a/lib/controllers/friends_controller.dart b/lib/controllers/friends_controller.dart index fe8a86da..79c70ffe 100644 --- a/lib/controllers/friends_controller.dart +++ b/lib/controllers/friends_controller.dart @@ -49,19 +49,19 @@ class FriendsController extends GetxController { final userFCMToken = await firebaseMessaging.getToken(); final friendModel = FriendsModel( - senderId: authStateController.uid!, + senderId: authStateController.uid, recieverId: recieverId, senderProfileImgUrl: authStateController.profileImageUrl!, recieverProfileImgUrl: recieverProfileImageUrl, senderUsername: authStateController.userName!, recieverUsername: recieverUsername, - senderName: authStateController.displayName!, + senderName: authStateController.displayName, recieverName: recieverName, requestStatus: FriendRequestStatus.sent, - requestSentByUserId: authStateController.uid!, + requestSentByUserId: authStateController.uid, docId: docId, senderFCMToken: userFCMToken, - users: [authStateController.uid!, recieverId], + users: [authStateController.uid, recieverId], senderRating: authStateController.ratingTotal / authStateController.ratingCount, recieverRating: recieverRating, @@ -95,7 +95,7 @@ class FriendsController extends GetxController { final userDoc = await tables.getRow( databaseId: userDatabaseID, tableId: usersTableID, - rowId: authStateController.uid!, + rowId: authStateController.uid, queries: [ Query.select(["*", "friends.*"]), ], diff --git a/lib/controllers/live_chapter_controller.dart b/lib/controllers/live_chapter_controller.dart index ada65621..06567a98 100644 --- a/lib/controllers/live_chapter_controller.dart +++ b/lib/controllers/live_chapter_controller.dart @@ -76,9 +76,9 @@ class LiveChapterController extends GetxController { try { final liveChapterData = LiveChapterModel( livekitRoomId: roomId, - authorUid: authStateController.uid!, + authorUid: authStateController.uid, authorProfileImageUrl: authStateController.profileImageUrl!, - authorName: authStateController.displayName!, + authorName: authStateController.displayName, chapterTitle: chapterTitle, chapterDescription: chapterDescription, storyId: storyId, @@ -106,7 +106,7 @@ class LiveChapterController extends GetxController { ); await RoomService.createLiveChapterRoom( appwriteRoomId: liveChapterData.livekitRoomId, - adminUid: authStateController.uid!, + adminUid: authStateController.uid, ); liveChapterModel.value = liveChapterData; if (authStateController.followers.isNotEmpty) { @@ -144,12 +144,12 @@ class LiveChapterController extends GetxController { ...liveChapterData.attendees!.users.map( (element) => element["\$id"] as String, ), - authStateController.uid!, + authStateController.uid, ], users: [ ...liveChapterData.attendees!.users, { - "\$id": authStateController.uid!, + "\$id": authStateController.uid, "name": authStateController.displayName, "profileImageUrl": authStateController.profileImageUrl, }, @@ -169,7 +169,7 @@ class LiveChapterController extends GetxController { ); await RoomService.joinLiveChapterRoom( roomId: roomId, - userId: authStateController.uid!, + userId: authStateController.uid, ); listenForAttendeesAdded(); appRouter.push(RoutePaths.liveChapterScreen); @@ -186,7 +186,7 @@ class LiveChapterController extends GetxController { bool get isAdmin { if (liveChapterModel.value == null) return false; - return liveChapterModel.value!.authorUid == authStateController.uid!; + return liveChapterModel.value!.authorUid == authStateController.uid; } Future turnOnMic() async { @@ -217,10 +217,10 @@ class LiveChapterController extends GetxController { .attendees! .copyWith( users: liveChapterModel.value!.attendees!.users - .where((element) => element["\$id"] != authStateController.uid!) + .where((element) => element["\$id"] != authStateController.uid) .toList(), userIds: liveChapterModel.value!.attendees!.userIds! - .where((element) => element != authStateController.uid!) + .where((element) => element != authStateController.uid) .toList(), ); await tables.updateRow( diff --git a/lib/controllers/onboarding_controller.dart b/lib/controllers/onboarding_controller.dart deleted file mode 100644 index a92d6659..00000000 --- a/lib/controllers/onboarding_controller.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'dart:developer'; -import 'dart:ui' as ui; - -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:intl/intl.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/core/providers/appwrite_providers.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class OnboardingController extends GetxController { - final ImagePicker _imagePicker = ImagePicker(); - AuthUser get authStateController => requireCurrentAuthUser; - final themeController = Get.find(); - late final Storage storage; - late final TablesDB tables; - - RxBool isLoading = false.obs; - String? profileImagePath; - String? uniqueIdForProfileImage; - TextEditingController nameController = TextEditingController(); - TextEditingController usernameController = TextEditingController(); - TextEditingController imageController = TextEditingController(text: ""); - TextEditingController dobController = TextEditingController(text: ""); - - final GlobalKey userOnboardingFormKey = GlobalKey(); - - Rx usernameAvailable = false.obs; - Rx usernameAvailableChecking = false.obs; - - @override - void onInit() async { - super.onInit(); - storage = Storage(rootContainer.read(appwriteClientProvider)); - tables = TablesDB(rootContainer.read(appwriteClientProvider)); - } - - Future chooseDate(BuildContext context) async { - DateTime? pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(1800), - lastDate: DateTime.now(), - ); - if (pickedDate != null) { - dobController.text = DateFormat( - "dd-MM-yyyy", - ).format(pickedDate).toString(); - } - } - - Future saveProfile() async { - if (!userOnboardingFormKey.currentState!.validate()) { - return; - } - var usernameAvail = await isUsernameAvailable( - usernameController.text.trim(), - ); - if (!usernameAvail) { - usernameAvailable.value = false; - customSnackbar( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.usernameUnavailable, - AppLocalizations.of(rootNavigatorKey.currentContext!)!.usernameInvalidOrTaken, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.usernameInvalidOrTaken, - ui.TextDirection.ltr, - ); - return; - } - try { - isLoading.value = true; - - // Update username collection - await tables.createRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: usernameController.text.trim(), - data: {"email": authStateController.email}, - ); - //Update User Meta Data - if (profileImagePath != null) { - uniqueIdForProfileImage = - authStateController.uid! + - DateTime.now().millisecondsSinceEpoch.toString(); - - final profileImage = await storage.createFile( - bucketId: userProfileImageBucketId, - fileId: uniqueIdForProfileImage!, - file: InputFile.fromPath( - path: profileImagePath!, - filename: "${authStateController.email}.jpeg", - ), - ); - imageController.text = - "$appwriteEndpoint/storage/buckets/$userProfileImageBucketId/files/${profileImage.$id}/view?project=$appwriteProjectId"; - } else { - imageController.text = themeController.userProfileImagePlaceholderUrl; - } - - // Update User meta data - await rootContainer.read(appwriteAccountProvider).updateName( - name: nameController.text.trim(), - ); - //log(authStateController.uid!); - await tables.createRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: { - "name": nameController.text.trim(), - "username": usernameController.text.trim(), - "profileImageUrl": imageController.text, - "dob": dobController.text, - "email": authStateController.email, - "profileImageID": uniqueIdForProfileImage, - }, - ); - await rootContainer.read(appwriteAccountProvider).updatePrefs( - prefs: {"isUserProfileComplete": true}, - ); - // Set user profile in authStateController - await rootContainer.read(authProvider.notifier).refresh(); - customSnackbar( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.profileCreatedSuccessfully, - AppLocalizations.of(rootNavigatorKey.currentContext!)!.userProfileCreatedSuccessfully, - LogType.success, - ); - - SemanticsService.announce( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.userProfileCreatedSuccessfully, - ui.TextDirection.ltr, - ); - appRouter.go(RoutePaths.tabview); - } catch (e) { - if (e.toString().contains('Invalid `documentId` param')) { - log(e.toString()); - customSnackbar( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.invalidFormat, - AppLocalizations.of(rootNavigatorKey.currentContext!)!.usernameAlphanumeric, - LogType.error, - ); - SemanticsService.announce( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.usernameAlphanumeric, - ui.TextDirection.ltr, - ); - } else { - // if (e.) - log(e.toString()); - customSnackbar( - AppLocalizations.of(rootNavigatorKey.currentContext!)!.error, - e.toString(), - LogType.error, - ); - SemanticsService.announce(e.toString(), ui.TextDirection.ltr); - } - } finally { - isLoading.value = false; - } - } - - Future pickImage() async { - try { - XFile? file = await _imagePicker.pickImage( - source: ImageSource.gallery, - maxHeight: 400, - maxWidth: 400, - ); - if (file == null) return; - profileImagePath = file.path; - update(); - } catch (e) { - log(e.toString()); - } - } - - Future isUsernameAvailable(String username) async { - try { - await tables.getRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: username, - ); - return false; - } catch (e) { - log(e.toString()); - return true; - } - } -} diff --git a/lib/controllers/pair_chat_controller.dart b/lib/controllers/pair_chat_controller.dart index 3fb5048a..3309ebe5 100644 --- a/lib/controllers/pair_chat_controller.dart +++ b/lib/controllers/pair_chat_controller.dart @@ -43,7 +43,7 @@ class PairChatController extends GetxController { final RxBool isUserListLoading = true.obs; void quickMatch() async { - String uid = authController.uid!; + String uid = authController.uid; String userName = authController.userName!; // Open realtime stream to check whether the request is paired @@ -109,7 +109,7 @@ class PairChatController extends GetxController { } void getRealtimeStream() { - String uid = authController.uid!; + String uid = authController.uid; String channel = 'databases.$masterDatabaseId.tables.$activePairsTableId.rows'; subscription = realtime.subscribe([channel]); @@ -213,7 +213,7 @@ class PairChatController extends GetxController { databaseId: masterDatabaseId, tableId: pairRequestTableId, queries: [ - Query.notEqual('uid', authController.uid!), + Query.notEqual('uid', authController.uid), Query.notEqual('isAnonymous', true), Query.limit(100), ], diff --git a/lib/controllers/user_profile_controller.dart b/lib/controllers/user_profile_controller.dart deleted file mode 100644 index cd1d0a36..00000000 --- a/lib/controllers/user_profile_controller.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'dart:developer'; -import 'dart:ui'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/core/providers/firebase_providers.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/story_category.dart'; - -class UserProfileController extends GetxController { - final TablesDB tablesDB; - - AuthUser get authStateController => requireCurrentAuthUser; - RxList searchedUserFollowers = [].obs; - RxList searchedUserStories = [].obs; - RxList searchedUserLikedStories = [].obs; - Rx isLoadingProfilePage = false.obs; - Rx isFollowingUser = false.obs; - String? followerDocumentId; - - UserProfileController({TablesDB? tablesDB}) - : tablesDB = tablesDB ?? AppwriteService.getTables(); - Future initializeProfile(String creatorId) async { - isLoadingProfilePage.value = true; - try { - await Future.wait([ - fetchUserCreatedStories(creatorId), - fetchUserLikedStories(creatorId), - fetchUserFollowers(creatorId), - ]); - } catch (e) { - log('initializeProfile failed: $e'); - } finally { - isLoadingProfilePage.value = false; - } - } - - Future fetchUserLikedStories(String creatorId) async { - List userLikedDocuments = await tablesDB - .listRows( - databaseId: storyDatabaseId, - tableId: likeTableId, - queries: [Query.equal('uId', creatorId)], - ) - .then((value) => value.rows); - - List userLikedStoriesDocuments = await Future.wait( - userLikedDocuments.map((value) async { - return await tablesDB.getRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: value.data['storyId'], - ); - }).toList(), - ); - - searchedUserLikedStories.value = await convertAppwriteDocListToStoryList( - userLikedStoriesDocuments, - ); - } - - Future> convertAppwriteDocListToStoryList( - List storyDocuments, - ) async { - return await Future.wait( - storyDocuments.map((value) async { - StoryCategory category = StoryCategory.values.byName( - value.data['category'], - ); - - Color tintColor = Color(int.parse("0xff${value.data['tintColor']}")); - - return Story( - title: value.data['title'], - storyId: value.$id, - description: value.data['description'], - userIsCreator: false, - category: category, - coverImageUrl: value.data['coverImgUrl'], - creatorId: value.data['creatorId'], - creatorName: value.data['creatorName'], - creatorImgUrl: value.data['creatorImgUrl'], - creationDate: DateTime.parse(value.$createdAt), - likesCount: value.data['likes'], - isLikedByCurrentUser: false, - playDuration: value.data['playDuration'], - tintColor: tintColor, - chapters: [], - ); - }).toList(), - ); - } - - Future fetchUserCreatedStories(String creatorId) async { - List storyDocuments = []; - try { - storyDocuments = await tablesDB - .listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', creatorId)], - ) - .then((value) => value.rows); - } on AppwriteException catch (e) { - log('Failed to fetch user created stories: ${e.message}'); - } - searchedUserStories.value = await convertAppwriteDocListToStoryList( - storyDocuments, - ); - } - - Future fetchUserFollowers(String userId) async { - Row userDocument = await tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: userId, - queries: [Query.select(["*", "followers.*"])], - ); - - searchedUserFollowers.value = - (userDocument.data['followers'] as List) - .map((doc) => FollowerUserModel.fromJson(doc)) - .toList(); - searchedUserFollowers - .where((follower) { - return follower.uid == authStateController.uid!; - }) - .forEach((follower) { - isFollowingUser.value = true; - followerDocumentId = follower.docId; - }); - } - - Future followCreator(String creatorId) async { - final fcmToken = - await rootContainer.read(firebaseMessagingProvider).getToken(); - final FollowerUserModel follower = FollowerUserModel( - docId: ID.unique(), - uid: authStateController.uid!, - username: authStateController.userName!, - profileImageUrl: authStateController.profileImageUrl!, - name: authStateController.displayName!, - fcmToken: fcmToken!, - followingUserId: creatorId, - followerRating: - authStateController.ratingTotal / authStateController.ratingCount, - ); - - await tablesDB.createRow( - databaseId: userDatabaseID, - tableId: followersTableID, - rowId: follower.docId, - data: follower.toJson(), - ); - searchedUserFollowers.add(follower); - isFollowingUser.value = true; - followerDocumentId = follower.docId; - return; - } - - Future unfollowCreator() async { - await tablesDB.deleteRow( - databaseId: userDatabaseID, - tableId: followersTableID, - rowId: followerDocumentId ?? "", - ); - isFollowingUser.value = false; - searchedUserFollowers.removeWhere( - (follower) => follower.docId == followerDocumentId, - ); - } -} diff --git a/lib/core/container.dart b/lib/core/container.dart index 34185c49..7f4d0606 100644 --- a/lib/core/container.dart +++ b/lib/core/container.dart @@ -4,7 +4,7 @@ import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -/// Single root ProviderContainer for the app. +// Single root ProviderContainer for the app. ProviderContainer _rootContainer = ProviderContainer(); ProviderContainer get rootContainer => _rootContainer; diff --git a/lib/core/legacy_dependencies.dart b/lib/core/legacy_dependencies.dart index 5f61da80..386f3445 100644 --- a/lib/core/legacy_dependencies.dart +++ b/lib/core/legacy_dependencies.dart @@ -3,7 +3,6 @@ import 'package:resonate/controllers/create_room_controller.dart'; import 'package:resonate/controllers/explore_story_controller.dart'; import 'package:resonate/controllers/friends_controller.dart'; import 'package:resonate/controllers/network_controller.dart'; -import 'package:resonate/controllers/onboarding_controller.dart'; import 'package:resonate/controllers/rooms_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; @@ -13,7 +12,6 @@ void setupLegacyGetXDependencies() { Get.lazyPut(() => TabViewController()); Get.lazyPut(() => RoomsController()); Get.lazyPut(() => CreateRoomController()); - Get.lazyPut(() => OnboardingController()); Get.lazyPut(() => ExploreStoryController()); Get.lazyPut(() => FriendsController(), fenix: true); } diff --git a/lib/features/auth/view/string_validators.dart b/lib/features/auth/view/string_validators.dart index dad8b086..851c5177 100644 --- a/lib/features/auth/view/string_validators.dart +++ b/lib/features/auth/view/string_validators.dart @@ -7,9 +7,20 @@ extension StringValidators on String { r'^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{6,}$', ); + static final _usernameRegex = RegExp(r'^[a-zA-Z0-9._-]+$'); + + static const usernameMinLength = 7; + bool isValidEmail() => _emailRegex.hasMatch(this); bool isValidPassword() => _passwordRegex.hasMatch(this); bool isSamePassword(String other) => this == other; + + + bool hasMinUsernameLength() => trim().length >= usernameMinLength; + + bool hasValidUsernameFormat() => _usernameRegex.hasMatch(trim()); + + bool isValidUsername() => hasMinUsernameLength() && hasValidUsernameFormat(); } diff --git a/lib/features/profile/data/generated/profile_repository.g.dart b/lib/features/profile/data/generated/profile_repository.g.dart new file mode 100644 index 00000000..b5c4d94a --- /dev/null +++ b/lib/features/profile/data/generated/profile_repository.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../profile_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(profileRepository) +final profileRepositoryProvider = ProfileRepositoryProvider._(); + +final class ProfileRepositoryProvider + extends + $FunctionalProvider< + ProfileRepository, + ProfileRepository, + ProfileRepository + > + with $Provider { + ProfileRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'profileRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$profileRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ProfileRepository create(Ref ref) { + return profileRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ProfileRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$profileRepositoryHash() => r'1eaac27edd3e5c67ebe7d4fbc8042cc14f9f7a7d'; diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart new file mode 100644 index 00000000..31454501 --- /dev/null +++ b/lib/features/profile/data/profile_repository.dart @@ -0,0 +1,392 @@ +import 'dart:developer'; +import 'dart:ui'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/core/providers/firebase_providers.dart'; +import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/models/story.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/profile_repository.g.dart'; + +@Riverpod(keepAlive: true) +ProfileRepository profileRepository(Ref ref) => ProfileRepository( + tables: ref.watch(appwriteTablesProvider), + storage: ref.watch(appwriteStorageProvider), + account: ref.watch(appwriteAccountProvider), + messaging: ref.watch(firebaseMessagingProvider), + ); + +class ProfileRepository { + ProfileRepository({ + required TablesDB tables, + required Storage storage, + required Account account, + required FirebaseMessaging messaging, + }) : _tables = tables, + _storage = storage, + _account = account, + _messaging = messaging; + + final TablesDB _tables; + final Storage _storage; + final Account _account; + final FirebaseMessaging _messaging; + +// Profile viewing + Future> fetchCreatedStories(String creatorId) async { + List rows = []; + try { + rows = await _tables + .listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.equal('creatorId', creatorId)], + ) + .then((value) => value.rows); + } on AppwriteException catch (e) { + log('Failed to fetch user created stories: ${e.message}'); + } + return rowsToStories(rows); + } + + Future> fetchLikedStories(String creatorId) async { + try { + final likeRows = await _tables + .listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [Query.equal('uId', creatorId)], + ) + .then((value) => value.rows); + + final storyRows = []; + for (final like in likeRows) { + try { + storyRows.add( + await _tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: like.data['storyId'], + ), + ); + } on AppwriteException catch (e) { + log('Skipping liked story ${like.data['storyId']}: ${e.message}'); + } + } + return rowsToStories(storyRows); + } on AppwriteException catch (e) { + log('Failed to fetch liked stories: ${e.message}'); + return []; + } + } + + Future> fetchFollowers(String userId) async { + try { + final userRow = await _tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: userId, + queries: [ + Query.select(['*', 'followers.*']) + ], + ); + + return (userRow.data['followers'] as List? ?? const []) + .map((doc) => FollowerUserModel.fromJson(doc as Map)) + .toList(); + } on AppwriteException catch (e) { + log('Failed to fetch followers: ${e.message}'); + return []; + } + } + + Future getFcmToken() => _messaging.getToken(); + + Future followCreator(FollowerUserModel follower) async { + await _tables.createRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: follower.docId, + data: follower.toJson(), + ); + } + + Future unfollowCreator(String followerDocumentId) async { + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: followerDocumentId, + ); + } + + List rowsToStories(List storyRows) { + return storyRows.map((value) { + final category = StoryCategory.values.byName(value.data['category']); + final tintColor = Color(int.parse('0xff${value.data['tintColor']}')); + + return Story( + title: value.data['title'], + storyId: value.$id, + description: value.data['description'], + userIsCreator: false, + category: category, + coverImageUrl: value.data['coverImgUrl'], + creatorId: value.data['creatorId'], + creatorName: value.data['creatorName'], + creatorImgUrl: value.data['creatorImgUrl'], + creationDate: DateTime.parse(value.$createdAt), + likesCount: value.data['likes'], + isLikedByCurrentUser: false, + playDuration: value.data['playDuration'], + tintColor: tintColor, + chapters: [], + ); + }).toList(); + } + + Future isUsernameAvailable( + String username, { + String? currentUsername, + }) async { + if (currentUsername != null && + username.trim() == currentUsername.trim()) { + return true; + } + try { + await _tables.getRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: username, + ); + return false; + } catch (e) { + log(e.toString()); + return true; + } + } + + Future createUsernameRow({ + required String username, + required String email, + }) async { + await _tables.createRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: username, + data: {'email': email}, + ); + } + + Future deleteUsernameRow(String username) async { + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: username, + ); + } + + Future changeUsername({ + required String uid, + required String email, + required String oldUsername, + required String newUsername, + }) async { + await createUsernameRow(username: newUsername, email: email); + await deleteUsernameRow(oldUsername); + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: {'username': newUsername}, + ); + } + + Future updateAccountName(String name) async { + await _account.updateName(name: name); + } + + Future updateDisplayName({ + required String uid, + required String name, + }) async { + await updateAccountName(name); + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: {'name': name}, + ); + } + +// Profile picture + Future<({String url, String id})> uploadProfileImage({ + required String uid, + required String email, + required String imagePath, + }) async { + final id = uid + DateTime.now().millisecondsSinceEpoch.toString(); + final file = await _storage.createFile( + bucketId: userProfileImageBucketId, + fileId: id, + file: InputFile.fromPath(path: imagePath, filename: '$email.jpeg'), + ); + final url = + '$appwriteEndpoint/storage/buckets/$userProfileImageBucketId/files/${file.$id}/view?project=$appwriteProjectId'; + return (url: url, id: id); + } + + Future deleteProfileImage(String fileId) async { + try { + await _storage.deleteFile( + bucketId: userProfileImageBucketId, + fileId: fileId, + ); + } catch (e) { + log(e.toString()); + } + } + + Future updateProfileImageRow({ + required String uid, + required String url, + required String id, + }) async { + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: {'profileImageUrl': url, 'profileImageID': id}, + ); + } + + Future clearProfileImageRow(String uid) async { + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: {'profileImageUrl': '', 'profileImageID': null}, + ); + } + +// Onboarding + Future createUserRow({ + required String uid, + required String name, + required String username, + required String profileImageUrl, + required String dob, + required String email, + String? profileImageID, + }) async { + await _tables.createRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: { + 'name': name, + 'username': username, + 'profileImageUrl': profileImageUrl, + 'dob': dob, + 'email': email, + 'profileImageID': profileImageID, + }, + ); + } + + Future markProfileComplete() async { + await _account.updatePrefs(prefs: {'isUserProfileComplete': true}); + } + +// Change email + Future isEmailAvailable(String email) async { + final docs = await _tables.listRows( + databaseId: userDatabaseID, + tableId: usernameTableID, + queries: [Query.equal('email', email)], + ); + return docs.total == 0; + } + + Future changeEmailInAuth({ + required String email, + required String password, + }) async { + try { + await _account.updateEmail(email: email, password: password); + } on AppwriteException catch (e) { + throw _mapException(e); + } catch (_) { + throw ChangeEmailFailure.unknown; + } + } + + Future changeEmailInDatabases({ + required String uid, + required String username, + required String email, + }) async { + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: {'email': email}, + ); + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: username, + data: {'email': email}, + ); + } + +// Delete account + Future deleteProfilePicture(String profileImageID) async { + try { + await _storage.deleteFile( + bucketId: userProfileImageBucketId, + fileId: profileImageID, + ); + } catch (e) { + log(e.toString()); + } + } + + Future deleteUsersCollectionDocument(String uid) async { + try { + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + ); + } catch (e) { + log(e.toString()); + } + } + + Future deleteUsernamesCollectionDocument(String username) async { + try { + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: username, + ); + } catch (e) { + log(e.toString()); + } + } + + ChangeEmailFailure _mapException(AppwriteException e) { + return switch (e.type) { + userInvalidCredentials => ChangeEmailFailure.invalidCredentials, + generalArgumentInvalid => ChangeEmailFailure.passwordTooShort, + _ => ChangeEmailFailure.unknown, + }; + } +} diff --git a/lib/features/profile/model/change_email_state.dart b/lib/features/profile/model/change_email_state.dart new file mode 100644 index 00000000..369e5d4d --- /dev/null +++ b/lib/features/profile/model/change_email_state.dart @@ -0,0 +1,27 @@ +enum ChangeEmailStatus { + success, + emailExists, + invalidCredentials, + passwordTooShort, + failed, +} + +// Credential-related failures for ProfileRepository +enum ChangeEmailFailure { invalidCredentials, passwordTooShort, unknown } + +// View-model state for the change-email form. +class ChangeEmailState { + const ChangeEmailState({ + this.isLoading = false, + this.passwordVisible = false, + }); + + final bool isLoading; + final bool passwordVisible; + + ChangeEmailState copyWith({bool? isLoading, bool? passwordVisible}) => + ChangeEmailState( + isLoading: isLoading ?? this.isLoading, + passwordVisible: passwordVisible ?? this.passwordVisible, + ); +} diff --git a/lib/features/profile/model/edit_profile_state.dart b/lib/features/profile/model/edit_profile_state.dart new file mode 100644 index 00000000..4cdc2761 --- /dev/null +++ b/lib/features/profile/model/edit_profile_state.dart @@ -0,0 +1,34 @@ +enum EditProfileSaveResult { saved, noChange } + +class EditProfileState { + const EditProfileState({ + this.isLoading = false, + this.usernameAvailable = true, + this.usernameChecking = false, + this.profileImagePath, + this.removeImage = false, + }); + + final bool isLoading; + final bool usernameAvailable; + final bool usernameChecking; + final String? profileImagePath; + final bool removeImage; + + EditProfileState copyWith({ + bool? isLoading, + bool? usernameAvailable, + bool? usernameChecking, + String? profileImagePath, + bool? removeImage, + bool clearProfileImagePath = false, + }) => EditProfileState( + isLoading: isLoading ?? this.isLoading, + usernameAvailable: usernameAvailable ?? this.usernameAvailable, + usernameChecking: usernameChecking ?? this.usernameChecking, + profileImagePath: clearProfileImagePath + ? null + : (profileImagePath ?? this.profileImagePath), + removeImage: removeImage ?? this.removeImage, + ); +} diff --git a/lib/features/profile/model/onboarding_state.dart b/lib/features/profile/model/onboarding_state.dart new file mode 100644 index 00000000..a3307e96 --- /dev/null +++ b/lib/features/profile/model/onboarding_state.dart @@ -0,0 +1,51 @@ +enum OnboardingStatus { + success, + usernameUnavailable, + invalidUsernameFormat, + error, +} + +class OnboardingResult { + const OnboardingResult(this.status, [this.message]); + + final OnboardingStatus status; + final String? message; + + static const success = OnboardingResult(OnboardingStatus.success); + static const usernameUnavailable = OnboardingResult( + OnboardingStatus.usernameUnavailable, + ); + static const invalidUsernameFormat = OnboardingResult( + OnboardingStatus.invalidUsernameFormat, + ); +} + +// View-model state for the onboarding form. +class OnboardingState { + const OnboardingState({ + this.isLoading = false, + this.profileImagePath, + this.usernameAvailable = false, + this.usernameChecking = false, + }); + + final bool isLoading; + final String? profileImagePath; + final bool usernameAvailable; + final bool usernameChecking; + + OnboardingState copyWith({ + bool? isLoading, + String? profileImagePath, + bool? usernameAvailable, + bool? usernameChecking, + bool clearProfileImagePath = false, + }) => OnboardingState( + isLoading: isLoading ?? this.isLoading, + profileImagePath: clearProfileImagePath + ? null + : (profileImagePath ?? this.profileImagePath), + usernameAvailable: usernameAvailable ?? this.usernameAvailable, + usernameChecking: usernameChecking ?? this.usernameChecking, + ); +} diff --git a/lib/features/profile/model/profile_view_data.dart b/lib/features/profile/model/profile_view_data.dart new file mode 100644 index 00000000..cbd1062e --- /dev/null +++ b/lib/features/profile/model/profile_view_data.dart @@ -0,0 +1,37 @@ +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/models/story.dart'; + +class ProfileViewData { + const ProfileViewData({ + this.createdStories = const [], + this.likedStories = const [], + this.followers = const [], + this.isFollowing = false, + this.followerDocumentId, + }); + + final List createdStories; + final List likedStories; + final List followers; + + final bool isFollowing; + final String? followerDocumentId; + + ProfileViewData copyWith({ + List? createdStories, + List? likedStories, + List? followers, + bool? isFollowing, + String? followerDocumentId, + bool clearFollowerDocumentId = false, + }) => + ProfileViewData( + createdStories: createdStories ?? this.createdStories, + likedStories: likedStories ?? this.likedStories, + followers: followers ?? this.followers, + isFollowing: isFollowing ?? this.isFollowing, + followerDocumentId: clearFollowerDocumentId + ? null + : (followerDocumentId ?? this.followerDocumentId), + ); +} diff --git a/lib/features/profile/profile_routes.dart b/lib/features/profile/profile_routes.dart new file mode 100644 index 00000000..76e4940e --- /dev/null +++ b/lib/features/profile/profile_routes.dart @@ -0,0 +1,30 @@ +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/profile/view/pages/change_email_page.dart'; +import 'package:resonate/features/profile/view/pages/delete_account_page.dart'; +import 'package:resonate/features/profile/view/pages/edit_profile_page.dart'; +import 'package:resonate/features/profile/view/pages/onboarding_page.dart'; +import 'package:resonate/features/profile/view/pages/profile_page.dart'; +import 'package:resonate/routes/route_paths.dart'; + +final List profileRoutes = [ + GoRoute( + path: RoutePaths.onboarding, + builder: (context, state) => const OnboardingPage(), + ), + GoRoute( + path: RoutePaths.profile, + builder: (context, state) => ProfilePage(), + ), + GoRoute( + path: RoutePaths.editProfile, + builder: (context, state) => const EditProfilePage(), + ), + GoRoute( + path: RoutePaths.changeEmail, + builder: (context, state) => const ChangeEmailPage(), + ), + GoRoute( + path: RoutePaths.deleteAccount, + builder: (context, state) => const DeleteAccountPage(), + ), +]; diff --git a/lib/features/profile/view/pages/change_email_page.dart b/lib/features/profile/view/pages/change_email_page.dart new file mode 100644 index 00000000..72db23e9 --- /dev/null +++ b/lib/features/profile/view/pages/change_email_page.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/auth/view/string_validators.dart'; +import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:resonate/features/profile/viewmodel/change_email_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class ChangeEmailPage extends ConsumerStatefulWidget { + const ChangeEmailPage({super.key}); + + @override + ConsumerState createState() => _ChangeEmailPageState(); +} + +class _ChangeEmailPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit(AppLocalizations l10n) async { + if (!_formKey.currentState!.validate()) return; + final view = View.of(context); + + final status = await ref.read(changeEmailProvider.notifier).changeEmail( + email: _emailController.text, + password: _passwordController.text, + ); + if (!mounted) return; + + final (title, message, type) = switch (status) { + ChangeEmailStatus.success => ( + l10n.emailChanged, + l10n.emailChangeSuccess, + LogType.success, + ), + ChangeEmailStatus.emailExists => ( + l10n.oops, + l10n.emailExists, + LogType.error, + ), + ChangeEmailStatus.invalidCredentials => ( + l10n.tryAgain, + l10n.incorrectEmailOrPassword, + LogType.error, + ), + ChangeEmailStatus.passwordTooShort => ( + l10n.tryAgain, + l10n.passwordShort, + LogType.error, + ), + ChangeEmailStatus.failed => ( + l10n.failed, + l10n.emailChangeFailed, + LogType.error, + ), + }; + customSnackbar(title, message, type); + SemanticsService.sendAnnouncement(view, message, TextDirection.ltr); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(changeEmailProvider); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar(title: Text(l10n.changeEmail)), + body: SingleChildScrollView( + child: Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_20, + horizontal: UiSizes.width_20, + ), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + validator: (value) => value!.isValidEmail() + ? null + : l10n.enterValidEmail, + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.alternate_email_rounded), + labelText: l10n.newEmail, + ), + ), + SizedBox(height: UiSizes.height_20), + TextFormField( + controller: _passwordController, + obscureText: !state.passwordVisible, + validator: (value) => + value! == "" ? l10n.passwordEmpty : null, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.lock_outline_rounded), + labelText: l10n.currentPassword, + suffixIcon: Semantics( + label: state.passwordVisible + ? l10n.hidePassword + : l10n.showPassword, + child: GestureDetector( + onTap: () => ref + .read(changeEmailProvider.notifier) + .togglePasswordVisible(), + child: Container( + width: 56, + color: Colors.transparent, + child: Icon( + state.passwordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + ), + ), + ), + SizedBox(height: UiSizes.height_30), + Text(l10n.emailChangeInfo), + SizedBox(height: UiSizes.height_30), + MergeSemantics( + child: Column( + children: [ + Text( + l10n.oauthUsersMessage, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + Text(l10n.oauthUsersEmailChangeInfo), + ], + ), + ), + SizedBox(height: UiSizes.height_30), + SizedBox( + width: double.maxFinite, + child: ElevatedButton( + onPressed: + state.isLoading ? null : () async => _submit(l10n), + child: state.isLoading + ? Center( + child: + LoadingAnimationWidget.horizontalRotatingDots( + color: Theme.of(context).colorScheme.onPrimary, + size: UiSizes.size_40, + ), + ) + : Text(l10n.changeEmail), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/view/pages/delete_account_page.dart b/lib/features/profile/view/pages/delete_account_page.dart new file mode 100644 index 00000000..18b869ee --- /dev/null +++ b/lib/features/profile/view/pages/delete_account_page.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/viewmodel/delete_account_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class DeleteAccountPage extends ConsumerWidget { + const DeleteAccountPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final username = ref.watch(authProvider).value?.userOrNull?.userName ?? ''; + final isButtonActive = ref.watch(deleteAccountProvider); + + return Scaffold( + appBar: AppBar(title: Text(l10n.deleteAccount)), + body: Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_20, + horizontal: UiSizes.width_20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_5), + child: Text( + l10n.deleteMyAccount, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: UiSizes.size_16, + color: Colors.redAccent, + ), + ), + ), + Text(l10n.deleteAccountPermanent), + SizedBox(height: UiSizes.height_40), + RichText( + text: TextSpan( + style: const TextStyle(color: Colors.redAccent, fontSize: 16), + children: [ + TextSpan(text: l10n.toConfirmType), + TextSpan( + text: ' "$username" ', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: l10n.inTheBoxBelow), + ], + ), + ), + SizedBox(height: UiSizes.height_10), + TextField( + onChanged: (value) => ref + .read(deleteAccountProvider.notifier) + .setButtonActive(value == username), + keyboardType: TextInputType.text, + autocorrect: false, + cursorColor: Colors.redAccent, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.redAccent, + width: UiSizes.width_2, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + ), + ), + SizedBox(height: UiSizes.height_40), + SizedBox( + width: double.maxFinite, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + disabledForegroundColor: Colors.redAccent.withAlpha(100), + disabledBackgroundColor: Colors.redAccent.withAlpha(50), + ), + onPressed: isButtonActive + ? () { + // DO NOT IMPLEMENT THIS WITHOUT PERMISSION + } + : null, + child: Text( + l10n.iUnderstandDeleteMyAccount, + style: const TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/view/pages/edit_profile_page.dart b/lib/features/profile/view/pages/edit_profile_page.dart new file mode 100644 index 00000000..27c625bb --- /dev/null +++ b/lib/features/profile/view/pages/edit_profile_page.dart @@ -0,0 +1,455 @@ +import 'dart:developer'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/auth/view/string_validators.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/model/edit_profile_state.dart'; +import 'package:resonate/features/profile/viewmodel/edit_profile_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/debouncer.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/loading_dialog.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class EditProfilePage extends ConsumerStatefulWidget { + const EditProfilePage({super.key}); + + @override + ConsumerState createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _usernameController = TextEditingController(); + final _debouncer = Debouncer(milliseconds: 800); + final _imagePicker = ImagePicker(); + final _themeController = Get.find(); + + @override + void initState() { + super.initState(); + final user = ref.read(authProvider).value?.userOrNull; + _nameController.text = (user?.displayName ?? '').trim(); + _usernameController.text = (user?.userName ?? '').trim(); + } + + @override + void dispose() { + _nameController.dispose(); + _usernameController.dispose(); + _debouncer.dispose(); + super.dispose(); + } + + bool _hasUnsavedChanges() { + final notifier = ref.read(editProfileProvider.notifier); + return notifier.isProfilePictureChanged() || + notifier.isUsernameChanged(_usernameController.text) || + notifier.isDisplayNameChanged(_nameController.text); + } + + void _onUsernameChanged(String value, AppLocalizations l10n) { + Get.closeCurrentSnackbar(); + final notifier = ref.read(editProfileProvider.notifier); + final trimmed = value.trim(); + + if (!value.isValidUsername()) { + notifier.setUsernameAvailable(false); + notifier.setUsernameChecking(false); + return; + } + + notifier.setUsernameChecking(true); + notifier.setUsernameAvailable(false); + _debouncer.run(() async { + final available = await notifier.isUsernameAvailable(trimmed); + notifier.setUsernameChecking(false); + notifier.setUsernameAvailable(available); + if (!available) { + customSnackbar( + l10n.usernameUnavailable, + l10n.usernameAlreadyTaken, + LogType.error, + snackbarDuration: 1, + ); + } + }); + } + + Future _cropImage(String imagePath, AppLocalizations l10n) { + return ImageCropper().cropImage( + sourcePath: imagePath, + aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), + uiSettings: [ + AndroidUiSettings(toolbarTitle: l10n.cropImage), + IOSUiSettings(minimumAspectRatio: 1.0, title: l10n.cropImage), + ], + ); + } + + Future _pickAndSet(ImageSource source, AppLocalizations l10n) async { + loadingDialog(context); + try { + final file = await _imagePicker.pickImage(source: source); + if (file == null) return; + final cropped = await _cropImage(file.path, l10n); + if (cropped != null) { + ref.read(editProfileProvider.notifier).setProfileImagePath(cropped.path); + } + } catch (e) { + log(e.toString()); + } finally { + if (mounted) Navigator.of(context, rootNavigator: true).pop(); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + final l10n = AppLocalizations.of(context)!; + final view = View.of(context); + try { + final result = await ref.read(editProfileProvider.notifier).saveProfile( + name: _nameController.text, + username: _usernameController.text, + ); + if (!mounted) return; + if (result == EditProfileSaveResult.saved) { + customSnackbar( + l10n.profileSavedSuccessfully, + l10n.profileUpdatedSuccessfully, + LogType.success, + ); + SemanticsService.sendAnnouncement( + view, + l10n.profileUpdatedSuccessfully, + ui.TextDirection.ltr, + ); + } else { + customSnackbar(l10n.profileUpToDate, l10n.noChangesToSave, LogType.info); + SemanticsService.sendAnnouncement( + view, + l10n.noChangesToSave, + ui.TextDirection.ltr, + ); + } + } catch (e) { + if (!mounted) return; + customSnackbar(l10n.error, e.toString(), LogType.error); + SemanticsService.sendAnnouncement(view, e.toString(), ui.TextDirection.ltr); + } + } + + Future _saveChangesDialog(AppLocalizations l10n) async { + await showDialog( + context: context, + useRootNavigator: true, + builder: (dialogContext) => AlertDialog( + title: Text( + l10n.saveChanges, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + content: Text( + textAlign: TextAlign.center, + l10n.unsavedChangesWarning, + style: TextStyle(fontSize: UiSizes.size_14), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + Navigator.of(context).pop(); + }, + child: Text( + l10n.discard, + style: const TextStyle( + letterSpacing: 2, + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () async { + Navigator.pop(dialogContext); + await _save(); + }, + child: Text( + l10n.save, + style: TextStyle( + letterSpacing: 2, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ); + } + + void _showImageSourceSheet(AppLocalizations l10n, String? currentImageUrl) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => ListView( + shrinkWrap: true, + padding: EdgeInsets.only( + top: UiSizes.height_30, + bottom: UiSizes.height_60, + ), + children: [ + Text( + l10n.changeProfilePicture, + style: TextStyle( + fontSize: UiSizes.size_20, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: UiSizes.height_30), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + IconButton( + tooltip: l10n.clickPictureCamera, + onPressed: () { + Navigator.pop(sheetContext); + _pickAndSet(ImageSource.camera, l10n); + }, + icon: Icon( + Icons.camera_alt, + color: Theme.of(context).colorScheme.primary, + ), + iconSize: UiSizes.size_56, + ), + Text(l10n.camera), + ], + ), + Column( + children: [ + IconButton( + tooltip: l10n.pickImageGallery, + onPressed: () { + Navigator.pop(sheetContext); + _pickAndSet(ImageSource.gallery, l10n); + }, + icon: Icon( + Icons.image, + color: Theme.of(context).colorScheme.primary, + ), + iconSize: UiSizes.size_56, + ), + Text(l10n.gallery), + ], + ), + if (currentImageUrl != null) + Column( + children: [ + IconButton( + onPressed: () { + Navigator.pop(sheetContext); + ref + .read(editProfileProvider.notifier) + .removeProfilePicture(); + }, + icon: Icon( + Icons.delete, + color: Theme.of(context).colorScheme.primary, + ), + iconSize: UiSizes.size_56, + ), + Text(l10n.remove), + ], + ), + ], + ), + ], + ), + ); + } + + ImageProvider _avatarImage(EditProfileState state, String? profileImageUrl) { + if (state.profileImagePath != null) { + return FileImage(File(state.profileImagePath!)); + } + if (state.removeImage || + profileImageUrl == null || + profileImageUrl.isEmpty) { + return NetworkImage(_themeController.userProfileImagePlaceholderUrl); + } + return NetworkImage(profileImageUrl); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(editProfileProvider); + final user = ref.watch(authProvider).value?.userOrNull; + + return PopScope( + canPop: !(state.isLoading || _hasUnsavedChanges()), + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + if (!state.isLoading && _hasUnsavedChanges()) { + await _saveChangesDialog(l10n); + } else if (!state.isLoading && mounted) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar(title: Text(l10n.editProfile)), + body: Container( + width: double.maxFinite, + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_20, + horizontal: UiSizes.width_20, + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: UiSizes.height_20), + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: _avatarImage(state, user?.profileImageUrl), + radius: UiSizes.width_80, + child: Align( + alignment: Alignment.bottomRight, + child: Semantics( + label: l10n.uploadProfilePicture, + child: GestureDetector( + onTap: () => + _showImageSourceSheet(l10n, user?.profileImageUrl), + child: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.primary, + child: Icon( + Icons.edit, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ), + ), + SizedBox(height: UiSizes.height_40), + TextFormField( + validator: (value) => + value!.isNotEmpty ? null : l10n.requiredField, + controller: _nameController, + keyboardType: TextInputType.text, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.name, + prefixIcon: const Icon(Icons.abc_rounded), + ), + ), + SizedBox(height: UiSizes.height_20), + TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + maxLength: 36, + validator: (value) { + if (!value!.hasMinUsernameLength()) { + return l10n.usernameCharacterLimit; + } + if (!value.hasValidUsernameFormat()) { + return l10n.usernameInvalidFormat; + } + return null; + }, + controller: _usernameController, + onChanged: (value) => _onUsernameChanged(value, l10n), + keyboardType: TextInputType.text, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.username, + prefixIcon: const Icon(Icons.person), + suffixIcon: state.usernameChecking + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ), + ) + : state.usernameAvailable + ? const Icon( + Icons.verified_outlined, + color: Colors.green, + ) + : const Icon(Icons.close, color: Colors.red), + ), + ), + SizedBox(height: UiSizes.height_20), + SizedBox( + width: double.maxFinite, + child: OutlinedButton( + onPressed: () => context.push(RoutePaths.changeEmail), + style: OutlinedButton.styleFrom( + fixedSize: Size.fromHeight(UiSizes.height_60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.changeEmail), + const Icon(Icons.arrow_forward_rounded), + ], + ), + ), + ), + SizedBox(height: UiSizes.height_60), + SizedBox( + width: double.maxFinite, + child: ElevatedButton( + onPressed: (!state.isLoading && state.usernameAvailable) + ? () async => _save() + : null, + child: state.isLoading + ? Center( + child: LoadingAnimationWidget + .horizontalRotatingDots( + color: Theme.of(context).colorScheme.onPrimary, + size: UiSizes.size_40, + ), + ) + : Text(l10n.saveChanges), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/view/pages/onboarding_page.dart b/lib/features/profile/view/pages/onboarding_page.dart new file mode 100644 index 00000000..94a646a1 --- /dev/null +++ b/lib/features/profile/view/pages/onboarding_page.dart @@ -0,0 +1,284 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:resonate/features/auth/view/string_validators.dart'; +import 'package:resonate/features/profile/model/onboarding_state.dart'; +import 'package:resonate/features/profile/viewmodel/onboarding_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/debouncer.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class OnboardingPage extends ConsumerStatefulWidget { + const OnboardingPage({super.key}); + + @override + ConsumerState createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _usernameController = TextEditingController(); + final _dobController = TextEditingController(); + final _debouncer = Debouncer(milliseconds: 800); + final _imagePicker = ImagePicker(); + final _themeController = Get.find(); + + @override + void dispose() { + _nameController.dispose(); + _usernameController.dispose(); + _dobController.dispose(); + _debouncer.dispose(); + super.dispose(); + } + + Future _pickImage() async { + final file = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxHeight: 400, + maxWidth: 400, + ); + if (file == null) return; + ref.read(onboardingProvider.notifier).setProfileImagePath(file.path); + } + + Future _chooseDate() async { + final pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1800), + lastDate: DateTime.now(), + ); + if (pickedDate != null) { + _dobController.text = DateFormat("dd-MM-yyyy").format(pickedDate); + } + } + + void _onUsernameChanged(String value, AppLocalizations l10n) { + Get.closeCurrentSnackbar(); + final notifier = ref.read(onboardingProvider.notifier); + if (value.isValidUsername()) { + notifier.setUsernameChecking(true); + _debouncer.run(() async { + final available = await notifier.isUsernameAvailable(value.trim()); + notifier.setUsernameAvailable(available); + notifier.setUsernameChecking(false); + if (!available) { + customSnackbar( + l10n.usernameUnavailable, + l10n.usernameInvalidOrTaken, + LogType.error, + snackbarDuration: 1, + ); + } + }); + } else { + notifier.setUsernameAvailable(false); + } + } + + Future _submit(AppLocalizations l10n) async { + if (!_formKey.currentState!.validate()) return; + final view = View.of(context); + + final result = await ref.read(onboardingProvider.notifier).saveProfile( + name: _nameController.text, + username: _usernameController.text, + dob: _dobController.text, + fallbackImageUrl: _themeController.userProfileImagePlaceholderUrl, + ); + if (!mounted) return; + + switch (result.status) { + case OnboardingStatus.success: + customSnackbar( + l10n.profileCreatedSuccessfully, + l10n.userProfileCreatedSuccessfully, + LogType.success, + ); + SemanticsService.sendAnnouncement( + view, + l10n.userProfileCreatedSuccessfully, + ui.TextDirection.ltr, + ); + context.go(RoutePaths.tabview); + case OnboardingStatus.usernameUnavailable: + customSnackbar( + l10n.usernameUnavailable, + l10n.usernameInvalidOrTaken, + LogType.error, + ); + SemanticsService.sendAnnouncement( + view, + l10n.usernameInvalidOrTaken, + ui.TextDirection.ltr, + ); + case OnboardingStatus.invalidUsernameFormat: + customSnackbar( + l10n.invalidFormat, + l10n.usernameAlphanumeric, + LogType.error, + ); + SemanticsService.sendAnnouncement( + view, + l10n.usernameAlphanumeric, + ui.TextDirection.ltr, + ); + case OnboardingStatus.error: + customSnackbar(l10n.error, result.message ?? '', LogType.error); + SemanticsService.sendAnnouncement( + view, + result.message ?? '', + ui.TextDirection.ltr, + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(onboardingProvider); + + return Scaffold( + appBar: AppBar(toolbarHeight: 0), + body: Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_20, + horizontal: UiSizes.width_20, + ), + width: double.maxFinite, + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + children: [ + SizedBox(height: UiSizes.height_40), + Text( + l10n.completeYourProfile, + style: Theme.of(context).textTheme.headlineSmall, + ), + SizedBox(height: UiSizes.height_60), + Semantics( + label: l10n.uploadProfilePicture, + child: GestureDetector( + onTap: _pickImage, + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: (state.profileImagePath == null) + ? NetworkImage( + _themeController.userProfileImagePlaceholderUrl, + ) + : FileImage(File(state.profileImagePath!)) + as ImageProvider, + radius: UiSizes.width_80, + child: Align( + alignment: Alignment.bottomRight, + child: CircleAvatar( + radius: UiSizes.width_20, + backgroundColor: Theme.of(context).colorScheme.primary, + child: Icon( + Icons.edit, + color: Theme.of(context).colorScheme.onPrimary, + size: UiSizes.size_20, + ), + ), + ), + ), + ), + ), + SizedBox(height: UiSizes.height_40), + TextFormField( + validator: (value) => + value!.isNotEmpty ? null : l10n.enterValidName, + controller: _nameController, + keyboardType: TextInputType.text, + maxLength: 100, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.name, + prefixIcon: const Icon(Icons.abc_rounded), + ), + ), + SizedBox(height: UiSizes.height_20), + TextFormField( + validator: (value) { + if (!value!.hasMinUsernameLength()) { + return l10n.usernameCharacterLimit; + } + if (!value.hasValidUsernameFormat()) { + return l10n.usernameInvalidFormat; + } + return null; + }, + maxLength: 36, + controller: _usernameController, + onChanged: (value) => _onUsernameChanged(value, l10n), + keyboardType: TextInputType.text, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.username, + prefixIcon: const Icon(Icons.person), + suffixText: + state.usernameChecking ? l10n.checking : null, + suffixIcon: + state.usernameAvailable && !state.usernameChecking + ? const Icon( + Icons.verified_outlined, + color: Colors.green, + ) + : null, + ), + ), + SizedBox(height: UiSizes.height_20), + TextFormField( + validator: (value) => + value!.isNotEmpty ? null : l10n.enterValidDOB, + readOnly: true, + onTap: _chooseDate, + canRequestFocus: false, + controller: _dobController, + keyboardType: TextInputType.text, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_rounded), + labelText: l10n.dateOfBirth, + ), + ), + SizedBox(height: UiSizes.height_40), + SizedBox( + width: double.maxFinite, + child: ElevatedButton( + onPressed: + state.isLoading ? null : () async => _submit(l10n), + child: state.isLoading + ? Center( + child: + LoadingAnimationWidget.horizontalRotatingDots( + color: Theme.of(context).colorScheme.onPrimary, + size: UiSizes.size_40, + ), + ) + : Text(l10n.submit), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/view/pages/profile_page.dart b/lib/features/profile/view/pages/profile_page.dart new file mode 100644 index 00000000..4d678a04 --- /dev/null +++ b/lib/features/profile/view/pages/profile_page.dart @@ -0,0 +1,608 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/controllers/explore_story_controller.dart'; +import 'package:resonate/controllers/friends_controller.dart'; +import 'package:resonate/features/auth/model/auth_user.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; +import 'package:resonate/features/profile/model/profile_view_data.dart'; +import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/models/friends_model.dart'; +import 'package:resonate/models/resonate_user.dart'; +import 'package:resonate/models/story.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/app_images.dart'; +import 'package:resonate/utils/enums/friend_request_status.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/screens/followers_screen.dart'; +import 'package:resonate/views/screens/friend_requests_screen.dart'; +import 'package:resonate/views/screens/friends_screen.dart'; +import 'package:resonate/views/screens/story_screen.dart'; +import 'package:resonate/views/widgets/loading_dialog.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class ProfilePage extends ConsumerStatefulWidget { + final ResonateUser? creator; + final bool? isCreatorProfile; + + ProfilePage({super.key, this.creator, this.isCreatorProfile}) + : assert( + isCreatorProfile != true || (creator != null && creator.uid != null), + 'creator and creator.uid are required when isCreatorProfile is true', + ); + + @override + ConsumerState createState() => _ProfilePageState(); +} + +class _ProfilePageState extends ConsumerState { + final themeController = Get.find(); + final exploreStoryController = Get.find(); + final friendsController = Get.find(); + + bool get _isCreator => widget.isCreatorProfile == true; + String get _creatorId => widget.creator!.uid!; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final authUser = ref.watch(authProvider).value?.userOrNull; + final profileAsync = + _isCreator ? ref.watch(profileViewProvider(_creatorId)) : null; + final profileData = profileAsync?.value; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.profile), + actions: !_isCreator + ? [ + IconButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FriendRequestsScreen(), + ), + ); + }, + icon: const Icon(Icons.notifications), + ), + IconButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => FriendsScreen()), + ); + }, + icon: const Icon(Icons.groups), + ), + ] + : null, + ), + body: Obx(() { + final loading = (profileAsync?.isLoading ?? false) || + friendsController.isLoadingFriends.value; + if (loading || authUser == null) { + return Center( + child: SizedBox( + height: 200, + width: 200, + child: LoadingIndicator( + indicatorType: Indicator.ballRotate, + colors: [Theme.of(context).colorScheme.primary], + ), + ), + ); + } + return SingleChildScrollView( + child: Column( + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_10, + horizontal: UiSizes.width_20, + ), + width: double.maxFinite, + child: Column( + children: [ + _buildProfileHeader(context, authUser, profileData), + _buildEmailVerificationButton(context, authUser), + SizedBox(height: UiSizes.height_10), + _buildProfileButtons(context, authUser, profileData), + ], + ), + ), + SizedBox(height: UiSizes.height_20), + _buildStoriesSection(context, profileData), + ], + ), + ); + }), + ); + } + + Widget _buildProfileHeader( + BuildContext context, + AuthUser authUser, + ProfileViewData? profileData, + ) { + final l10n = AppLocalizations.of(context)!; + final followers = profileData?.followers ?? const []; + + return Row( + children: [ + SizedBox(width: UiSizes.width_20), + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: _isCreator + ? NetworkImage(widget.creator!.profileImageUrl ?? '') + : authUser.profileImageUrl == null || + authUser.profileImageUrl!.isEmpty + ? NetworkImage(themeController.userProfileImagePlaceholderUrl) + : NetworkImage(authUser.profileImageUrl!), + radius: UiSizes.width_66, + ), + SizedBox(width: UiSizes.width_20), + Expanded( + child: MergeSemantics( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_isCreator && authUser.isEmailVerified) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + children: [ + const Icon( + Icons.verified_user_outlined, + color: Colors.green, + ), + const SizedBox(width: 5), + Text( + l10n.verified, + style: const TextStyle(color: Colors.green), + ), + ], + ), + ), + Text( + _isCreator + ? widget.creator!.name ?? '' + : authUser.displayName, + style: TextStyle( + fontSize: UiSizes.size_24, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + Chip( + label: Text( + "@${_isCreator ? widget.creator!.userName : authUser.userName}", + style: TextStyle( + fontSize: UiSizes.size_14, + overflow: TextOverflow.ellipsis, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade300), + ), + ), + Row( + children: [ + const Icon(Icons.star, color: Colors.amber), + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + _isCreator + ? widget.creator!.userRating!.toStringAsFixed(1) + : (authUser.ratingCount == 0 + ? 0.0 + : authUser.ratingTotal / + authUser.ratingCount) + .toStringAsFixed(1), + ), + ), + ], + ), + InkWell( + onTap: () { + if (!(followers.length == 1 && + (profileData?.isFollowing ?? false)) && + _isCreator) { + final sanitizedFollowersList = followers + .where((follower) => follower.uid != authUser.uid) + .toList(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FollowersScreen( + followers: sanitizedFollowersList, + ), + ), + ); + } + }, + child: Row( + children: [ + const Icon(Icons.people), + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + _isCreator + ? followers.length.toString() + : authUser.followers.length.toString(), + ), + ), + ], + ), + ), + ], + ), + ), + ), + SizedBox(width: UiSizes.width_20), + ], + ); + } + + Widget _buildEmailVerificationButton(BuildContext context, AuthUser authUser) { + final l10n = AppLocalizations.of(context)!; + if (_isCreator || authUser.isEmailVerified) { + return const SizedBox.shrink(); + } + + return Container( + margin: EdgeInsets.only(top: UiSizes.height_10), + width: double.maxFinite, + child: OutlinedButton( + onPressed: () { + loadingDialog(context); + ref.read(emailVerifyProvider.notifier).sendOtp(email: authUser.email); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.verified_user_outlined), + const SizedBox(width: 10), + Text(l10n.verifyEmail), + ], + ), + ), + ); + } + + Widget _buildProfileButtons( + BuildContext context, + AuthUser authUser, + ProfileViewData? profileData, + ) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final isFollowing = profileData?.isFollowing ?? false; + + return Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + if (_isCreator) { + final notifier = + ref.read(profileViewProvider(_creatorId).notifier); + if (isFollowing) { + notifier.unfollowCreator(); + } else { + notifier.followCreator(_creatorId); + } + } else { + context.push(RoutePaths.editProfile); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + isFollowing ? colorScheme.secondary : colorScheme.primary, + foregroundColor: + isFollowing ? colorScheme.onSecondary : colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + side: BorderSide(color: colorScheme.primary), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _isCreator + ? (isFollowing ? Icons.done : Icons.add) + : Icons.edit, + color: colorScheme.onPrimary, + ), + const SizedBox(width: 8), + Text( + _isCreator + ? (isFollowing ? l10n.following : l10n.follow) + : l10n.editProfile, + style: TextStyle(color: colorScheme.onPrimary), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + if (!_isCreator) + SizedBox( + height: 50, + width: 50, + child: ElevatedButton( + onPressed: () => context.push(RoutePaths.settings), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: const CircleBorder(), + padding: const EdgeInsets.all(12), + ), + child: Icon(Icons.settings, color: colorScheme.onPrimary), + ), + ) + else + Expanded(child: _buildFriendButton(context, colorScheme)), + ], + ); + } + + Widget _buildFriendButton(BuildContext context, ColorScheme colorScheme) { + final l10n = AppLocalizations.of(context)!; + return Obx(() { + final FriendsModel? friendModel = friendsController.friendsList + .firstWhereOrNull( + (friend) => + friend.senderId == widget.creator!.uid || + friend.recieverId == widget.creator!.uid, + ) ?? + friendsController.friendRequestsList.firstWhereOrNull( + (friend) => + friend.senderId == widget.creator!.uid || + friend.recieverId == widget.creator!.uid, + ); + return ElevatedButton( + onPressed: () async { + if (friendModel == null) { + await friendsController.sendFriendRequest( + widget.creator!.uid!, + widget.creator!.profileImageUrl!, + widget.creator!.userName!, + widget.creator!.name!, + widget.creator!.userRating!, + ); + customSnackbar( + l10n.friendRequestSent, + l10n.friendRequestSentTo(widget.creator!.name!), + LogType.success, + ); + } else { + if (friendModel.requestStatus == FriendRequestStatus.sent && + friendModel.senderId == widget.creator!.uid) { + await friendsController.acceptFriendRequest(friendModel); + customSnackbar( + l10n.friendRequestAccepted, + l10n.friendRequestAcceptedTo(widget.creator!.name!), + LogType.success, + ); + } else { + try { + await friendsController.removeFriend(friendModel); + } catch (e) { + log(e.toString()); + } + customSnackbar( + l10n.friendRequestCancelled, + l10n.friendRequestCancelledTo(widget.creator!.name!), + LogType.info, + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? colorScheme.primary + : colorScheme.secondary) + : colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + side: BorderSide(color: colorScheme.primary), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? Icons.check + : Icons.people) + : Icons.add, + color: colorScheme.onPrimary, + ), + const SizedBox(width: 8), + Text( + friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? friendModel.senderId == widget.creator!.uid + ? l10n.accept + : l10n.requested + : l10n.friends) + : l10n.addFriend, + style: TextStyle(color: colorScheme.onPrimary), + ), + ], + ), + ); + }); + } + + Widget _buildStoriesSection( + BuildContext context, + ProfileViewData? profileData, + ) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: EdgeInsets.only(left: UiSizes.width_20), + width: double.maxFinite, + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + _isCreator ? l10n.userCreatedStories : l10n.yourStories, + style: TextStyle( + fontSize: UiSizes.size_16, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ), + SizedBox(height: UiSizes.height_5), + _isCreator + ? _buildStoriesList( + context, + profileData?.createdStories ?? const [], + l10n.userNoStories, + ) + : Obx( + () => _buildStoriesList( + context, + exploreStoryController.userCreatedStories, + l10n.youNoStories, + ), + ), + SizedBox(height: UiSizes.height_10), + Align( + alignment: Alignment.centerLeft, + child: Text( + _isCreator ? l10n.userLikedStories : l10n.yourLikedStories, + style: TextStyle( + fontSize: UiSizes.size_16, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ), + SizedBox(height: UiSizes.height_5), + _isCreator + ? _buildStoriesList( + context, + profileData?.likedStories ?? const [], + l10n.userNoLikedStories, + ) + : Obx( + () => _buildStoriesList( + context, + exploreStoryController.userLikedStories, + l10n.youNoLikedStories, + ), + ), + ], + ), + ); + } + + Widget _buildStoriesList( + BuildContext context, + List stories, + String noStoryTextToShow, + ) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: UiSizes.height_200, + child: stories.isNotEmpty + ? ListView.builder( + itemCount: stories.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + return StoryItem(story: stories[index]); + }, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset(height: 150, width: 150, AppImages.emptyBoxImage), + const SizedBox(height: 5), + Text( + noStoryTextToShow, + style: TextStyle(color: colorScheme.onSurface), + ), + ], + ), + ); + } +} + +class StoryItem extends StatelessWidget { + const StoryItem({super.key, required this.story}); + + final Story story; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => StoryScreen(story: story)), + ); + }, + child: Container( + width: UiSizes.height_140, + margin: EdgeInsets.only(right: UiSizes.width_10), + child: Column( + children: [ + Container( + height: UiSizes.height_140, + width: UiSizes.height_140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme.surfaceContainerHighest, + image: DecorationImage( + image: NetworkImage(story.coverImageUrl), + fit: BoxFit.cover, + ), + ), + ), + SizedBox(height: UiSizes.height_5), + Text( + story.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: UiSizes.size_16, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + Text( + story.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: UiSizes.size_12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/viewmodel/change_email_notifier.dart b/lib/features/profile/viewmodel/change_email_notifier.dart new file mode 100644 index 00000000..34592f1a --- /dev/null +++ b/lib/features/profile/viewmodel/change_email_notifier.dart @@ -0,0 +1,56 @@ +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/change_email_notifier.g.dart'; + +@riverpod +class ChangeEmail extends _$ChangeEmail { + @override + ChangeEmailState build() => const ChangeEmailState(); + + void togglePasswordVisible() => + state = state.copyWith(passwordVisible: !state.passwordVisible); + + Future changeEmail({ + required String email, + required String password, + }) async { + final user = ref.read(authProvider).value?.userOrNull; + if (user == null) return ChangeEmailStatus.failed; + + final repo = ref.read(profileRepositoryProvider); + state = state.copyWith(isLoading: true); + try { + if (!await repo.isEmailAvailable(email)) { + return ChangeEmailStatus.emailExists; + } + + try { + await repo.changeEmailInAuth(email: email, password: password); + } on ChangeEmailFailure catch (failure) { + return switch (failure) { + ChangeEmailFailure.invalidCredentials => + ChangeEmailStatus.invalidCredentials, + ChangeEmailFailure.passwordTooShort => + ChangeEmailStatus.passwordTooShort, + ChangeEmailFailure.unknown => ChangeEmailStatus.failed, + }; + } + + await repo.changeEmailInDatabases( + uid: user.uid, + username: user.userName ?? '', + email: email, + ); + await ref.read(authProvider.notifier).refresh(); + + return ChangeEmailStatus.success; + } catch (_) { + return ChangeEmailStatus.failed; + } finally { + state = state.copyWith(isLoading: false); + } + } +} diff --git a/lib/features/profile/viewmodel/delete_account_notifier.dart b/lib/features/profile/viewmodel/delete_account_notifier.dart new file mode 100644 index 00000000..8c8ece11 --- /dev/null +++ b/lib/features/profile/viewmodel/delete_account_notifier.dart @@ -0,0 +1,12 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/delete_account_notifier.g.dart'; + +// Not Implemented as it never was +@riverpod +class DeleteAccount extends _$DeleteAccount { + @override + bool build() => false; + + void setButtonActive(bool value) => state = value; +} diff --git a/lib/features/profile/viewmodel/edit_profile_notifier.dart b/lib/features/profile/viewmodel/edit_profile_notifier.dart new file mode 100644 index 00000000..8a960361 --- /dev/null +++ b/lib/features/profile/viewmodel/edit_profile_notifier.dart @@ -0,0 +1,121 @@ +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/edit_profile_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/edit_profile_notifier.g.dart'; + +@riverpod +class EditProfile extends _$EditProfile { + @override + EditProfileState build() => const EditProfileState(); + + void setUsernameChecking(bool value) => + state = state.copyWith(usernameChecking: value); + + void setUsernameAvailable(bool value) => + state = state.copyWith(usernameAvailable: value); + + void setProfileImagePath(String path) => + state = state.copyWith(profileImagePath: path, removeImage: false); + + void removeProfilePicture() { + final user = ref.read(authProvider).value?.userOrNull; + state = state.copyWith( + removeImage: user?.profileImageUrl != null, + clearProfileImagePath: true, + ); + } + + Future isUsernameAvailable(String username) { + final user = ref.read(authProvider).value?.userOrNull; + return ref.read(profileRepositoryProvider).isUsernameAvailable( + username, + currentUsername: user?.userName, + ); + } + + bool isDisplayNameChanged(String name) { + final user = ref.read(authProvider).value?.userOrNull; + return name.trim() != (user?.displayName ?? '').trim(); + } + + bool isUsernameChanged(String username) { + final user = ref.read(authProvider).value?.userOrNull; + return username.trim() != (user?.userName ?? '').trim(); + } + + bool isProfilePictureChanged() => + state.profileImagePath != null || state.removeImage; + + Future saveProfile({ + required String name, + required String username, + }) async { + final user = ref.read(authProvider).value?.userOrNull; + if (user == null) return EditProfileSaveResult.noChange; + + final repo = ref.read(profileRepositoryProvider); + final trimmedName = name.trim(); + final trimmedUsername = username.trim(); + + final pictureChanged = isProfilePictureChanged(); + final usernameChanged = isUsernameChanged(username); + final displayNameChanged = isDisplayNameChanged(name); + final changed = pictureChanged || usernameChanged || displayNameChanged; + + state = state.copyWith(isLoading: true); + try { + if (state.profileImagePath != null) { + if (user.profileImageID != null) { + await repo.deleteProfileImage(user.profileImageID!); + } + final uploaded = await repo.uploadProfileImage( + uid: user.uid, + email: user.email, + imagePath: state.profileImagePath!, + ); + await repo.updateProfileImageRow( + uid: user.uid, + url: uploaded.url, + id: uploaded.id, + ); + } + + if (state.removeImage) { + if (user.profileImageID != null) { + await repo.deleteProfileImage(user.profileImageID!); + } + await repo.clearProfileImageRow(user.uid); + } + + if (usernameChanged) { + await repo.changeUsername( + uid: user.uid, + email: user.email, + oldUsername: (user.userName ?? '').trim(), + newUsername: trimmedUsername, + ); + } + + if (displayNameChanged) { + await repo.updateDisplayName(uid: user.uid, name: trimmedName); + } + if (changed) { + await ref.read(authProvider.notifier).refresh(); + } + + state = state.copyWith( + isLoading: false, + removeImage: false, + clearProfileImagePath: true, + ); + return changed + ? EditProfileSaveResult.saved + : EditProfileSaveResult.noChange; + } catch (_) { + state = state.copyWith(isLoading: false); + rethrow; + } + } +} diff --git a/lib/features/profile/viewmodel/generated/change_email_notifier.g.dart b/lib/features/profile/viewmodel/generated/change_email_notifier.g.dart new file mode 100644 index 00000000..478af198 --- /dev/null +++ b/lib/features/profile/viewmodel/generated/change_email_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../change_email_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ChangeEmail) +final changeEmailProvider = ChangeEmailProvider._(); + +final class ChangeEmailProvider + extends $NotifierProvider { + ChangeEmailProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'changeEmailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$changeEmailHash(); + + @$internal + @override + ChangeEmail create() => ChangeEmail(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ChangeEmailState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$changeEmailHash() => r'a98535b66fb6f4551f2f7ff64c51c54478d49d37'; + +abstract class _$ChangeEmail extends $Notifier { + ChangeEmailState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ChangeEmailState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/profile/viewmodel/generated/delete_account_notifier.g.dart b/lib/features/profile/viewmodel/generated/delete_account_notifier.g.dart new file mode 100644 index 00000000..eff23545 --- /dev/null +++ b/lib/features/profile/viewmodel/generated/delete_account_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../delete_account_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(DeleteAccount) +final deleteAccountProvider = DeleteAccountProvider._(); + +final class DeleteAccountProvider + extends $NotifierProvider { + DeleteAccountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'deleteAccountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$deleteAccountHash(); + + @$internal + @override + DeleteAccount create() => DeleteAccount(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$deleteAccountHash() => r'a6ce4a8d69441aa8936b19ebfca553e964496ef3'; + +abstract class _$DeleteAccount extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/profile/viewmodel/generated/edit_profile_notifier.g.dart b/lib/features/profile/viewmodel/generated/edit_profile_notifier.g.dart new file mode 100644 index 00000000..4196958f --- /dev/null +++ b/lib/features/profile/viewmodel/generated/edit_profile_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../edit_profile_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(EditProfile) +final editProfileProvider = EditProfileProvider._(); + +final class EditProfileProvider + extends $NotifierProvider { + EditProfileProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'editProfileProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$editProfileHash(); + + @$internal + @override + EditProfile create() => EditProfile(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EditProfileState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$editProfileHash() => r'07022c879cd877802852c1898f373ad64ec82a0f'; + +abstract class _$EditProfile extends $Notifier { + EditProfileState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + EditProfileState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/profile/viewmodel/generated/onboarding_notifier.g.dart b/lib/features/profile/viewmodel/generated/onboarding_notifier.g.dart new file mode 100644 index 00000000..44bd7d6a --- /dev/null +++ b/lib/features/profile/viewmodel/generated/onboarding_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../onboarding_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(Onboarding) +final onboardingProvider = OnboardingProvider._(); + +final class OnboardingProvider + extends $NotifierProvider { + OnboardingProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'onboardingProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$onboardingHash(); + + @$internal + @override + Onboarding create() => Onboarding(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(OnboardingState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$onboardingHash() => r'5d549b5816b81d85e2edbd402a0e28455cf924d9'; + +abstract class _$Onboarding extends $Notifier { + OnboardingState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + OnboardingState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/profile/viewmodel/generated/profile_view_notifier.g.dart b/lib/features/profile/viewmodel/generated/profile_view_notifier.g.dart new file mode 100644 index 00000000..42870b46 --- /dev/null +++ b/lib/features/profile/viewmodel/generated/profile_view_notifier.g.dart @@ -0,0 +1,99 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../profile_view_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ProfileView) +final profileViewProvider = ProfileViewFamily._(); + +final class ProfileViewProvider + extends $AsyncNotifierProvider { + ProfileViewProvider._({ + required ProfileViewFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'profileViewProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$profileViewHash(); + + @override + String toString() { + return r'profileViewProvider' + '' + '($argument)'; + } + + @$internal + @override + ProfileView create() => ProfileView(); + + @override + bool operator ==(Object other) { + return other is ProfileViewProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$profileViewHash() => r'01aedde272801aa4b21bc14701b109babb5692b2'; + +final class ProfileViewFamily extends $Family + with + $ClassFamilyOverride< + ProfileView, + AsyncValue, + ProfileViewData, + FutureOr, + String + > { + ProfileViewFamily._() + : super( + retry: null, + name: r'profileViewProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ProfileViewProvider call(String creatorId) => + ProfileViewProvider._(argument: creatorId, from: this); + + @override + String toString() => r'profileViewProvider'; +} + +abstract class _$ProfileView extends $AsyncNotifier { + late final _$args = ref.$arg as String; + String get creatorId => _$args; + + FutureOr build(String creatorId); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, ProfileViewData>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, ProfileViewData>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/lib/features/profile/viewmodel/onboarding_notifier.dart b/lib/features/profile/viewmodel/onboarding_notifier.dart new file mode 100644 index 00000000..4ba5c25f --- /dev/null +++ b/lib/features/profile/viewmodel/onboarding_notifier.dart @@ -0,0 +1,81 @@ +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/onboarding_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/onboarding_notifier.g.dart'; + +@riverpod +class Onboarding extends _$Onboarding { + @override + OnboardingState build() => const OnboardingState(); + + void setProfileImagePath(String path) => + state = state.copyWith(profileImagePath: path); + + void setUsernameChecking(bool value) => + state = state.copyWith(usernameChecking: value); + + void setUsernameAvailable(bool value) => + state = state.copyWith(usernameAvailable: value); + + Future isUsernameAvailable(String username) => + ref.read(profileRepositoryProvider).isUsernameAvailable(username); + + Future saveProfile({ + required String name, + required String username, + required String dob, + required String fallbackImageUrl, + }) async { + final user = ref.read(authProvider).value?.userOrNull; + if (user == null) return const OnboardingResult(OnboardingStatus.error); + + final repo = ref.read(profileRepositoryProvider); + + if (!await repo.isUsernameAvailable(username.trim())) { + state = state.copyWith(usernameAvailable: false); + return OnboardingResult.usernameUnavailable; + } + + state = state.copyWith(isLoading: true); + try { + await repo.createUsernameRow(username: username.trim(), email: user.email); + + String imageUrl; + String? imageId; + if (state.profileImagePath != null) { + final uploaded = await repo.uploadProfileImage( + uid: user.uid, + email: user.email, + imagePath: state.profileImagePath!, + ); + imageUrl = uploaded.url; + imageId = uploaded.id; + } else { + imageUrl = fallbackImageUrl; + } + + await repo.updateAccountName(name.trim()); + await repo.createUserRow( + uid: user.uid, + name: name.trim(), + username: username.trim(), + profileImageUrl: imageUrl, + dob: dob, + email: user.email, + profileImageID: imageId, + ); + await repo.markProfileComplete(); + await ref.read(authProvider.notifier).refresh(); + return OnboardingResult.success; + } catch (e) { + if (e.toString().contains('Invalid `documentId` param')) { + return OnboardingResult.invalidUsernameFormat; + } + return OnboardingResult(OnboardingStatus.error, e.toString()); + } finally { + state = state.copyWith(isLoading: false); + } + } +} diff --git a/lib/features/profile/viewmodel/profile_view_notifier.dart b/lib/features/profile/viewmodel/profile_view_notifier.dart new file mode 100644 index 00000000..a6a68c59 --- /dev/null +++ b/lib/features/profile/viewmodel/profile_view_notifier.dart @@ -0,0 +1,92 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/profile_view_data.dart'; +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/models/story.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/profile_view_notifier.g.dart'; + +@riverpod +class ProfileView extends _$ProfileView { + @override + Future build(String creatorId) async { + final repo = ref.watch(profileRepositoryProvider); + + final results = await Future.wait([ + repo.fetchCreatedStories(creatorId), + repo.fetchLikedStories(creatorId), + repo.fetchFollowers(creatorId), + ]); + + final created = results[0] as List; + final liked = results[1] as List; + final followers = results[2] as List; + + final currentUid = ref.read(authProvider).value?.userOrNull?.uid; + FollowerUserModel? mine; + for (final follower in followers) { + if (follower.uid == currentUid) { + mine = follower; + break; + } + } + + return ProfileViewData( + createdStories: created, + likedStories: liked, + followers: followers, + isFollowing: mine != null, + followerDocumentId: mine?.docId, + ); + } + + Future followCreator(String creatorId) async { + final data = state.value; + final user = ref.read(authProvider).value?.userOrNull; + if (data == null || user == null) return; + + final repo = ref.read(profileRepositoryProvider); + final fcmToken = await repo.getFcmToken(); + + final follower = FollowerUserModel( + docId: ID.unique(), + uid: user.uid, + username: user.userName ?? '', + profileImageUrl: user.profileImageUrl ?? '', + name: user.displayName, + fcmToken: fcmToken ?? '', + followingUserId: creatorId, + followerRating: + user.ratingCount == 0 ? 0 : user.ratingTotal / user.ratingCount, + ); + + await repo.followCreator(follower); + + state = AsyncData( + data.copyWith( + followers: [...data.followers, follower], + isFollowing: true, + followerDocumentId: follower.docId, + ), + ); + } + + Future unfollowCreator() async { + final data = state.value; + final docId = data?.followerDocumentId; + if (data == null || docId == null) return; + + await ref.read(profileRepositoryProvider).unfollowCreator(docId); + + state = AsyncData( + data.copyWith( + followers: + data.followers.where((f) => f.docId != docId).toList(), + isFollowing: false, + clearFollowerDocumentId: true, + ), + ); + } +} diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index 3e8c249b..964919f8 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -6,26 +6,22 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/auth_routes.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/profile_routes.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/themes/theme_screen.dart'; import 'package:resonate/views/screens/about_app_screen.dart'; import 'package:resonate/views/screens/app_preferences_screen.dart'; -import 'package:resonate/views/screens/change_email_screen.dart'; import 'package:resonate/views/screens/contribute_screen.dart'; import 'package:resonate/views/screens/create_room_screen.dart'; import 'package:resonate/views/screens/create_story_screen.dart'; -import 'package:resonate/views/screens/delete_account_screen.dart'; -import 'package:resonate/views/screens/edit_profile_screen.dart'; import 'package:resonate/views/screens/explore_screen.dart'; import 'package:resonate/views/screens/home_screen.dart'; import 'package:resonate/views/screens/live_chapter_screen.dart'; import 'package:resonate/views/screens/notifications_screen.dart'; import 'package:resonate/views/screens/verify_chapter_details_screen.dart'; -import 'package:resonate/views/screens/onboarding_screen.dart'; import 'package:resonate/views/screens/pair_chat_screen.dart'; import 'package:resonate/views/screens/pair_chat_users_screen.dart'; import 'package:resonate/views/screens/pairing_screen.dart'; -import 'package:resonate/views/screens/profile_screen.dart'; import 'package:resonate/views/screens/ringing_screen.dart'; import 'package:resonate/views/screens/settings_screen.dart'; import 'package:resonate/views/screens/tabview_screen.dart'; @@ -48,12 +44,7 @@ final routerProvider = Provider((ref) { redirect: (context, state) => _redirect(ref, state), routes: [ ...authRoutes, - - // Onboarding (still GetX-backed until profile feature migrates) - GoRoute( - path: RoutePaths.onboarding, - builder: (_, _) => const OnBoardingScreen(), - ), + ...profileRoutes, // Main app shell GoRoute( @@ -69,23 +60,7 @@ final routerProvider = Provider((ref) { builder: (_, _) => CreateRoomScreen(), ), - // Profile & account - GoRoute( - path: RoutePaths.profile, - builder: (_, _) => ProfileScreen(), - ), - GoRoute( - path: RoutePaths.editProfile, - builder: (_, _) => EditProfileScreen(), - ), - GoRoute( - path: RoutePaths.deleteAccount, - builder: (_, _) => const DeleteAccountScreen(), - ), - GoRoute( - path: RoutePaths.changeEmail, - builder: (_, _) => ChangeEmailScreen(), - ), + // Account (settings remains GetX-backed) GoRoute( path: RoutePaths.settings, builder: (_, _) => SettingsScreen(), diff --git a/lib/views/screens/change_email_screen.dart b/lib/views/screens/change_email_screen.dart deleted file mode 100644 index dfe9cd6e..00000000 --- a/lib/views/screens/change_email_screen.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/controllers/change_email_controller.dart'; -import 'package:resonate/features/auth/view/string_validators.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -import '../../utils/ui_sizes.dart'; - -class ChangeEmailScreen extends StatelessWidget { - ChangeEmailScreen({super.key}); - - final controller = Get.put(ChangeEmailController()); - - @override - Widget build(BuildContext context) { - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar(title: Text(AppLocalizations.of(context)!.changeEmail)), - body: SingleChildScrollView( - child: Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_20, - horizontal: UiSizes.width_20, - ), - child: Form( - key: controller.changeEmailFormKey, - child: Column( - children: [ - TextFormField( - validator: (value) => value!.isValidEmail() - ? null - : AppLocalizations.of(context)!.enterValidEmail, - controller: controller.emailController, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.alternate_email_rounded), - labelText: AppLocalizations.of(context)!.newEmail, - ), - ), - SizedBox(height: UiSizes.height_20), - Obx( - () => TextFormField( - controller: controller.passwordController, - obscureText: !controller.isPasswordFieldVisible.value, - validator: (value) => value! == "" - ? AppLocalizations.of(context)!.passwordEmpty - : null, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - // number of lines the error text would wrap - prefixIcon: const Icon(Icons.lock_outline_rounded), - labelText: AppLocalizations.of(context)!.currentPassword, - suffixIcon: Semantics( - label: (controller.isPasswordFieldVisible.value) - ? AppLocalizations.of(context)!.hidePassword - : AppLocalizations.of(context)!.showPassword, - child: GestureDetector( - onTap: () { - controller.isPasswordFieldVisible.value = - !controller.isPasswordFieldVisible.value; - }, - child: Container( - width: 56, - color: Colors.transparent, - child: Icon( - controller.isPasswordFieldVisible.value - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - color: Theme.of(context).colorScheme.onSecondary, - ), - ), - ), - ), - ), - ), - ), - SizedBox(height: UiSizes.height_30), - Text(AppLocalizations.of(context)!.emailChangeInfo), - SizedBox(height: UiSizes.height_30), - MergeSemantics( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.oauthUsersMessage, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.redAccent, - ), - ), - Text( - AppLocalizations.of(context)!.oauthUsersEmailChangeInfo, - ), - ], - ), - ), - SizedBox(height: UiSizes.height_30), - Obx( - () => SizedBox( - width: double.maxFinite, - child: ElevatedButton( - onPressed: () async { - if (!controller.isLoading.value) { - await controller.changeEmail(context); - } - }, - child: controller.isLoading.value - ? Center( - child: - LoadingAnimationWidget.horizontalRotatingDots( - color: Theme.of( - context, - ).colorScheme.onPrimary, - size: UiSizes.size_40, - ), - ) - : Text(AppLocalizations.of(context)!.changeEmail), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/delete_account_screen.dart b/lib/views/screens/delete_account_screen.dart deleted file mode 100644 index 68e5a089..00000000 --- a/lib/views/screens/delete_account_screen.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/delete_account_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class DeleteAccountScreen extends StatelessWidget { - const DeleteAccountScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.deleteAccount)), - body: GetBuilder( - init: DeleteAccountController(), - builder: (controller) => Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_20, - horizontal: UiSizes.width_20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_5), - child: Text( - AppLocalizations.of(context)!.deleteMyAccount, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: UiSizes.size_16, - color: Colors.redAccent, - ), - ), - ), - Text(AppLocalizations.of(context)!.deleteAccountPermanent), - SizedBox(height: UiSizes.height_40), - RichText( - text: TextSpan( - style: const TextStyle(color: Colors.redAccent, fontSize: 16), - children: [ - TextSpan(text: AppLocalizations.of(context)!.toConfirmType), - TextSpan( - text: ' "${requireCurrentAuthUser.userName}" ', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: AppLocalizations.of(context)!.inTheBoxBelow), - ], - ), - ), - SizedBox(height: UiSizes.height_10), - TextField( - onChanged: (value) { - if (value == requireCurrentAuthUser.userName) { - controller.isButtonActive.value = true; - } else { - controller.isButtonActive.value = false; - } - }, - keyboardType: TextInputType.text, - autocorrect: false, - cursorColor: Colors.redAccent, - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.redAccent, - width: UiSizes.width_2, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), - ), - ), - ), - SizedBox(height: UiSizes.height_40), - Obx( - () => SizedBox( - width: double.maxFinite, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - disabledForegroundColor: Colors.redAccent.withAlpha(100), - disabledBackgroundColor: Colors.redAccent.withAlpha(50), - ), - onPressed: (controller.isButtonActive.value) - ? () { - // DO NOT IMPLEMENT THIS WITHOUT PERMISSION - } - : null, - child: Text( - AppLocalizations.of(context)!.iUnderstandDeleteMyAccount, - style: const TextStyle(fontSize: 16), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/edit_profile_screen.dart b/lib/views/screens/edit_profile_screen.dart deleted file mode 100644 index 667f327b..00000000 --- a/lib/views/screens/edit_profile_screen.dart +++ /dev/null @@ -1,414 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/debouncer.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/loading_dialog.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -import 'package:go_router/go_router.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/routes/route_paths.dart'; -import '../../controllers/edit_profile_controller.dart'; - -class EditProfileScreen extends StatelessWidget { - EditProfileScreen({super.key}); - - // Initializing controllers - final EditProfileController editProfileController = Get.put( - EditProfileController(), - ); - AuthUser get authStateController => requireCurrentAuthUser; - final debouncer = Debouncer(milliseconds: 800); - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: - !(editProfileController.isLoading.value || - editProfileController.isThereUnsavedChanges()), - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - - if (!editProfileController.isLoading.value && - editProfileController.isThereUnsavedChanges()) { - await saveChangesDialogue(context); - } else { - if (!editProfileController.isLoading.value) { - Get.back(); - } - } - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar(title: Text(AppLocalizations.of(context)!.editProfile)), - body: GetBuilder( - builder: (controller) => Container( - width: double.maxFinite, - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_20, - horizontal: UiSizes.width_20, - ), - child: Form( - key: controller.editProfileFormKey, - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox(height: UiSizes.height_20), - GetBuilder( - builder: (themeController) => CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.secondary, - backgroundImage: (controller.profileImagePath == null) - ? authStateController.profileImageUrl == "" || - controller.removeImage - ? NetworkImage( - themeController - .userProfileImagePlaceholderUrl, - ) - : NetworkImage( - authStateController.profileImageUrl!, - ) - : FileImage(File(controller.profileImagePath!)) - as ImageProvider, - radius: UiSizes.width_80, - child: Align( - alignment: Alignment.bottomRight, - child: Semantics( - label: AppLocalizations.of( - context, - )!.uploadProfilePicture, - child: GestureDetector( - onTap: () { - showBottomSheet(); - }, - child: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - child: Icon( - Icons.edit, - color: Theme.of( - context, - ).colorScheme.onPrimary, - ), - ), - ), - ), - ), - ), - ), - SizedBox(height: UiSizes.height_40), - TextFormField( - validator: (value) => value!.isNotEmpty - ? null - : AppLocalizations.of(context)!.requiredField, - controller: controller.nameController, - keyboardType: TextInputType.text, - autocorrect: false, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.name, - prefixIcon: Icon(Icons.abc_rounded), - ), - ), - SizedBox(height: UiSizes.height_20), - Obx( - () => TextFormField( - autovalidateMode: AutovalidateMode.onUserInteraction, - maxLength: 36, - validator: (value) { - if (value!.length >= 7) { - final validUsername = RegExp( - r'^[a-zA-Z0-9._-]+$', - ).hasMatch(value.trim()); - if (!validUsername) { - return AppLocalizations.of( - context, - )!.usernameInvalidFormat; - } - return null; - } else { - return AppLocalizations.of( - context, - )!.usernameCharacterLimit; - } - }, - controller: controller.usernameController, - onChanged: (value) { - Get.closeCurrentSnackbar(); - - final trimmedValue = value.trim(); - - if (trimmedValue.length < 7) { - controller.usernameAvailable.value = false; - controller.usernameChecking.value = false; - - return; - } - - if (!RegExp( - r'^[a-zA-Z0-9._-]+$', - ).hasMatch(trimmedValue)) { - controller.usernameAvailable.value = false; - controller.usernameChecking.value = false; - return; - } - - controller.usernameChecking.value = true; - controller.usernameAvailable.value = false; - - debouncer.run(() async { - final available = await controller - .isUsernameAvailable(value.trim()); - - controller.usernameChecking.value = false; - controller.usernameAvailable.value = available; - - if (!available) { - customSnackbar( - AppLocalizations.of( - context, - )!.usernameUnavailable, - AppLocalizations.of( - context, - )!.usernameAlreadyTaken, - LogType.error, - snackbarDuration: 1, - ); - } - }); - }, - - keyboardType: TextInputType.text, - autocorrect: false, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.username, - prefixIcon: const Icon(Icons.person), - //added circular progress indicator when checking - suffixIcon: controller.usernameChecking.value - ? Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : controller.usernameAvailable.value - ? const Icon( - Icons.verified_outlined, - color: Colors.green, - ) - : const Icon(Icons.close, color: Colors.red), - ), - ), - ), - SizedBox(height: UiSizes.height_20), - SizedBox( - width: double.maxFinite, - child: OutlinedButton( - onPressed: () { - context.push(RoutePaths.changeEmail); - }, - style: OutlinedButton.styleFrom( - fixedSize: Size.fromHeight(UiSizes.height_60), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.changeEmail), - Icon(Icons.arrow_forward_rounded), - ], - ), - ), - ), - SizedBox(height: UiSizes.height_60), - Obx( - () => SizedBox( - width: double.maxFinite, - child: ElevatedButton( - onPressed: - (!controller.isLoading.value && - controller.usernameAvailable.value) - ? () async { - await controller.saveProfile(); - } - : null, - child: controller.isLoading.value - ? Center( - child: - LoadingAnimationWidget.horizontalRotatingDots( - color: Theme.of( - context, - ).colorScheme.onPrimary, - size: UiSizes.size_40, - ), - ) - : Text(AppLocalizations.of(context)!.saveChanges), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } - - Future saveChangesDialogue(BuildContext context) async { - Get.defaultDialog( - title: AppLocalizations.of(context)!.saveChanges, - titleStyle: const TextStyle(fontWeight: FontWeight.w500), - titlePadding: EdgeInsets.symmetric(vertical: UiSizes.height_20), - content: Text( - textAlign: TextAlign.center, - AppLocalizations.of(context)!.unsavedChangesWarning, - style: TextStyle(fontSize: UiSizes.size_14), - ), - contentPadding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), - actions: [ - Padding( - padding: EdgeInsets.only(bottom: UiSizes.height_5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - onPressed: () { - Get.back(); - Get.back(); - }, - child: Text( - AppLocalizations.of(context)!.discard, - style: TextStyle( - letterSpacing: 2, - color: Colors.redAccent, - fontWeight: FontWeight.bold, - ), - ), - ), - TextButton( - onPressed: () async { - Get.back(); - await editProfileController.saveProfile(); - }, - child: Text( - AppLocalizations.of(context)!.save, - style: TextStyle( - letterSpacing: 2, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ], - ); - } - - void showBottomSheet() { - showModalBottomSheet( - context: Get.context!, - builder: (_) { - return changeProfilePictureBottomSheet(Get.context!); - }, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - ); - } - - Widget changeProfilePictureBottomSheet(BuildContext context) { - return ListView( - shrinkWrap: true, - padding: EdgeInsets.only( - top: UiSizes.height_30, - bottom: UiSizes.height_60, - ), - children: [ - Text( - AppLocalizations.of(context)!.changeProfilePicture, - style: TextStyle( - fontSize: UiSizes.size_20, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: UiSizes.height_30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton( - tooltip: AppLocalizations.of(context)!.clickPictureCamera, - onPressed: () { - Navigator.pop(context); - loadingDialog(context); - editProfileController.pickImageFromCamera(); - }, - icon: Icon( - Icons.camera_alt, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: UiSizes.size_56, - ), - Text(AppLocalizations.of(context)!.camera), - ], - ), - Column( - children: [ - IconButton( - tooltip: AppLocalizations.of(context)!.pickImageGallery, - onPressed: () { - Navigator.pop(context); - loadingDialog(context); - editProfileController.pickImageFromGallery(); - }, - icon: Icon( - Icons.image, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: UiSizes.size_56, - ), - Text(AppLocalizations.of(context)!.gallery), - ], - ), - if (authStateController.profileImageUrl != null) - Column( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - editProfileController.removeProfilePicture(); - }, - icon: Icon( - Icons.delete, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: UiSizes.size_56, - ), - Text(AppLocalizations.of(context)!.remove), - ], - ), - ], - ), - ], - ); - } -} diff --git a/lib/views/screens/onboarding_screen.dart b/lib/views/screens/onboarding_screen.dart deleted file mode 100644 index e8348220..00000000 --- a/lib/views/screens/onboarding_screen.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/debouncer.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -import '../../controllers/onboarding_controller.dart'; -import '../../utils/ui_sizes.dart'; - -class OnBoardingScreen extends StatefulWidget { - const OnBoardingScreen({super.key}); - - @override - State createState() => _OnBoardingScreenState(); -} - -class _OnBoardingScreenState extends State { - final debouncer = Debouncer(milliseconds: 800); - final themeController = Get.find(); - @override - Widget build(BuildContext context) { - return GetBuilder( - builder: (controller) => Scaffold( - appBar: AppBar(toolbarHeight: 0), - body: Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_20, - horizontal: UiSizes.width_20, - ), - width: double.maxFinite, - child: SingleChildScrollView( - child: Form( - key: controller.userOnboardingFormKey, - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox(height: UiSizes.height_40), - Text( - AppLocalizations.of(context)!.completeYourProfile, - style: Theme.of(context).textTheme.headlineSmall, - ), - SizedBox(height: UiSizes.height_60), - Semantics( - label: AppLocalizations.of(context)!.uploadProfilePicture, - child: GestureDetector( - onTap: () async => await controller.pickImage(), - child: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.secondary, - backgroundImage: (controller.profileImagePath == null) - ? NetworkImage( - themeController - .userProfileImagePlaceholderUrl, - ) - : FileImage(File(controller.profileImagePath!)) - as ImageProvider, - radius: UiSizes.width_80, - child: Stack( - children: [ - Align( - alignment: Alignment.bottomRight, - child: CircleAvatar( - radius: UiSizes.width_20, - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - child: Icon( - Icons.edit, - color: Theme.of( - context, - ).colorScheme.onPrimary, - size: UiSizes.size_20, - ), - ), - ), - ], - ), - ), - ), - ), - SizedBox(height: UiSizes.height_40), - TextFormField( - validator: (value) => value!.isNotEmpty - ? null - : AppLocalizations.of(context)!.enterValidName, - controller: controller.nameController, - keyboardType: TextInputType.text, - maxLength: 100, - autocorrect: false, - decoration: InputDecoration( - // hintText: "Name", - labelText: AppLocalizations.of(context)!.name, - prefixIcon: const Icon(Icons.abc_rounded), - ), - ), - SizedBox(height: UiSizes.height_20), - Obx( - () => TextFormField( - validator: (value) { - if (value!.length > 5) { - return null; - } else { - return AppLocalizations.of( - context, - )!.usernameCharacterLimit; - } - }, - maxLength: 36, - controller: controller.usernameController, - onChanged: (value) async { - Get.closeCurrentSnackbar(); - if (value.length > 5) { - controller.usernameAvailableChecking.value = true; - debouncer.run(() async { - controller.usernameAvailable.value = - await controller.isUsernameAvailable( - value.trim(), - ); - controller.usernameAvailableChecking.value = - false; - if (!controller.usernameAvailable.value) { - customSnackbar( - AppLocalizations.of( - context, - )!.usernameUnavailable, - AppLocalizations.of( - context, - )!.usernameInvalidOrTaken, - LogType.error, - snackbarDuration: 1, - ); - } - }); - } else { - controller.usernameAvailable.value = false; - } - }, - keyboardType: TextInputType.text, - autocorrect: false, - decoration: InputDecoration( - // hintText: "Username", - labelText: AppLocalizations.of(context)!.username, - prefixIcon: const Icon(Icons.person), - suffixText: controller.usernameAvailableChecking.value - ? AppLocalizations.of(context)!.checking - : null, - suffixIcon: - controller.usernameAvailable.value && - !controller.usernameAvailableChecking.value - ? const Icon( - Icons.verified_outlined, - color: Colors.green, - ) - : null, - ), - ), - ), - SizedBox(height: UiSizes.height_20), - TextFormField( - validator: (value) => value!.isNotEmpty - ? null - : AppLocalizations.of(context)!.enterValidDOB, - readOnly: true, - onTap: () async { - await controller.chooseDate(context); - }, - canRequestFocus: false, - controller: controller.dobController, - keyboardType: TextInputType.text, - autocorrect: false, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.calendar_month_rounded), - labelText: AppLocalizations.of(context)!.dateOfBirth, - // hintText: "Date of Birth", - ), - ), - SizedBox(height: UiSizes.height_40), - SizedBox( - width: double.maxFinite, - child: Obx( - () => ElevatedButton( - onPressed: () async { - if (!controller.isLoading.value) { - await controller.saveProfile(); - } - }, - child: controller.isLoading.value - ? Center( - child: - LoadingAnimationWidget.horizontalRotatingDots( - color: Theme.of( - context, - ).colorScheme.onPrimary, - size: UiSizes.size_40, - ), - ) - : Text(AppLocalizations.of(context)!.submit), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/profile_screen.dart b/lib/views/screens/profile_screen.dart deleted file mode 100644 index 79ce7042..00000000 --- a/lib/views/screens/profile_screen.dart +++ /dev/null @@ -1,653 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/controllers/friends_controller.dart'; -import 'package:resonate/controllers/user_profile_controller.dart'; -import 'package:resonate/models/friends_model.dart'; -import 'package:resonate/models/resonate_user.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/enums/friend_request_status.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/screens/followers_screen.dart'; -import 'package:resonate/views/screens/friend_requests_screen.dart'; -import 'package:resonate/views/screens/friends_screen.dart'; -import 'package:resonate/views/screens/story_screen.dart'; -import 'package:resonate/views/widgets/loading_dialog.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; -import 'package:resonate/routes/route_paths.dart'; -import '../../utils/app_images.dart'; -import '../../utils/ui_sizes.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class ProfileScreen extends StatefulWidget { - final ResonateUser? creator; - - final bool? isCreatorProfile; - - ProfileScreen({super.key, this.creator, this.isCreatorProfile}) - : assert( - isCreatorProfile != true || (creator != null && creator.uid != null), - 'creator and creator.uid are required when isCreatorProfile is true', - ); - - @override - State createState() => _ProfileScreenState(); -} - -class _ProfileScreenState extends State { - @override - void initState() { - super.initState(); - if (widget.isCreatorProfile == true) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - await userProfileController.initializeProfile(widget.creator!.uid!); - }); - } - } - - @override - void dispose() { - super.dispose(); - Get.delete(); - } - - final themeController = Get.find(); - - AuthUser get authController => requireCurrentAuthUser; - - final userProfileController = Get.put(UserProfileController()); - final exploreStoryController = Get.find(); - final friendsController = Get.find(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.profile), - actions: widget.isCreatorProfile == null - ? [ - IconButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FriendRequestsScreen(), - ), - ); - }, - icon: Icon(Icons.notifications), - ), - IconButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FriendsScreen(), - ), - ); - }, - icon: Icon(Icons.groups), - ), - ] - : null, - ), - body: Obx( - () => - userProfileController.isLoadingProfilePage.value || - friendsController.isLoadingFriends.value - ? Center( - child: SizedBox( - height: 200, - width: 200, - child: LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ), - ), - ) - : SingleChildScrollView( - child: Column( - children: [ - Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_10, - horizontal: UiSizes.width_20, - ), - width: double.maxFinite, - child: Column( - children: [ - _buildProfileHeader(context), - _buildEmailVerificationButton(context, authController), - SizedBox(height: UiSizes.height_10), - _buildProfileButtons(context), - ], - ), - ), - SizedBox(height: UiSizes.height_20), - _buildStoriesSection(context), - ], - ), - ), - ), - ); - } - - Widget _buildProfileHeader(BuildContext context) { - return Consumer( - builder: (context, ref, _) { - final controller = ref.watch(authProvider).value?.userOrNull ?? - requireCurrentAuthUser; - return Row( - children: [ - SizedBox(width: UiSizes.width_20), - CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.secondary, - backgroundImage: widget.isCreatorProfile != null - ? NetworkImage(widget.creator!.profileImageUrl ?? '') - : controller.profileImageUrl == null || - controller.profileImageUrl!.isEmpty - ? NetworkImage(themeController.userProfileImagePlaceholderUrl) - : NetworkImage(controller.profileImageUrl ?? ''), - radius: UiSizes.width_66, - ), - SizedBox(width: UiSizes.width_20), - Expanded( - child: MergeSemantics( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.isCreatorProfile == null && - controller.isEmailVerified) - Padding( - padding: EdgeInsets.only(top: 10), - child: Row( - children: [ - Icon( - Icons.verified_user_outlined, - color: Colors.green, - ), - SizedBox(width: 5), - Text( - AppLocalizations.of(context)!.verified, - style: TextStyle(color: Colors.green), - ), - ], - ), - ), - Text( - widget.isCreatorProfile != null - ? widget.creator!.name ?? '' - : controller.displayName.toString(), - style: TextStyle( - fontSize: UiSizes.size_24, - fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis, - ), - ), - Chip( - label: Text( - "@${widget.isCreatorProfile != null ? widget.creator!.userName : controller.userName}", - style: TextStyle( - fontSize: UiSizes.size_14, - overflow: TextOverflow.ellipsis, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.grey.shade300), - ), - ), - Row( - children: [ - Icon(Icons.star, color: Colors.amber), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text( - widget.isCreatorProfile == null - ? (authController.ratingTotal / - authController.ratingCount) - .toStringAsFixed(1) - : widget.creator!.userRating!.toStringAsFixed(1), - ), - ), - ], - ), - InkWell( - onTap: () { - //If current user is only follower or signed in user profile is being viewed then don't route - if (!(userProfileController - .searchedUserFollowers - .length == - 1 && - userProfileController.isFollowingUser.value) && - widget.isCreatorProfile != null) { - //Remove current user from followers list - final sanitizedFollowersList = userProfileController - .searchedUserFollowers - .where((follower) { - return follower.uid != authController.uid; - }) - .toList(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FollowersScreen( - followers: sanitizedFollowersList, - ), - ), - ); - } - }, - child: Row( - children: [ - Icon(Icons.people), - Padding( - padding: const EdgeInsets.only(left: 5), - child: widget.isCreatorProfile == null - ? Text( - authController.followers.length - .toString(), - ) - : Obx( - () => Text( - userProfileController - .searchedUserFollowers - .length - .toString(), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - SizedBox(width: UiSizes.width_20), - ], - ); - }, - ); - } - - Widget _buildEmailVerificationButton( - BuildContext context, - AuthUser controller, - ) { - if (widget.isCreatorProfile != null || controller.isEmailVerified) { - return const SizedBox.shrink(); - } - - return Container( - margin: EdgeInsets.only(top: UiSizes.height_10), - width: double.maxFinite, - child: OutlinedButton( - onPressed: () { - loadingDialog(context); - rootContainer - .read(emailVerifyProvider.notifier) - .sendOtp(email: controller.email); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.verified_user_outlined), - SizedBox(width: 10), - Text(AppLocalizations.of(context)!.verifyEmail), - ], - ), - ), - ); - } - - Widget _buildProfileButtons(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Row( - children: [ - Expanded( - child: Obx(() { - return ElevatedButton( - onPressed: () { - if (widget.isCreatorProfile != null) { - if (userProfileController.isFollowingUser.value) { - userProfileController.unfollowCreator(); - } else { - userProfileController.followCreator(widget.creator!.uid!); - } - } else { - context.push(RoutePaths.editProfile); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: userProfileController.isFollowingUser.value - ? colorScheme.secondary - : colorScheme.primary, - foregroundColor: userProfileController.isFollowingUser.value - ? colorScheme.onSecondary - : colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - side: BorderSide(color: colorScheme.primary), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - widget.isCreatorProfile != null - ? userProfileController.isFollowingUser.value - ? Icons.done - : Icons.add - : Icons.edit, - color: colorScheme.onPrimary, - ), - const SizedBox(width: 8), - Text( - widget.isCreatorProfile != null - ? userProfileController.isFollowingUser.value - ? AppLocalizations.of(context)!.following - : AppLocalizations.of(context)!.follow - : AppLocalizations.of(context)!.editProfile, - style: TextStyle(color: colorScheme.onPrimary), - ), - ], - ), - ); - }), - ), - const SizedBox(width: 10), - - widget.isCreatorProfile == null - ? SizedBox( - height: 50, - width: 50, - child: ElevatedButton( - onPressed: () { - context.push(RoutePaths.settings); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: Icon(Icons.settings, color: colorScheme.onPrimary), - ), - ) - : Expanded( - child: Obx(() { - final FriendsModel? friendModel = - friendsController.friendsList.firstWhereOrNull( - (friend) => - (friend.senderId == widget.creator!.uid || - friend.recieverId == widget.creator!.uid), - ) ?? - friendsController.friendRequestsList.firstWhereOrNull( - (friend) => - (friend.senderId == widget.creator!.uid || - friend.recieverId == widget.creator!.uid), - ); - return ElevatedButton( - onPressed: () async { - if (friendModel == null) { - await friendsController.sendFriendRequest( - widget.creator!.uid!, - widget.creator!.profileImageUrl!, - widget.creator!.userName!, - widget.creator!.name!, - widget.creator!.userRating!, - ); - - customSnackbar( - AppLocalizations.of(context)!.friendRequestSent, - AppLocalizations.of( - context, - )!.friendRequestSentTo(widget.creator!.name!), - LogType.success, - ); - } else { - if (friendModel.requestStatus == - FriendRequestStatus.sent && - friendModel.senderId == widget.creator!.uid) { - await friendsController.acceptFriendRequest( - friendModel, - ); - customSnackbar( - AppLocalizations.of(context)!.friendRequestAccepted, - AppLocalizations.of( - context, - )!.friendRequestAcceptedTo(widget.creator!.name!), - LogType.success, - ); - } else { - try { - await friendsController.removeFriend(friendModel); - } catch (e) { - log(e.toString()); - } - customSnackbar( - AppLocalizations.of( - context, - )!.friendRequestCancelled, - AppLocalizations.of( - context, - )!.friendRequestCancelledTo(widget.creator!.name!), - LogType.info, - ); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: friendModel != null - ? (friendModel.requestStatus == - FriendRequestStatus.sent - ? colorScheme.primary - : colorScheme.secondary) - : colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - side: BorderSide(color: colorScheme.primary), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - friendModel != null - ? (friendModel.requestStatus == - FriendRequestStatus.sent - ? Icons.check - : Icons.people) - : Icons.add, - color: colorScheme.onPrimary, - ), - const SizedBox(width: 8), - Text( - friendModel != null - ? (friendModel.requestStatus == - FriendRequestStatus.sent - ? friendModel.senderId == - widget.creator!.uid - ? AppLocalizations.of(context)!.accept - : AppLocalizations.of( - context, - )!.requested - : AppLocalizations.of(context)!.friends) - : AppLocalizations.of(context)!.addFriend, - style: TextStyle(color: colorScheme.onPrimary), - ), - ], - ), - ); - }), - ), - ], - ); - } - - Widget _buildStoriesSection(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - padding: EdgeInsets.only(left: UiSizes.width_20), - width: double.maxFinite, - child: Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - widget.isCreatorProfile != null - ? AppLocalizations.of(context)!.userCreatedStories - : AppLocalizations.of(context)!.yourStories, - style: TextStyle( - fontSize: UiSizes.size_16, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ), - SizedBox(height: UiSizes.height_5), - Obx( - () => _buildStoriesList( - context, - widget.isCreatorProfile != null - ? userProfileController.searchedUserStories - : exploreStoryController.userCreatedStories, - widget.isCreatorProfile != null - ? AppLocalizations.of(context)!.userNoStories - : AppLocalizations.of(context)!.youNoStories, - ), - ), - SizedBox(height: UiSizes.height_10), - Align( - alignment: Alignment.centerLeft, - child: Text( - widget.isCreatorProfile != null - ? AppLocalizations.of(context)!.userLikedStories - : AppLocalizations.of(context)!.yourLikedStories, - style: TextStyle( - fontSize: UiSizes.size_16, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ), - SizedBox(height: UiSizes.height_5), - Obx( - () => _buildStoriesList( - context, - widget.isCreatorProfile != null - ? userProfileController.searchedUserLikedStories - : exploreStoryController.userLikedStories, - widget.isCreatorProfile != null - ? AppLocalizations.of(context)!.userNoLikedStories - : AppLocalizations.of(context)!.youNoLikedStories, - ), - ), - ], - ), - ); - } - - Widget _buildStoriesList( - BuildContext context, - List stories, - String noStoryTextToShow, - ) { - final colorScheme = Theme.of(context).colorScheme; - - return SizedBox( - height: UiSizes.height_200, - child: stories.isNotEmpty - ? ListView.builder( - itemCount: stories.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - return StoryItem(story: stories[index]); - }, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset(height: 150, width: 150, AppImages.emptyBoxImage), - const SizedBox(height: 5), - Text( - noStoryTextToShow, - style: TextStyle(color: colorScheme.onSurface), - ), - ], - ), - ); - } -} - -class StoryItem extends StatelessWidget { - const StoryItem({super.key, required this.story}); - - final Story story; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => StoryScreen(story: story)), - ); - }, - child: Container( - width: UiSizes.height_140, - margin: EdgeInsets.only(right: UiSizes.width_10), - child: Column( - children: [ - Container( - height: UiSizes.height_140, - width: UiSizes.height_140, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: colorScheme.surfaceContainerHighest, - image: DecorationImage( - image: NetworkImage(story.coverImageUrl), - fit: BoxFit.cover, - ), - ), - ), - SizedBox(height: UiSizes.height_5), - Text( - story.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: UiSizes.size_16, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - Text( - story.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - //overflow: TextOverflow.ellipsis, - fontSize: UiSizes.size_12, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/views/widgets/filtered_list_tile.dart b/lib/views/widgets/filtered_list_tile.dart index 67d15c98..b71c63bc 100644 --- a/lib/views/widgets/filtered_list_tile.dart +++ b/lib/views/widgets/filtered_list_tile.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/models/resonate_user.dart'; import 'package:resonate/models/story.dart'; +import 'package:resonate/features/profile/view/pages/profile_page.dart'; import 'package:resonate/views/screens/create_story_screen.dart'; -import 'package:resonate/views/screens/profile_screen.dart'; import 'package:resonate/views/screens/story_screen.dart'; class FilteredListTile extends StatelessWidget { @@ -37,7 +37,7 @@ class FilteredListTile extends StatelessWidget { context, MaterialPageRoute( builder: (context) => - ProfileScreen(creator: user, isCreatorProfile: true), + ProfilePage(creator: user, isCreatorProfile: true), ), ); } diff --git a/lib/views/widgets/friend_request_list_tile.dart b/lib/views/widgets/friend_request_list_tile.dart index c875c50f..45721c85 100644 --- a/lib/views/widgets/friend_request_list_tile.dart +++ b/lib/views/widgets/friend_request_list_tile.dart @@ -7,7 +7,7 @@ import 'package:resonate/controllers/friends_controller.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/models/friends_model.dart'; import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/screens/profile_screen.dart'; +import 'package:resonate/features/profile/view/pages/profile_page.dart'; import 'package:resonate/views/widgets/snackbar.dart'; class FriendsListTile extends StatelessWidget { @@ -31,7 +31,7 @@ class FriendsListTile extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => ProfileScreen( + builder: (context) => ProfilePage( creator: userIsSender ? friendModel.recieverToResonateUserForRequestsPage() : friendModel.senderToResonateUserForRequestsPage(), diff --git a/lib/views/widgets/profile_avatar.dart b/lib/views/widgets/profile_avatar.dart index e6d82242..52f17a49 100644 --- a/lib/views/widgets/profile_avatar.dart +++ b/lib/views/widgets/profile_avatar.dart @@ -13,7 +13,7 @@ Widget profileAvatar(BuildContext context) { return Semantics( label: AppLocalizations.of(context)!.userProfile, child: GestureDetector( - onTap: () => context.go(RoutePaths.profile), + onTap: () => context.push(RoutePaths.profile), child: Padding( padding: EdgeInsets.symmetric( horizontal: UiSizes.width_10, diff --git a/test/controllers/change_email_controller_test.dart b/test/controllers/change_email_controller_test.dart deleted file mode 100644 index 8c99f672..00000000 --- a/test/controllers/change_email_controller_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:appwrite/appwrite.dart' hide Locale; -import 'package:appwrite/models.dart' hide Locale; -import 'package:flutter/material.dart' hide Row; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get_navigation/src/root/get_material_app.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/change_email_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/constants.dart'; - -import '../helpers/test_root_container.dart'; -import 'change_email_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB, Account]) -void main() { - late MockTablesDB mockTablesDB; - late MockAccount mockAccount; - late FakeAuthRepository fakeRepo; - late ChangeEmailController controller; - - final User mockUser = User( - $id: '123', - name: 'Test User', - email: 'test2@test.com', - emailVerification: true, - prefs: Preferences(data: {'isUserProfileComplete': true}), - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - accessedAt: DateTime.now().toIso8601String(), - registration: DateTime.now().toIso8601String(), - phone: '1234567890', - phoneVerification: false, - mfa: false, - passwordUpdate: DateTime.now().toIso8601String(), - status: true, - password: 'password', - labels: [], - hash: 'Argon2', - targets: [], - hashOptions: {}, - ); - - setUp(() async { - mockTablesDB = MockTablesDB(); - mockAccount = MockAccount(); - fakeRepo = FakeAuthRepository( - AuthState.authenticated(fakeAuthUser()), - ); - - await installTestRootContainer(authRepository: fakeRepo); - - controller = ChangeEmailController( - tables: mockTablesDB, - account: mockAccount, - ); - - when( - mockTablesDB.listRows( - databaseId: userDatabaseID, - tableId: usernameTableID, - queries: [Query.equal('email', 'test2@test.com')], - ), - ).thenAnswer((_) async => RowList(total: 0, rows: [])); - - when( - mockTablesDB.updateRow( - databaseId: anyNamed('databaseId'), - tableId: anyNamed('tableId'), - rowId: anyNamed('rowId'), - data: anyNamed('data'), - ), - ).thenAnswer((invocation) async { - return Row( - $id: invocation.namedArguments[#rowId] as String, - $tableId: invocation.namedArguments[#tableId] as String, - $databaseId: invocation.namedArguments[#databaseId] as String, - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: Map.from( - invocation.namedArguments[#data] as Map, - ), - ); - }); - - when( - mockAccount.updateEmail( - email: 'test2@test.com', - password: 'anyPassword', - ), - ).thenAnswer((_) async => mockUser); - }); - - test('isEmailAvailable returns true when no matching row exists', () async { - final result = await controller.isEmailAvailable('test2@test.com'); - expect(result, true); - }); - - testWidgets( - 'changeEmailInDatabases updates both rows and refreshes auth', - (tester) async { - await tester.pumpWidget(GetMaterialApp(home: Container())); - await tester.pumpAndSettle(); - - final loadCountBefore = fakeRepo.loadCount; - final result = await controller.changeEmailInDatabases( - 'test2@test.com', - tester.element(find.byType(Container)), - ); - - verify( - mockTablesDB.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: '123', - data: {'email': 'test2@test.com'}, - ), - ).called(1); - verify( - mockTablesDB.updateRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: 'TestUser', - data: {'email': 'test2@test.com'}, - ), - ).called(1); - expect(result, true); - // refresh() should have called loadCurrentUser exactly once more than - // the initial container-build call. - expect(fakeRepo.loadCount, loadCountBefore + 1); - }, - ); - - testWidgets('changeEmailInAuth calls account.updateEmail', (tester) async { - await tester.pumpWidget(GetMaterialApp(home: Container())); - await tester.pumpAndSettle(); - final result = await controller.changeEmailInAuth( - 'test2@test.com', - 'anyPassword', - tester.element(find.byType(Container)), - ); - expect(result, true); - verify( - mockAccount.updateEmail(email: 'test2@test.com', password: 'anyPassword'), - ).called(1); - }); - - testWidgets('changeEmail runs the full happy path', (tester) async { - await tester.pumpWidget( - GetMaterialApp( - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [Locale('en'), Locale('hi')], - home: Scaffold( - body: Form( - key: controller.changeEmailFormKey, - child: Builder( - builder: (context) => ElevatedButton( - onPressed: () async { - await controller.changeEmail(context); - }, - child: const Text('Test'), - ), - ), - ), - ), - ), - ); - controller.passwordController.text = 'anyPassword'; - controller.emailController.text = 'test2@test.com'; - await tester.tap(find.text('Test')); - await tester.pumpAndSettle(); - verify( - mockAccount.updateEmail(email: 'test2@test.com', password: 'anyPassword'), - ).called(1); - await tester.pumpAndSettle(const Duration(seconds: 4)); - verify( - mockTablesDB.updateRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: 'TestUser', - data: {'email': 'test2@test.com'}, - ), - ).called(1); - expect(controller.isLoading.value, false); - }); -} diff --git a/test/controllers/edit_profile_controller_test.dart b/test/controllers/edit_profile_controller_test.dart deleted file mode 100644 index aef9bc37..00000000 --- a/test/controllers/edit_profile_controller_test.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/edit_profile_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/constants.dart'; - -import '../helpers/test_root_container.dart'; -import 'edit_profile_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB, Storage, ThemeController]) -void main() { - late EditProfileController editProfileController; - late MockTablesDB mockTablesDB; - - setUp(() async { - // Install a test root container with an authenticated user that matches - // the data the original tests assumed. - await installTestRootContainer( - authState: AuthState.authenticated( - fakeAuthUser( - displayName: 'Test User', - userName: 'testuser', - profileImageUrl: 'https://example.com/image.jpg', - profileImageID: 'image123', - ), - ), - ); - - mockTablesDB = MockTablesDB(); - editProfileController = EditProfileController( - themeController: MockThemeController(), - storage: MockStorage(), - tables: mockTablesDB, - ); - editProfileController.onInit(); - - // Current user's own username → row exists → DB returns it. - when( - mockTablesDB.getRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: 'testuser', - ), - ).thenAnswer( - (_) async => Row( - $id: 'testuser', - $tableId: usernameTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: {'email': 'test@test.com'}, - ), - ); - // A different username that is taken by someone else. - when( - mockTablesDB.getRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: 'anotheruser', - ), - ).thenAnswer( - (_) async => Row( - $id: 'anotheruser', - $tableId: usernameTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: {'email': 'another@test.com'}, - ), - ); - }); - - test('onInit copies displayName and userName from auth state', () { - editProfileController.onInit(); - expect(editProfileController.oldDisplayName, 'Test User'); - expect(editProfileController.oldUsername, 'testuser'); - expect(editProfileController.nameController.text, 'Test User'); - expect(editProfileController.usernameController.text, 'testuser'); - }); - - test('isUsernameAvailable: own username true, taken username false', - () async { - expect(await editProfileController.isUsernameAvailable('testuser'), true); - expect( - await editProfileController.isUsernameAvailable('anotheruser'), - false, - ); - }); - - test('isUsernameChanged compares against the original username', () { - editProfileController.usernameController.text = 'newuser'; - expect(editProfileController.isUsernameChanged(), true); - editProfileController.usernameController.text = 'testuser'; - expect(editProfileController.isUsernameChanged(), false); - }); - - test('isDisplayNameChanged compares against the original display name', () { - editProfileController.nameController.text = 'New User'; - expect(editProfileController.isDisplayNameChanged(), true); - editProfileController.nameController.text = 'Test User'; - expect(editProfileController.isDisplayNameChanged(), false); - }); - - test( - 'removeProfilePicture sets removeImage=true and clears profileImagePath', - () { - // The auth user installed in setUp already has a profileImageUrl, so - // removeProfilePicture should flip removeImage to true. - editProfileController.removeProfilePicture(); - expect(editProfileController.removeImage, true); - expect(editProfileController.profileImagePath, null); - }, - ); - - test('isProfilePictureChanged: path set OR removeImage flag', () { - editProfileController.profileImagePath = 'path/to/image.jpg'; - expect(editProfileController.isProfilePictureChanged(), true); - - editProfileController.profileImagePath = null; - editProfileController.removeImage = false; - expect(editProfileController.isProfilePictureChanged(), false); - - editProfileController.removeImage = true; - expect(editProfileController.isProfilePictureChanged(), true); - }); - - test('isThereUnsavedChanges aggregates the three change checks', () { - editProfileController.nameController.text = 'New User'; - editProfileController.usernameController.text = 'newuser'; - editProfileController.profileImagePath = 'path/to/image.jpg'; - expect(editProfileController.isThereUnsavedChanges(), true); - - editProfileController.nameController.text = 'Test User'; - editProfileController.usernameController.text = 'testuser'; - editProfileController.profileImagePath = null; - editProfileController.removeImage = false; - expect(editProfileController.isThereUnsavedChanges(), false); - }); -} diff --git a/test/controllers/edit_profile_controller_test.mocks.dart b/test/controllers/edit_profile_controller_test.mocks.dart deleted file mode 100644 index 601f1695..00000000 --- a/test/controllers/edit_profile_controller_test.mocks.dart +++ /dev/null @@ -1,882 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/edit_profile_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; -import 'dart:typed_data' as _i9; -import 'dart:ui' as _i14; - -import 'package:appwrite/appwrite.dart' as _i5; -import 'package:appwrite/enums.dart' as _i10; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/input_file.dart' as _i7; -import 'package:appwrite/src/upload_progress.dart' as _i8; -import 'package:get/get.dart' as _i4; -import 'package:get/get_state_manager/src/simple/list_notifier.dart' as _i13; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i12; -import 'package:resonate/themes/theme_controller.dart' as _i11; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFileList_5 extends _i1.SmartFake implements _i3.FileList { - _FakeFileList_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFile_6 extends _i1.SmartFake implements _i3.File { - _FakeFile_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRx_7 extends _i1.SmartFake implements _i4.Rx { - _FakeRx_7(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeInternalFinalCallback_8 extends _i1.SmartFake - implements _i4.InternalFinalCallback { - _FakeInternalFinalCallback_8(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [TablesDB]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i5.TablesDB { - MockTablesDB() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i6.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i6.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i6.Future<_i3.TransactionList>); - - @override - _i6.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i6.Future<_i3.RowList>.value( - _FakeRowList_3( - this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i6.Future<_i3.RowList>); - - @override - _i6.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); -} - -/// A class which mocks [Storage]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockStorage extends _i1.Mock implements _i5.Storage { - MockStorage() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i6.Future<_i3.FileList> listFiles({ - required String? bucketId, - List? queries, - String? search, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listFiles, [], { - #bucketId: bucketId, - #queries: queries, - #search: search, - #total: total, - }), - returnValue: _i6.Future<_i3.FileList>.value( - _FakeFileList_5( - this, - Invocation.method(#listFiles, [], { - #bucketId: bucketId, - #queries: queries, - #search: search, - #total: total, - }), - ), - ), - ) - as _i6.Future<_i3.FileList>); - - @override - _i6.Future<_i3.File> createFile({ - required String? bucketId, - required String? fileId, - required _i7.InputFile? file, - List? permissions, - dynamic Function(_i8.UploadProgress)? onProgress, - }) => - (super.noSuchMethod( - Invocation.method(#createFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #file: file, - #permissions: permissions, - #onProgress: onProgress, - }), - returnValue: _i6.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#createFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #file: file, - #permissions: permissions, - #onProgress: onProgress, - }), - ), - ), - ) - as _i6.Future<_i3.File>); - - @override - _i6.Future<_i3.File> getFile({ - required String? bucketId, - required String? fileId, - }) => - (super.noSuchMethod( - Invocation.method(#getFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - returnValue: _i6.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#getFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - ), - ), - ) - as _i6.Future<_i3.File>); - - @override - _i6.Future<_i3.File> updateFile({ - required String? bucketId, - required String? fileId, - String? name, - List? permissions, - }) => - (super.noSuchMethod( - Invocation.method(#updateFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #name: name, - #permissions: permissions, - }), - returnValue: _i6.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#updateFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #name: name, - #permissions: permissions, - }), - ), - ), - ) - as _i6.Future<_i3.File>); - - @override - _i6.Future deleteFile({ - required String? bucketId, - required String? fileId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i9.Uint8List> getFileDownload({ - required String? bucketId, - required String? fileId, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFileDownload, [], { - #bucketId: bucketId, - #fileId: fileId, - #token: token, - }), - returnValue: _i6.Future<_i9.Uint8List>.value(_i9.Uint8List(0)), - ) - as _i6.Future<_i9.Uint8List>); - - @override - _i6.Future<_i9.Uint8List> getFilePreview({ - required String? bucketId, - required String? fileId, - int? width, - int? height, - _i10.ImageGravity? gravity, - int? quality, - int? borderWidth, - String? borderColor, - int? borderRadius, - double? opacity, - int? rotation, - String? background, - _i10.ImageFormat? output, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFilePreview, [], { - #bucketId: bucketId, - #fileId: fileId, - #width: width, - #height: height, - #gravity: gravity, - #quality: quality, - #borderWidth: borderWidth, - #borderColor: borderColor, - #borderRadius: borderRadius, - #opacity: opacity, - #rotation: rotation, - #background: background, - #output: output, - #token: token, - }), - returnValue: _i6.Future<_i9.Uint8List>.value(_i9.Uint8List(0)), - ) - as _i6.Future<_i9.Uint8List>); - - @override - _i6.Future<_i9.Uint8List> getFileView({ - required String? bucketId, - required String? fileId, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFileView, [], { - #bucketId: bucketId, - #fileId: fileId, - #token: token, - }), - returnValue: _i6.Future<_i9.Uint8List>.value(_i9.Uint8List(0)), - ) - as _i6.Future<_i9.Uint8List>); -} - -/// A class which mocks [ThemeController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockThemeController extends _i1.Mock implements _i11.ThemeController { - MockThemeController() { - _i1.throwOnMissingStub(this); - } - - @override - _i4.Rx get currentTheme => - (super.noSuchMethod( - Invocation.getter(#currentTheme), - returnValue: _FakeRx_7( - this, - Invocation.getter(#currentTheme), - ), - ) - as _i4.Rx); - - @override - _i4.Rx get currentThemePlaceHolder => - (super.noSuchMethod( - Invocation.getter(#currentThemePlaceHolder), - returnValue: _FakeRx_7( - this, - Invocation.getter(#currentThemePlaceHolder), - ), - ) - as _i4.Rx); - - @override - String get getCurrentTheme => - (super.noSuchMethod( - Invocation.getter(#getCurrentTheme), - returnValue: _i12.dummyValue( - this, - Invocation.getter(#getCurrentTheme), - ), - ) - as String); - - @override - String get userProfileImagePlaceholderUrl => - (super.noSuchMethod( - Invocation.getter(#userProfileImagePlaceholderUrl), - returnValue: _i12.dummyValue( - this, - Invocation.getter(#userProfileImagePlaceholderUrl), - ), - ) - as String); - - @override - set currentTheme(_i4.Rx? value) => super.noSuchMethod( - Invocation.setter(#currentTheme, value), - returnValueForMissingStub: null, - ); - - @override - set currentThemePlaceHolder(_i4.Rx? value) => super.noSuchMethod( - Invocation.setter(#currentThemePlaceHolder, value), - returnValueForMissingStub: null, - ); - - @override - _i4.InternalFinalCallback get onStart => - (super.noSuchMethod( - Invocation.getter(#onStart), - returnValue: _FakeInternalFinalCallback_8( - this, - Invocation.getter(#onStart), - ), - ) - as _i4.InternalFinalCallback); - - @override - _i4.InternalFinalCallback get onDelete => - (super.noSuchMethod( - Invocation.getter(#onDelete), - returnValue: _FakeInternalFinalCallback_8( - this, - Invocation.getter(#onDelete), - ), - ) - as _i4.InternalFinalCallback); - - @override - bool get initialized => - (super.noSuchMethod(Invocation.getter(#initialized), returnValue: false) - as bool); - - @override - bool get isClosed => - (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) - as bool); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - int get listeners => - (super.noSuchMethod(Invocation.getter(#listeners), returnValue: 0) - as int); - - @override - void onInit() => super.noSuchMethod( - Invocation.method(#onInit, []), - returnValueForMissingStub: null, - ); - - @override - void updateTheme(String? theme) => super.noSuchMethod( - Invocation.method(#updateTheme, [theme]), - returnValueForMissingStub: null, - ); - - @override - void updateUserProfileImagePlaceholderUrlOnTheme() => super.noSuchMethod( - Invocation.method(#updateUserProfileImagePlaceholderUrlOnTheme, []), - returnValueForMissingStub: null, - ); - - @override - void setTheme(String? newTheme) => super.noSuchMethod( - Invocation.method(#setTheme, [newTheme]), - returnValueForMissingStub: null, - ); - - @override - void update([List? ids, bool? condition = true]) => - super.noSuchMethod( - Invocation.method(#update, [ids, condition]), - returnValueForMissingStub: null, - ); - - @override - void onReady() => super.noSuchMethod( - Invocation.method(#onReady, []), - returnValueForMissingStub: null, - ); - - @override - void onClose() => super.noSuchMethod( - Invocation.method(#onClose, []), - returnValueForMissingStub: null, - ); - - @override - void $configureLifeCycle() => super.noSuchMethod( - Invocation.method(#$configureLifeCycle, []), - returnValueForMissingStub: null, - ); - - @override - _i13.Disposer addListener(_i13.GetStateUpdate? listener) => - (super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValue: () {}, - ) - as _i13.Disposer); - - @override - void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void refresh() => super.noSuchMethod( - Invocation.method(#refresh, []), - returnValueForMissingStub: null, - ); - - @override - void refreshGroup(Object? id) => super.noSuchMethod( - Invocation.method(#refreshGroup, [id]), - returnValueForMissingStub: null, - ); - - @override - void notifyChildrens() => super.noSuchMethod( - Invocation.method(#notifyChildrens, []), - returnValueForMissingStub: null, - ); - - @override - void removeListenerId(Object? id, _i14.VoidCallback? listener) => - super.noSuchMethod( - Invocation.method(#removeListenerId, [id, listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - _i13.Disposer addListenerId(Object? key, _i13.GetStateUpdate? listener) => - (super.noSuchMethod( - Invocation.method(#addListenerId, [key, listener]), - returnValue: () {}, - ) - as _i13.Disposer); - - @override - void disposeId(Object? id) => super.noSuchMethod( - Invocation.method(#disposeId, [id]), - returnValueForMissingStub: null, - ); -} diff --git a/test/controllers/user_profile_controller_test.dart b/test/controllers/user_profile_controller_test.dart deleted file mode 100644 index 974b67ce..00000000 --- a/test/controllers/user_profile_controller_test.dart +++ /dev/null @@ -1,320 +0,0 @@ -import 'dart:ui'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/user_profile_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/story_category.dart'; - -import '../helpers/test_root_container.dart'; -import 'user_profile_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB, FirebaseMessaging]) -List mockStoryDocuments = [ - Row( - $id: 'doc1', - $tableId: storyTableId, - $databaseId: storyDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: { - 'title': 'Story 1', - 'description': 'Description of Story 1', - 'category': 'comedy', - 'coverImgUrl': 'https://example.com/image1.jpg', - 'creatorId': 'id1', - 'creatorName': 'Creator 1', - 'creatorImgUrl': 'https://example.com/profile1.jpg', - 'likes': 10, - 'tintColor': '0000FF', - 'playDuration': 120, - }, - ), - Row( - $id: 'doc2', - $tableId: storyTableId, - $databaseId: storyDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - $sequence: 1, - data: { - 'title': 'Story 2', - 'description': 'Description of Story 2', - 'category': 'thriller', - 'coverImgUrl': 'https://example.com/image2.jpg', - 'creatorId': 'id2', - 'creatorName': 'Creator 2', - 'creatorImgUrl': 'https://example.com/profile2.jpg', - 'likes': 10, - 'tintColor': '0000FF', - 'playDuration': 120, - }, - ), -]; - -final Row mockSearchedUserDocument = Row( - $id: 'doc1', - $tableId: usersTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: { - 'name': 'Test User 1', - 'dob': '2000-01-01', - 'username': 'testuser1', - 'profileImageUrl': 'https://example.com/profile1.jpg', - 'email': 'testuser1@example.com', - 'profileImageId': 'profileImageId1', - 'ratingCount': 7, - 'ratingTotal': 25, - 'followers': [ - { - 'followerUserId': 'id2', - 'followerUsername': 'testu2', - 'followerName': 'Test User 2', - 'followerFCMToken': 'testToken2', - 'followerProfileImageUrl': 'https://example.com/profile2.jpg', - 'followerRating': 5, - '\$id': 'doc2', - }, - { - 'followerUserId': 'id3', - 'followerUsername': 'testu3', - 'followerName': 'Test User 3', - 'followerFCMToken': 'testToken3', - 'followerProfileImageUrl': 'https://example.com/profile3.jpg', - 'followerRating': 5, - '\$id': 'doc3', - }, - ], - }, -); - -final FollowerUserModel mockFollowerUserModel = FollowerUserModel( - docId: 'fdocid1', - uid: 'id2', - username: 'testu2', - profileImageUrl: 'https://example.com/profile2.jpg', - name: 'Test User 2', - fcmToken: 'testToken2', - followingUserId: 'id1', - followerRating: 5, -); - -final Row mockFollowerDocument = Row( - $id: 'fdocid1', - $tableId: followersTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - $sequence: 0, - data: { - 'followerUserId': 'id2', - 'followerUsername': 'testu2', - 'followerName': 'Test User 2', - 'followerFCMToken': 'testToken2', - 'followerProfileImageUrl': 'https://example.com/profile2.jpg', - 'followerRating': 5, - 'followingUserId': 'id1', - }, -); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late MockTablesDB tablesDB; - late MockFirebaseMessaging mockFirebaseMessaging; - late UserProfileController userProfileController; - - setUp(() async { - Get.testMode = true; - tablesDB = MockTablesDB(); - mockFirebaseMessaging = MockFirebaseMessaging(); - - // followCreator uses rootContainer.read(firebaseMessagingProvider).getToken(), - // so we override the FCM provider with our mock. - await installTestRootContainer( - authState: AuthState.authenticated( - fakeAuthUser( - uid: 'id2', - userName: 'testu2', - profileImageUrl: 'https://example.com/profile2.jpg', - displayName: 'Test User 2', - ratingTotal: 25.0, - ratingCount: 5, - ), - ), - messaging: mockFirebaseMessaging, - ); - - userProfileController = UserProfileController(tablesDB: tablesDB); - - when( - tablesDB.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', 'id2')], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 1), - () => RowList(total: 1, rows: [mockStoryDocuments[1]]), - ), - ); - when( - tablesDB.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', 'id1')], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 1), - () => RowList(total: 1, rows: [mockStoryDocuments[0]]), - ), - ); - when( - tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: 'id1', - queries: [Query.select(['*', 'followers.*'])], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 1), - () => mockSearchedUserDocument, - ), - ); - when( - tablesDB.createRow( - databaseId: userDatabaseID, - tableId: followersTableID, - rowId: anyNamed('rowId'), - data: mockFollowerUserModel.toJson(), - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 1), - () => mockFollowerDocument, - ), - ); - when(mockFirebaseMessaging.getToken()) - .thenAnswer((_) async => 'testToken2'); - }); - - test('convertAppwriteDocListToStoryList maps fields correctly', () async { - final stories = await userProfileController - .convertAppwriteDocListToStoryList(mockStoryDocuments); - expect(stories.length, 2); - expect(stories[0].title, 'Story 1'); - expect(stories[1].title, 'Story 2'); - expect(stories[0].storyId, 'doc1'); - expect(stories[1].storyId, 'doc2'); - expect(stories[0].category, StoryCategory.comedy); - expect(stories[1].category, StoryCategory.thriller); - expect(stories[0].creatorId, 'id1'); - expect(stories[1].creatorId, 'id2'); - expect(stories[0].creatorName, 'Creator 1'); - expect(stories[1].creatorName, 'Creator 2'); - expect(stories[0].creatorImgUrl, 'https://example.com/profile1.jpg'); - expect(stories[1].creatorImgUrl, 'https://example.com/profile2.jpg'); - expect(stories[0].likesCount.value, 10); - expect(stories[1].likesCount.value, 10); - expect(stories[0].playDuration, 120); - expect(stories[1].playDuration, 120); - expect(stories[0].tintColor, const Color(0xff0000FF)); - expect(stories[1].tintColor, const Color(0xff0000FF)); - expect(stories[0].chapters, isEmpty); - expect(stories[1].chapters, isEmpty); - expect(stories[0].userIsCreator, false); - expect(stories[1].userIsCreator, false); - }); - - test('fetchUserCreatedStories populates searchedUserStories', () async { - await userProfileController.fetchUserCreatedStories('id1'); - expect(userProfileController.searchedUserStories.length, 1); - expect(userProfileController.searchedUserStories[0].storyId, 'doc1'); - expect(userProfileController.searchedUserStories[0].title, 'Story 1'); - expect( - userProfileController.searchedUserStories[0].description, - 'Description of Story 1', - ); - expect(userProfileController.searchedUserStories[0].userIsCreator, false); - }); - - test( - 'fetchUserFollowers populates list and sets isFollowingUser correctly', - () async { - await userProfileController.fetchUserFollowers('id1'); - expect(userProfileController.searchedUserFollowers.length, 2); - expect( - userProfileController.searchedUserFollowers[0].name, - 'Test User 2', - ); - expect( - userProfileController.searchedUserFollowers[1].name, - 'Test User 3', - ); - expect( - userProfileController.searchedUserFollowers[0].profileImageUrl, - 'https://example.com/profile2.jpg', - ); - expect( - userProfileController.searchedUserFollowers[1].profileImageUrl, - 'https://example.com/profile3.jpg', - ); - expect( - userProfileController.searchedUserFollowers[0].username, - 'testu2', - ); - expect( - userProfileController.searchedUserFollowers[1].username, - 'testu3', - ); - expect(userProfileController.searchedUserFollowers[0].docId, 'doc2'); - expect(userProfileController.searchedUserFollowers[1].docId, 'doc3'); - expect(userProfileController.searchedUserFollowers[0].uid, 'id2'); - expect(userProfileController.searchedUserFollowers[1].uid, 'id3'); - expect(userProfileController.isFollowingUser.value, true); - expect(userProfileController.followerDocumentId, 'doc2'); - }, - ); - - test('followCreator adds the current user to the followers list', () async { - await userProfileController.followCreator('id1'); - expect(userProfileController.isFollowingUser.value, true); - expect(userProfileController.searchedUserFollowers.length, 1); - expect(userProfileController.searchedUserFollowers[0].uid, 'id2'); - expect(userProfileController.searchedUserFollowers[0].username, 'testu2'); - expect( - userProfileController.searchedUserFollowers[0].profileImageUrl, - 'https://example.com/profile2.jpg', - ); - expect( - userProfileController.searchedUserFollowers[0].name, - 'Test User 2', - ); - expect( - userProfileController.searchedUserFollowers[0].fcmToken, - 'testToken2', - ); - expect( - userProfileController.searchedUserFollowers[0].followingUserId, - 'id1', - ); - expect(userProfileController.searchedUserFollowers[0].followerRating, 5); - }); -} diff --git a/test/controllers/user_profile_controller_test.mocks.dart b/test/controllers/user_profile_controller_test.mocks.dart deleted file mode 100644 index 3021415e..00000000 --- a/test/controllers/user_profile_controller_test.mocks.dart +++ /dev/null @@ -1,637 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/user_profile_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i7; - -import 'package:appwrite/appwrite.dart' as _i6; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:firebase_core/firebase_core.dart' as _i4; -import 'package:firebase_messaging/firebase_messaging.dart' as _i8; -import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' - as _i5; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFirebaseApp_5 extends _i1.SmartFake implements _i4.FirebaseApp { - _FakeFirebaseApp_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeNotificationSettings_6 extends _i1.SmartFake - implements _i5.NotificationSettings { - _FakeNotificationSettings_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [TablesDB]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i6.TablesDB { - MockTablesDB() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i7.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i7.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i7.Future<_i3.TransactionList>); - - @override - _i7.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i7.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i7.Future<_i3.Transaction>); - - @override - _i7.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Transaction>); - - @override - _i7.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i7.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i7.Future<_i3.Transaction>); - - @override - _i7.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i7.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i7.Future<_i3.Transaction>); - - @override - _i7.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i7.Future<_i3.RowList>.value( - _FakeRowList_3( - this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i7.Future<_i3.RowList>); - - @override - _i7.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); - - @override - _i7.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); - - @override - _i7.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); - - @override - _i7.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); - - @override - _i7.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), - returnValue: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); - - @override - _i7.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i7.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i7.Future<_i3.Row>); -} - -/// A class which mocks [FirebaseMessaging]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFirebaseMessaging extends _i1.Mock implements _i8.FirebaseMessaging { - MockFirebaseMessaging() { - _i1.throwOnMissingStub(this); - } - - @override - _i4.FirebaseApp get app => - (super.noSuchMethod( - Invocation.getter(#app), - returnValue: _FakeFirebaseApp_5(this, Invocation.getter(#app)), - ) - as _i4.FirebaseApp); - - @override - bool get isAutoInitEnabled => - (super.noSuchMethod( - Invocation.getter(#isAutoInitEnabled), - returnValue: false, - ) - as bool); - - @override - _i7.Stream get onTokenRefresh => - (super.noSuchMethod( - Invocation.getter(#onTokenRefresh), - returnValue: _i7.Stream.empty(), - ) - as _i7.Stream); - - @override - set app(_i4.FirebaseApp? value) => super.noSuchMethod( - Invocation.setter(#app, value), - returnValueForMissingStub: null, - ); - - @override - Map get pluginConstants => - (super.noSuchMethod( - Invocation.getter(#pluginConstants), - returnValue: {}, - ) - as Map); - - @override - _i7.Future<_i5.RemoteMessage?> getInitialMessage() => - (super.noSuchMethod( - Invocation.method(#getInitialMessage, []), - returnValue: _i7.Future<_i5.RemoteMessage?>.value(), - ) - as _i7.Future<_i5.RemoteMessage?>); - - @override - _i7.Future deleteToken() => - (super.noSuchMethod( - Invocation.method(#deleteToken, []), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future getAPNSToken() => - (super.noSuchMethod( - Invocation.method(#getAPNSToken, []), - returnValue: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future getToken({String? vapidKey}) => - (super.noSuchMethod( - Invocation.method(#getToken, [], {#vapidKey: vapidKey}), - returnValue: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future isSupported() => - (super.noSuchMethod( - Invocation.method(#isSupported, []), - returnValue: _i7.Future.value(false), - ) - as _i7.Future); - - @override - _i7.Future<_i5.NotificationSettings> getNotificationSettings() => - (super.noSuchMethod( - Invocation.method(#getNotificationSettings, []), - returnValue: _i7.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_6( - this, - Invocation.method(#getNotificationSettings, []), - ), - ), - ) - as _i7.Future<_i5.NotificationSettings>); - - @override - _i7.Future<_i5.NotificationSettings> requestPermission({ - bool? alert = true, - bool? announcement = false, - bool? badge = true, - bool? carPlay = false, - bool? criticalAlert = false, - bool? provisional = false, - bool? sound = true, - bool? providesAppNotificationSettings = false, - }) => - (super.noSuchMethod( - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: providesAppNotificationSettings, - }), - returnValue: _i7.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_6( - this, - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: - providesAppNotificationSettings, - }), - ), - ), - ) - as _i7.Future<_i5.NotificationSettings>); - - @override - _i7.Future setAutoInitEnabled(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setAutoInitEnabled, [enabled]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future setForegroundNotificationPresentationOptions({ - bool? alert = false, - bool? badge = false, - bool? sound = false, - }) => - (super.noSuchMethod( - Invocation.method( - #setForegroundNotificationPresentationOptions, - [], - {#alert: alert, #badge: badge, #sound: sound}, - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future subscribeToTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#subscribeToTopic, [topic]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - _i7.Future unsubscribeFromTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#unsubscribeFromTopic, [topic]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); -} diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart new file mode 100644 index 00000000..8e213cf5 --- /dev/null +++ b/test/features/profile/data/profile_repository_test.dart @@ -0,0 +1,580 @@ +import 'dart:ui'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import 'profile_repository_test.mocks.dart'; + +Row _storyRow(String id, String title, String category, String creatorId) => + Row( + $id: id, + $tableId: storyTableId, + $databaseId: storyDatabaseId, + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + $sequence: 0, + data: { + 'title': title, + 'description': 'Description of $title', + 'category': category, + 'coverImgUrl': 'http://cover/$id', + 'creatorId': creatorId, + 'creatorName': 'Creator', + 'creatorImgUrl': 'http://img/$id', + 'likes': 10, + 'tintColor': '0000FF', + 'playDuration': 120, + }, + ); + +final _userRowWithFollowers = Row( + $id: 'id1', + $tableId: usersTableID, + $databaseId: userDatabaseID, + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + $sequence: 0, + data: { + 'username': 'testuser1', + 'followers': [ + { + 'followerUserId': 'id2', + 'followerUsername': 'testu2', + 'followerName': 'Test User 2', + 'followerFCMToken': 'testToken2', + 'followerProfileImageUrl': 'http://img/2', + 'followerRating': 5, + '\$id': 'doc2', + }, + { + 'followerUserId': 'id3', + 'followerUsername': 'testu3', + 'followerName': 'Test User 3', + 'followerFCMToken': 'testToken3', + 'followerProfileImageUrl': 'http://img/3', + 'followerRating': 5, + '\$id': 'doc3', + }, + ], + }, +); + +Row _genericRow() => Row( + $id: 'x', + $tableId: 't', + $databaseId: 'd', + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + $sequence: 0, + data: const {}, + ); + +User _user() => User( + $id: 'u1', + name: 'Test User', + email: 'test@test.com', + emailVerification: true, + prefs: Preferences(data: const {}), + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + accessedAt: DateTime(2024).toIso8601String(), + registration: DateTime(2024).toIso8601String(), + phone: '', + phoneVerification: false, + mfa: false, + passwordUpdate: DateTime(2024).toIso8601String(), + status: true, + labels: const [], + hash: 'Argon2', + targets: const [], + hashOptions: const {}, + ); + +@GenerateMocks([TablesDB, Storage, Account, FirebaseMessaging]) +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockAccount account; + late MockFirebaseMessaging messaging; + late ProfileRepository repo; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + account = MockAccount(); + messaging = MockFirebaseMessaging(); + repo = ProfileRepository( + tables: tables, + storage: storage, + account: account, + messaging: messaging, + ); + }); + + group('rowsToStories', () { + test('maps row fields onto the Story model', () { + final stories = repo.rowsToStories([ + _storyRow('doc1', 'Story 1', 'comedy', 'id1'), + _storyRow('doc2', 'Story 2', 'thriller', 'id2'), + ]); + expect(stories.length, 2); + expect(stories[0].title, 'Story 1'); + expect(stories[0].storyId, 'doc1'); + expect(stories[0].category, StoryCategory.comedy); + expect(stories[1].category, StoryCategory.thriller); + expect(stories[0].likesCount.value, 10); + expect(stories[0].tintColor, const Color(0xff0000FF)); + expect(stories[0].userIsCreator, false); + expect(stories[0].chapters, isEmpty); + }); + }); + + group('fetchCreatedStories', () { + test('queries by creatorId and maps the rows', () async { + when( + tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.equal('creatorId', 'id1')], + ), + ).thenAnswer( + (_) async => + RowList(total: 1, rows: [_storyRow('doc1', 'Story 1', 'comedy', 'id1')]), + ); + + final stories = await repo.fetchCreatedStories('id1'); + expect(stories.length, 1); + expect(stories[0].storyId, 'doc1'); + }); + + test('returns empty on AppwriteException', () async { + when( + tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.equal('creatorId', 'id1')], + ), + ).thenThrow(AppwriteException('nope')); + + expect(await repo.fetchCreatedStories('id1'), isEmpty); + }); + }); + + group('fetchLikedStories', () { + test('resolves the liked story rows', () async { + final likeRow = Row( + $id: 'like1', + $tableId: likeTableId, + $databaseId: storyDatabaseId, + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + $sequence: 0, + data: const {'storyId': 'doc1'}, + ); + when( + tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [Query.equal('uId', 'id1')], + ), + ).thenAnswer((_) async => RowList(total: 1, rows: [likeRow])); + when( + tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 'doc1', + ), + ).thenAnswer((_) async => _storyRow('doc1', 'Story 1', 'comedy', 'id1')); + + final stories = await repo.fetchLikedStories('id1'); + expect(stories.length, 1); + expect(stories[0].storyId, 'doc1'); + }); + + test('skips liked stories whose lookup fails', () async { + Row likeRow(String storyId) => Row( + $id: 'like-$storyId', + $tableId: likeTableId, + $databaseId: storyDatabaseId, + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + $sequence: 0, + data: {'storyId': storyId}, + ); + when( + tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [Query.equal('uId', 'id1')], + ), + ).thenAnswer( + (_) async => RowList(total: 2, rows: [likeRow('doc1'), likeRow('gone')]), + ); + when( + tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 'doc1', + ), + ).thenAnswer((_) async => _storyRow('doc1', 'Story 1', 'comedy', 'id1')); + when( + tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 'gone', + ), + ).thenThrow(AppwriteException('not found', 404)); + + final stories = await repo.fetchLikedStories('id1'); + expect(stories.length, 1); + expect(stories[0].storyId, 'doc1'); + }); + }); + + group('fetchFollowers', () { + test('parses the followers relation', () async { + when( + tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'id1', + queries: [Query.select(['*', 'followers.*'])], + ), + ).thenAnswer((_) async => _userRowWithFollowers); + + final followers = await repo.fetchFollowers('id1'); + expect(followers.length, 2); + expect(followers[0].name, 'Test User 2'); + expect(followers[0].docId, 'doc2'); + expect(followers[1].uid, 'id3'); + }); + + test('returns [] when the lookup throws', () async { + when( + tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'id1', + queries: [Query.select(['*', 'followers.*'])], + ), + ).thenThrow(AppwriteException('boom')); + + expect(await repo.fetchFollowers('id1'), isEmpty); + }); + }); + + group('follow / unfollow', () { + final follower = FollowerUserModel( + docId: 'fdoc1', + uid: 'id2', + username: 'testu2', + profileImageUrl: 'http://img/2', + name: 'Test User 2', + fcmToken: 'testToken2', + followingUserId: 'id1', + followerRating: 5, + ); + + test('followCreator creates the follower row', () async { + when( + tables.createRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: 'fdoc1', + data: follower.toJson(), + ), + ).thenAnswer((_) async => _genericRow()); + + await repo.followCreator(follower); + verify( + tables.createRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: 'fdoc1', + data: follower.toJson(), + ), + ).called(1); + }); + + test('unfollowCreator deletes the follower row', () async { + when( + tables.deleteRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: 'fdoc1', + ), + ).thenAnswer((_) async => ''); + + await repo.unfollowCreator('fdoc1'); + verify( + tables.deleteRow( + databaseId: userDatabaseID, + tableId: followersTableID, + rowId: 'fdoc1', + ), + ).called(1); + }); + }); + + group('isUsernameAvailable', () { + test('returns true without a lookup when it matches currentUsername', + () async { + expect( + await repo.isUsernameAvailable('me', currentUsername: 'me'), + true, + ); + verifyNever( + tables.getRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + ), + ); + }); + + test('returns false when the username row exists', () async { + when( + tables.getRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: 'taken', + ), + ).thenAnswer((_) async => _genericRow()); + expect(await repo.isUsernameAvailable('taken'), false); + }); + + test('returns true when the lookup throws (row not found)', () async { + when( + tables.getRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: 'free', + ), + ).thenThrow(AppwriteException('not found', 404)); + expect(await repo.isUsernameAvailable('free'), true); + }); + }); + + group('isEmailAvailable', () { + test('true when no rows, false when some exist', () async { + when( + tables.listRows( + databaseId: userDatabaseID, + tableId: usernameTableID, + queries: [Query.equal('email', 'free@test.com')], + ), + ).thenAnswer((_) async => RowList(total: 0, rows: const [])); + when( + tables.listRows( + databaseId: userDatabaseID, + tableId: usernameTableID, + queries: [Query.equal('email', 'taken@test.com')], + ), + ).thenAnswer((_) async => RowList(total: 1, rows: [_genericRow()])); + + expect(await repo.isEmailAvailable('free@test.com'), true); + expect(await repo.isEmailAvailable('taken@test.com'), false); + }); + }); + + group('changeEmailInAuth', () { + test('maps invalid credentials to ChangeEmailFailure', () async { + when(account.updateEmail(email: 'e', password: 'p')).thenThrow( + AppwriteException('bad', 401, userInvalidCredentials), + ); + expect( + () => repo.changeEmailInAuth(email: 'e', password: 'p'), + throwsA(ChangeEmailFailure.invalidCredentials), + ); + }); + + test('maps argument-invalid to passwordTooShort', () async { + when(account.updateEmail(email: 'e', password: 'p')).thenThrow( + AppwriteException('bad', 400, generalArgumentInvalid), + ); + expect( + () => repo.changeEmailInAuth(email: 'e', password: 'p'), + throwsA(ChangeEmailFailure.passwordTooShort), + ); + }); + + test('completes on success', () async { + when(account.updateEmail(email: 'e', password: 'p')) + .thenAnswer((_) async => _user()); + await repo.changeEmailInAuth(email: 'e', password: 'p'); + verify(account.updateEmail(email: 'e', password: 'p')).called(1); + }); + }); + + group('changeEmailInDatabases', () { + test('updates the user row and the username row', () async { + when( + tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + ), + ).thenAnswer((_) async => _genericRow()); + + await repo.changeEmailInDatabases( + uid: 'u1', + username: 'TestUser', + email: 'new@test.com', + ); + + verify( + tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + data: {'email': 'new@test.com'}, + ), + ).called(1); + verify( + tables.updateRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: 'TestUser', + data: {'email': 'new@test.com'}, + ), + ).called(1); + }); + }); + + group('changeUsername', () { + test('creates the new row, deletes the old, updates the user', () async { + when( + tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + ), + ).thenAnswer((_) async => _genericRow()); + when( + tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + ), + ).thenAnswer((_) async => ''); + when( + tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + ), + ).thenAnswer((_) async => _genericRow()); + + await repo.changeUsername( + uid: 'u1', + email: 'test@test.com', + oldUsername: 'old', + newUsername: 'new', + ); + + verify( + tables.createRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: 'new', + data: {'email': 'test@test.com'}, + ), + ).called(1); + verify( + tables.deleteRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: 'old', + ), + ).called(1); + verify( + tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + data: {'username': 'new'}, + ), + ).called(1); + }); + }); + + group('updateDisplayName', () { + test('updates the account name and the user row', () async { + when(account.updateName(name: 'New Name')) + .thenAnswer((_) async => _user()); + when( + tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + ), + ).thenAnswer((_) async => _genericRow()); + + await repo.updateDisplayName(uid: 'u1', name: 'New Name'); + + verify(account.updateName(name: 'New Name')).called(1); + verify( + tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'u1', + data: {'name': 'New Name'}, + ), + ).called(1); + }); + }); + + group('uploadProfileImage', () { + test('uploads the file and builds the view url', () async { + when( + storage.createFile( + bucketId: anyNamed('bucketId'), + fileId: anyNamed('fileId'), + file: anyNamed('file'), + ), + ).thenAnswer( + (_) async => File( + $id: 'newfileid', + bucketId: userProfileImageBucketId, + $createdAt: DateTime(2024).toIso8601String(), + $updatedAt: DateTime(2024).toIso8601String(), + $permissions: const ['any'], + name: 'x.jpeg', + signature: 'sig', + mimeType: 'image/jpeg', + sizeOriginal: 100, + chunksTotal: 1, + chunksUploaded: 1, + ), + ); + + final result = await repo.uploadProfileImage( + uid: 'u1', + email: 'test@test.com', + imagePath: 'local.jpg', + ); + + expect(result.id, startsWith('u1')); + expect(result.url, contains('newfileid')); + expect(result.url, contains(userProfileImageBucketId)); + }); + }); +} diff --git a/test/controllers/change_email_controller_test.mocks.dart b/test/features/profile/data/profile_repository_test.mocks.dart similarity index 52% rename from test/controllers/change_email_controller_test.mocks.dart rename to test/features/profile/data/profile_repository_test.mocks.dart index d89a677a..f2367c8c 100644 --- a/test/controllers/change_email_controller_test.mocks.dart +++ b/test/features/profile/data/profile_repository_test.mocks.dart @@ -1,14 +1,21 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/change_email_controller_test.dart. +// in resonate/test/features/profile/data/profile_repository_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i7; +import 'dart:typed_data' as _i10; -import 'package:appwrite/appwrite.dart' as _i4; -import 'package:appwrite/enums.dart' as _i6; +import 'package:appwrite/appwrite.dart' as _i6; +import 'package:appwrite/enums.dart' as _i11; import 'package:appwrite/models.dart' as _i3; import 'package:appwrite/src/client.dart' as _i2; +import 'package:appwrite/src/input_file.dart' as _i8; +import 'package:appwrite/src/upload_progress.dart' as _i9; +import 'package:firebase_core/firebase_core.dart' as _i4; +import 'package:firebase_messaging/firebase_messaging.dart' as _i12; +import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' + as _i5; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -52,76 +59,97 @@ class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { : super(parent, parentInvocation); } -class _FakeUser_5 extends _i1.SmartFake implements _i3.User { - _FakeUser_5(Object parent, Invocation parentInvocation) +class _FakeFileList_5 extends _i1.SmartFake implements _i3.FileList { + _FakeFileList_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeIdentityList_6 extends _i1.SmartFake implements _i3.IdentityList { - _FakeIdentityList_6(Object parent, Invocation parentInvocation) +class _FakeFile_6 extends _i1.SmartFake implements _i3.File { + _FakeFile_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeJwt_7 extends _i1.SmartFake implements _i3.Jwt { - _FakeJwt_7(Object parent, Invocation parentInvocation) +class _FakeUser_7 extends _i1.SmartFake implements _i3.User { + _FakeUser_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeLogList_8 extends _i1.SmartFake implements _i3.LogList { - _FakeLogList_8(Object parent, Invocation parentInvocation) +class _FakeIdentityList_8 extends _i1.SmartFake implements _i3.IdentityList { + _FakeIdentityList_8(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaType_9 extends _i1.SmartFake implements _i3.MfaType { - _FakeMfaType_9(Object parent, Invocation parentInvocation) +class _FakeJwt_9 extends _i1.SmartFake implements _i3.Jwt { + _FakeJwt_9(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaChallenge_10 extends _i1.SmartFake implements _i3.MfaChallenge { - _FakeMfaChallenge_10(Object parent, Invocation parentInvocation) +class _FakeLogList_10 extends _i1.SmartFake implements _i3.LogList { + _FakeLogList_10(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSession_11 extends _i1.SmartFake implements _i3.Session { - _FakeSession_11(Object parent, Invocation parentInvocation) +class _FakeMfaType_11 extends _i1.SmartFake implements _i3.MfaType { + _FakeMfaType_11(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaFactors_12 extends _i1.SmartFake implements _i3.MfaFactors { - _FakeMfaFactors_12(Object parent, Invocation parentInvocation) +class _FakeMfaChallenge_12 extends _i1.SmartFake implements _i3.MfaChallenge { + _FakeMfaChallenge_12(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeMfaRecoveryCodes_13 extends _i1.SmartFake +class _FakeSession_13 extends _i1.SmartFake implements _i3.Session { + _FakeSession_13(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaFactors_14 extends _i1.SmartFake implements _i3.MfaFactors { + _FakeMfaFactors_14(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaRecoveryCodes_15 extends _i1.SmartFake implements _i3.MfaRecoveryCodes { - _FakeMfaRecoveryCodes_13(Object parent, Invocation parentInvocation) + _FakeMfaRecoveryCodes_15(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakePreferences_16 extends _i1.SmartFake implements _i3.Preferences { + _FakePreferences_16(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeToken_17 extends _i1.SmartFake implements _i3.Token { + _FakeToken_17(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakePreferences_14 extends _i1.SmartFake implements _i3.Preferences { - _FakePreferences_14(Object parent, Invocation parentInvocation) +class _FakeSessionList_18 extends _i1.SmartFake implements _i3.SessionList { + _FakeSessionList_18(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeToken_15 extends _i1.SmartFake implements _i3.Token { - _FakeToken_15(Object parent, Invocation parentInvocation) +class _FakeTarget_19 extends _i1.SmartFake implements _i3.Target { + _FakeTarget_19(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSessionList_16 extends _i1.SmartFake implements _i3.SessionList { - _FakeSessionList_16(Object parent, Invocation parentInvocation) +class _FakeFirebaseApp_20 extends _i1.SmartFake implements _i4.FirebaseApp { + _FakeFirebaseApp_20(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeTarget_17 extends _i1.SmartFake implements _i3.Target { - _FakeTarget_17(Object parent, Invocation parentInvocation) +class _FakeNotificationSettings_21 extends _i1.SmartFake + implements _i5.NotificationSettings { + _FakeNotificationSettings_21(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [TablesDB]. /// /// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i4.TablesDB { +class MockTablesDB extends _i1.Mock implements _i6.TablesDB { MockTablesDB() { _i1.throwOnMissingStub(this); } @@ -135,40 +163,40 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { as _i2.Client); @override - _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => + _i7.Future<_i3.TransactionList> listTransactions({List? queries}) => (super.noSuchMethod( Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i5.Future<_i3.TransactionList>.value( + returnValue: _i7.Future<_i3.TransactionList>.value( _FakeTransactionList_1( this, Invocation.method(#listTransactions, [], {#queries: queries}), ), ), ) - as _i5.Future<_i3.TransactionList>); + as _i7.Future<_i3.TransactionList>); @override - _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => + _i7.Future<_i3.Transaction> createTransaction({int? ttl}) => (super.noSuchMethod( Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i5.Future<_i3.Transaction>.value( + returnValue: _i7.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#createTransaction, [], {#ttl: ttl}), ), ), ) - as _i5.Future<_i3.Transaction>); + as _i7.Future<_i3.Transaction>); @override - _i5.Future<_i3.Transaction> getTransaction({ + _i7.Future<_i3.Transaction> getTransaction({ required String? transactionId, }) => (super.noSuchMethod( Invocation.method(#getTransaction, [], { #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Transaction>.value( + returnValue: _i7.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#getTransaction, [], { @@ -177,10 +205,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Transaction>); + as _i7.Future<_i3.Transaction>); @override - _i5.Future<_i3.Transaction> updateTransaction({ + _i7.Future<_i3.Transaction> updateTransaction({ required String? transactionId, bool? commit, bool? rollback, @@ -191,7 +219,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #commit: commit, #rollback: rollback, }), - returnValue: _i5.Future<_i3.Transaction>.value( + returnValue: _i7.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#updateTransaction, [], { @@ -202,20 +230,20 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Transaction>); + as _i7.Future<_i3.Transaction>); @override - _i5.Future deleteTransaction({required String? transactionId}) => + _i7.Future deleteTransaction({required String? transactionId}) => (super.noSuchMethod( Invocation.method(#deleteTransaction, [], { #transactionId: transactionId, }), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Transaction> createOperations({ + _i7.Future<_i3.Transaction> createOperations({ required String? transactionId, List>? operations, }) => @@ -224,7 +252,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #transactionId: transactionId, #operations: operations, }), - returnValue: _i5.Future<_i3.Transaction>.value( + returnValue: _i7.Future<_i3.Transaction>.value( _FakeTransaction_2( this, Invocation.method(#createOperations, [], { @@ -234,10 +262,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Transaction>); + as _i7.Future<_i3.Transaction>); @override - _i5.Future<_i3.RowList> listRows({ + _i7.Future<_i3.RowList> listRows({ required String? databaseId, required String? tableId, List? queries, @@ -252,7 +280,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #transactionId: transactionId, #total: total, }), - returnValue: _i5.Future<_i3.RowList>.value( + returnValue: _i7.Future<_i3.RowList>.value( _FakeRowList_3( this, Invocation.method(#listRows, [], { @@ -265,10 +293,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.RowList>); + as _i7.Future<_i3.RowList>); @override - _i5.Future<_i3.Row> createRow({ + _i7.Future<_i3.Row> createRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -285,7 +313,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#createRow, [], { @@ -299,10 +327,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); @override - _i5.Future<_i3.Row> getRow({ + _i7.Future<_i3.Row> getRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -317,7 +345,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #queries: queries, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#getRow, [], { @@ -330,10 +358,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); @override - _i5.Future<_i3.Row> upsertRow({ + _i7.Future<_i3.Row> upsertRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -350,7 +378,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#upsertRow, [], { @@ -364,10 +392,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); @override - _i5.Future<_i3.Row> updateRow({ + _i7.Future<_i3.Row> updateRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -384,7 +412,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #permissions: permissions, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#updateRow, [], { @@ -398,10 +426,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); @override - _i5.Future deleteRow({ + _i7.Future deleteRow({ required String? databaseId, required String? tableId, required String? rowId, @@ -414,12 +442,12 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #rowId: rowId, #transactionId: transactionId, }), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Row> decrementRowColumn({ + _i7.Future<_i3.Row> decrementRowColumn({ required String? databaseId, required String? tableId, required String? rowId, @@ -438,7 +466,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #min: min, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#decrementRowColumn, [], { @@ -453,10 +481,10 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); @override - _i5.Future<_i3.Row> incrementRowColumn({ + _i7.Future<_i3.Row> incrementRowColumn({ required String? databaseId, required String? tableId, required String? rowId, @@ -475,7 +503,7 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { #max: max, #transactionId: transactionId, }), - returnValue: _i5.Future<_i3.Row>.value( + returnValue: _i7.Future<_i3.Row>.value( _FakeRow_4( this, Invocation.method(#incrementRowColumn, [], { @@ -490,13 +518,223 @@ class MockTablesDB extends _i1.Mock implements _i4.TablesDB { ), ), ) - as _i5.Future<_i3.Row>); + as _i7.Future<_i3.Row>); +} + +/// A class which mocks [Storage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockStorage extends _i1.Mock implements _i6.Storage { + MockStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i7.Future<_i3.FileList> listFiles({ + required String? bucketId, + List? queries, + String? search, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + returnValue: _i7.Future<_i3.FileList>.value( + _FakeFileList_5( + this, + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + ), + ), + ) + as _i7.Future<_i3.FileList>); + + @override + _i7.Future<_i3.File> createFile({ + required String? bucketId, + required String? fileId, + required _i8.InputFile? file, + List? permissions, + dynamic Function(_i9.UploadProgress)? onProgress, + }) => + (super.noSuchMethod( + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future<_i3.File> getFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future<_i3.File> updateFile({ + required String? bucketId, + required String? fileId, + String? name, + List? permissions, + }) => + (super.noSuchMethod( + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future deleteFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i10.Uint8List> getFileDownload({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileDownload, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); + + @override + _i7.Future<_i10.Uint8List> getFilePreview({ + required String? bucketId, + required String? fileId, + int? width, + int? height, + _i11.ImageGravity? gravity, + int? quality, + int? borderWidth, + String? borderColor, + int? borderRadius, + double? opacity, + int? rotation, + String? background, + _i11.ImageFormat? output, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFilePreview, [], { + #bucketId: bucketId, + #fileId: fileId, + #width: width, + #height: height, + #gravity: gravity, + #quality: quality, + #borderWidth: borderWidth, + #borderColor: borderColor, + #borderRadius: borderRadius, + #opacity: opacity, + #rotation: rotation, + #background: background, + #output: output, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); + + @override + _i7.Future<_i10.Uint8List> getFileView({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileView, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); } /// A class which mocks [Account]. /// /// See the documentation for Mockito's code generation for more information. -class MockAccount extends _i1.Mock implements _i4.Account { +class MockAccount extends _i1.Mock implements _i6.Account { MockAccount() { _i1.throwOnMissingStub(this); } @@ -510,17 +748,17 @@ class MockAccount extends _i1.Mock implements _i4.Account { as _i2.Client); @override - _i5.Future<_i3.User> get() => + _i7.Future<_i3.User> get() => (super.noSuchMethod( Invocation.method(#get, []), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#get, [])), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#get, [])), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.User> create({ + _i7.Future<_i3.User> create({ required String? userId, required String? email, required String? password, @@ -533,8 +771,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #password: password, #name: name, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#create, [], { #userId: userId, @@ -545,10 +783,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.User> updateEmail({ + _i7.Future<_i3.User> updateEmail({ required String? email, required String? password, }) => @@ -557,8 +795,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #email: email, #password: password, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updateEmail, [], { #email: email, @@ -567,10 +805,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.IdentityList> listIdentities({ + _i7.Future<_i3.IdentityList> listIdentities({ List? queries, bool? total, }) => @@ -579,8 +817,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #queries: queries, #total: total, }), - returnValue: _i5.Future<_i3.IdentityList>.value( - _FakeIdentityList_6( + returnValue: _i7.Future<_i3.IdentityList>.value( + _FakeIdentityList_8( this, Invocation.method(#listIdentities, [], { #queries: queries, @@ -589,35 +827,35 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.IdentityList>); + as _i7.Future<_i3.IdentityList>); @override - _i5.Future deleteIdentity({required String? identityId}) => + _i7.Future deleteIdentity({required String? identityId}) => (super.noSuchMethod( Invocation.method(#deleteIdentity, [], {#identityId: identityId}), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Jwt> createJWT() => + _i7.Future<_i3.Jwt> createJWT() => (super.noSuchMethod( Invocation.method(#createJWT, []), - returnValue: _i5.Future<_i3.Jwt>.value( - _FakeJwt_7(this, Invocation.method(#createJWT, [])), + returnValue: _i7.Future<_i3.Jwt>.value( + _FakeJwt_9(this, Invocation.method(#createJWT, [])), ), ) - as _i5.Future<_i3.Jwt>); + as _i7.Future<_i3.Jwt>); @override - _i5.Future<_i3.LogList> listLogs({List? queries, bool? total}) => + _i7.Future<_i3.LogList> listLogs({List? queries, bool? total}) => (super.noSuchMethod( Invocation.method(#listLogs, [], { #queries: queries, #total: total, }), - returnValue: _i5.Future<_i3.LogList>.value( - _FakeLogList_8( + returnValue: _i7.Future<_i3.LogList>.value( + _FakeLogList_10( this, Invocation.method(#listLogs, [], { #queries: queries, @@ -626,51 +864,51 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.LogList>); + as _i7.Future<_i3.LogList>); @override - _i5.Future<_i3.User> updateMFA({required bool? mfa}) => + _i7.Future<_i3.User> updateMFA({required bool? mfa}) => (super.noSuchMethod( Invocation.method(#updateMFA, [], {#mfa: mfa}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.MfaType> createMfaAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future<_i3.MfaType> createMfaAuthenticator({ + required _i11.AuthenticatorType? type, }) => (super.noSuchMethod( Invocation.method(#createMfaAuthenticator, [], {#type: type}), - returnValue: _i5.Future<_i3.MfaType>.value( - _FakeMfaType_9( + returnValue: _i7.Future<_i3.MfaType>.value( + _FakeMfaType_11( this, Invocation.method(#createMfaAuthenticator, [], {#type: type}), ), ), ) - as _i5.Future<_i3.MfaType>); + as _i7.Future<_i3.MfaType>); @override - _i5.Future<_i3.MfaType> createMFAAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future<_i3.MfaType> createMFAAuthenticator({ + required _i11.AuthenticatorType? type, }) => (super.noSuchMethod( Invocation.method(#createMFAAuthenticator, [], {#type: type}), - returnValue: _i5.Future<_i3.MfaType>.value( - _FakeMfaType_9( + returnValue: _i7.Future<_i3.MfaType>.value( + _FakeMfaType_11( this, Invocation.method(#createMFAAuthenticator, [], {#type: type}), ), ), ) - as _i5.Future<_i3.MfaType>); + as _i7.Future<_i3.MfaType>); @override - _i5.Future<_i3.User> updateMfaAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future<_i3.User> updateMfaAuthenticator({ + required _i11.AuthenticatorType? type, required String? otp, }) => (super.noSuchMethod( @@ -678,8 +916,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #type: type, #otp: otp, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updateMfaAuthenticator, [], { #type: type, @@ -688,11 +926,11 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.User> updateMFAAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future<_i3.User> updateMFAAuthenticator({ + required _i11.AuthenticatorType? type, required String? otp, }) => (super.noSuchMethod( @@ -700,8 +938,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #type: type, #otp: otp, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updateMFAAuthenticator, [], { #type: type, @@ -710,60 +948,60 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future deleteMfaAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future deleteMfaAuthenticator({ + required _i11.AuthenticatorType? type, }) => (super.noSuchMethod( Invocation.method(#deleteMfaAuthenticator, [], {#type: type}), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future deleteMFAAuthenticator({ - required _i6.AuthenticatorType? type, + _i7.Future deleteMFAAuthenticator({ + required _i11.AuthenticatorType? type, }) => (super.noSuchMethod( Invocation.method(#deleteMFAAuthenticator, [], {#type: type}), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.MfaChallenge> createMfaChallenge({ - required _i6.AuthenticationFactor? factor, + _i7.Future<_i3.MfaChallenge> createMfaChallenge({ + required _i11.AuthenticationFactor? factor, }) => (super.noSuchMethod( Invocation.method(#createMfaChallenge, [], {#factor: factor}), - returnValue: _i5.Future<_i3.MfaChallenge>.value( - _FakeMfaChallenge_10( + returnValue: _i7.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_12( this, Invocation.method(#createMfaChallenge, [], {#factor: factor}), ), ), ) - as _i5.Future<_i3.MfaChallenge>); + as _i7.Future<_i3.MfaChallenge>); @override - _i5.Future<_i3.MfaChallenge> createMFAChallenge({ - required _i6.AuthenticationFactor? factor, + _i7.Future<_i3.MfaChallenge> createMFAChallenge({ + required _i11.AuthenticationFactor? factor, }) => (super.noSuchMethod( Invocation.method(#createMFAChallenge, [], {#factor: factor}), - returnValue: _i5.Future<_i3.MfaChallenge>.value( - _FakeMfaChallenge_10( + returnValue: _i7.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_12( this, Invocation.method(#createMFAChallenge, [], {#factor: factor}), ), ), ) - as _i5.Future<_i3.MfaChallenge>); + as _i7.Future<_i3.MfaChallenge>); @override - _i5.Future<_i3.Session> updateMfaChallenge({ + _i7.Future<_i3.Session> updateMfaChallenge({ required String? challengeId, required String? otp, }) => @@ -772,8 +1010,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #challengeId: challengeId, #otp: otp, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#updateMfaChallenge, [], { #challengeId: challengeId, @@ -782,10 +1020,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> updateMFAChallenge({ + _i7.Future<_i3.Session> updateMFAChallenge({ required String? challengeId, required String? otp, }) => @@ -794,8 +1032,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #challengeId: challengeId, #otp: otp, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#updateMFAChallenge, [], { #challengeId: challengeId, @@ -804,121 +1042,121 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.MfaFactors> listMfaFactors() => + _i7.Future<_i3.MfaFactors> listMfaFactors() => (super.noSuchMethod( Invocation.method(#listMfaFactors, []), - returnValue: _i5.Future<_i3.MfaFactors>.value( - _FakeMfaFactors_12(this, Invocation.method(#listMfaFactors, [])), + returnValue: _i7.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_14(this, Invocation.method(#listMfaFactors, [])), ), ) - as _i5.Future<_i3.MfaFactors>); + as _i7.Future<_i3.MfaFactors>); @override - _i5.Future<_i3.MfaFactors> listMFAFactors() => + _i7.Future<_i3.MfaFactors> listMFAFactors() => (super.noSuchMethod( Invocation.method(#listMFAFactors, []), - returnValue: _i5.Future<_i3.MfaFactors>.value( - _FakeMfaFactors_12(this, Invocation.method(#listMFAFactors, [])), + returnValue: _i7.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_14(this, Invocation.method(#listMFAFactors, [])), ), ) - as _i5.Future<_i3.MfaFactors>); + as _i7.Future<_i3.MfaFactors>); @override - _i5.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => (super.noSuchMethod( Invocation.method(#getMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#getMfaRecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => (super.noSuchMethod( Invocation.method(#getMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#getMFARecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => (super.noSuchMethod( Invocation.method(#createMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#createMfaRecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => (super.noSuchMethod( Invocation.method(#createMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#createMFARecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => (super.noSuchMethod( Invocation.method(#updateMfaRecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#updateMfaRecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => + _i7.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => (super.noSuchMethod( Invocation.method(#updateMFARecoveryCodes, []), - returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( - _FakeMfaRecoveryCodes_13( + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( this, Invocation.method(#updateMFARecoveryCodes, []), ), ), ) - as _i5.Future<_i3.MfaRecoveryCodes>); + as _i7.Future<_i3.MfaRecoveryCodes>); @override - _i5.Future<_i3.User> updateName({required String? name}) => + _i7.Future<_i3.User> updateName({required String? name}) => (super.noSuchMethod( Invocation.method(#updateName, [], {#name: name}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updateName, [], {#name: name}), ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.User> updatePassword({ + _i7.Future<_i3.User> updatePassword({ required String? password, String? oldPassword, }) => @@ -927,8 +1165,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #password: password, #oldPassword: oldPassword, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updatePassword, [], { #password: password, @@ -937,10 +1175,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.User> updatePhone({ + _i7.Future<_i3.User> updatePhone({ required String? phone, required String? password, }) => @@ -949,8 +1187,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #phone: phone, #password: password, }), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updatePhone, [], { #phone: phone, @@ -959,40 +1197,40 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.Preferences> getPrefs() => + _i7.Future<_i3.Preferences> getPrefs() => (super.noSuchMethod( Invocation.method(#getPrefs, []), - returnValue: _i5.Future<_i3.Preferences>.value( - _FakePreferences_14(this, Invocation.method(#getPrefs, [])), + returnValue: _i7.Future<_i3.Preferences>.value( + _FakePreferences_16(this, Invocation.method(#getPrefs, [])), ), ) - as _i5.Future<_i3.Preferences>); + as _i7.Future<_i3.Preferences>); @override - _i5.Future<_i3.User> updatePrefs({required Map? prefs}) => + _i7.Future<_i3.User> updatePrefs({required Map? prefs}) => (super.noSuchMethod( Invocation.method(#updatePrefs, [], {#prefs: prefs}), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5( + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( this, Invocation.method(#updatePrefs, [], {#prefs: prefs}), ), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.Token> createRecovery({ + _i7.Future<_i3.Token> createRecovery({ required String? email, required String? url, }) => (super.noSuchMethod( Invocation.method(#createRecovery, [], {#email: email, #url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createRecovery, [], { #email: email, @@ -1001,10 +1239,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> updateRecovery({ + _i7.Future<_i3.Token> updateRecovery({ required String? userId, required String? secret, required String? password, @@ -1015,8 +1253,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #secret: secret, #password: password, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#updateRecovery, [], { #userId: userId, @@ -1026,41 +1264,41 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.SessionList> listSessions() => + _i7.Future<_i3.SessionList> listSessions() => (super.noSuchMethod( Invocation.method(#listSessions, []), - returnValue: _i5.Future<_i3.SessionList>.value( - _FakeSessionList_16(this, Invocation.method(#listSessions, [])), + returnValue: _i7.Future<_i3.SessionList>.value( + _FakeSessionList_18(this, Invocation.method(#listSessions, [])), ), ) - as _i5.Future<_i3.SessionList>); + as _i7.Future<_i3.SessionList>); @override - _i5.Future deleteSessions() => + _i7.Future deleteSessions() => (super.noSuchMethod( Invocation.method(#deleteSessions, []), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Session> createAnonymousSession() => + _i7.Future<_i3.Session> createAnonymousSession() => (super.noSuchMethod( Invocation.method(#createAnonymousSession, []), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#createAnonymousSession, []), ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> createEmailPasswordSession({ + _i7.Future<_i3.Session> createEmailPasswordSession({ required String? email, required String? password, }) => @@ -1069,8 +1307,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #email: email, #password: password, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#createEmailPasswordSession, [], { #email: email, @@ -1079,10 +1317,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> updateMagicURLSession({ + _i7.Future<_i3.Session> updateMagicURLSession({ required String? userId, required String? secret, }) => @@ -1091,8 +1329,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#updateMagicURLSession, [], { #userId: userId, @@ -1101,11 +1339,11 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future createOAuth2Session({ - required _i6.OAuthProvider? provider, + _i7.Future createOAuth2Session({ + required _i11.OAuthProvider? provider, String? success, String? failure, List? scopes, @@ -1117,12 +1355,12 @@ class MockAccount extends _i1.Mock implements _i4.Account { #failure: failure, #scopes: scopes, }), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Session> updatePhoneSession({ + _i7.Future<_i3.Session> updatePhoneSession({ required String? userId, required String? secret, }) => @@ -1131,8 +1369,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#updatePhoneSession, [], { #userId: userId, @@ -1141,10 +1379,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> createSession({ + _i7.Future<_i3.Session> createSession({ required String? userId, required String? secret, }) => @@ -1153,8 +1391,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#createSession, [], { #userId: userId, @@ -1163,54 +1401,54 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> getSession({required String? sessionId}) => + _i7.Future<_i3.Session> getSession({required String? sessionId}) => (super.noSuchMethod( Invocation.method(#getSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#getSession, [], {#sessionId: sessionId}), ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future<_i3.Session> updateSession({required String? sessionId}) => + _i7.Future<_i3.Session> updateSession({required String? sessionId}) => (super.noSuchMethod( Invocation.method(#updateSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_11( + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( this, Invocation.method(#updateSession, [], {#sessionId: sessionId}), ), ), ) - as _i5.Future<_i3.Session>); + as _i7.Future<_i3.Session>); @override - _i5.Future deleteSession({required String? sessionId}) => + _i7.Future deleteSession({required String? sessionId}) => (super.noSuchMethod( Invocation.method(#deleteSession, [], {#sessionId: sessionId}), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.User> updateStatus() => + _i7.Future<_i3.User> updateStatus() => (super.noSuchMethod( Invocation.method(#updateStatus, []), - returnValue: _i5.Future<_i3.User>.value( - _FakeUser_5(this, Invocation.method(#updateStatus, [])), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#updateStatus, [])), ), ) - as _i5.Future<_i3.User>); + as _i7.Future<_i3.User>); @override - _i5.Future<_i3.Target> createPushTarget({ + _i7.Future<_i3.Target> createPushTarget({ required String? targetId, required String? identifier, String? providerId, @@ -1221,8 +1459,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #identifier: identifier, #providerId: providerId, }), - returnValue: _i5.Future<_i3.Target>.value( - _FakeTarget_17( + returnValue: _i7.Future<_i3.Target>.value( + _FakeTarget_19( this, Invocation.method(#createPushTarget, [], { #targetId: targetId, @@ -1232,10 +1470,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Target>); + as _i7.Future<_i3.Target>); @override - _i5.Future<_i3.Target> updatePushTarget({ + _i7.Future<_i3.Target> updatePushTarget({ required String? targetId, required String? identifier, }) => @@ -1244,8 +1482,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #targetId: targetId, #identifier: identifier, }), - returnValue: _i5.Future<_i3.Target>.value( - _FakeTarget_17( + returnValue: _i7.Future<_i3.Target>.value( + _FakeTarget_19( this, Invocation.method(#updatePushTarget, [], { #targetId: targetId, @@ -1254,18 +1492,18 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Target>); + as _i7.Future<_i3.Target>); @override - _i5.Future deletePushTarget({required String? targetId}) => + _i7.Future deletePushTarget({required String? targetId}) => (super.noSuchMethod( Invocation.method(#deletePushTarget, [], {#targetId: targetId}), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Token> createEmailToken({ + _i7.Future<_i3.Token> createEmailToken({ required String? userId, required String? email, bool? phrase, @@ -1276,8 +1514,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #email: email, #phrase: phrase, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createEmailToken, [], { #userId: userId, @@ -1287,10 +1525,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> createMagicURLToken({ + _i7.Future<_i3.Token> createMagicURLToken({ required String? userId, required String? email, String? url, @@ -1303,8 +1541,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #url: url, #phrase: phrase, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createMagicURLToken, [], { #userId: userId, @@ -1315,11 +1553,11 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future createOAuth2Token({ - required _i6.OAuthProvider? provider, + _i7.Future createOAuth2Token({ + required _i11.OAuthProvider? provider, String? success, String? failure, List? scopes, @@ -1331,12 +1569,12 @@ class MockAccount extends _i1.Mock implements _i4.Account { #failure: failure, #scopes: scopes, }), - returnValue: _i5.Future.value(), + returnValue: _i7.Future.value(), ) - as _i5.Future); + as _i7.Future); @override - _i5.Future<_i3.Token> createPhoneToken({ + _i7.Future<_i3.Token> createPhoneToken({ required String? userId, required String? phone, }) => @@ -1345,8 +1583,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #phone: phone, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createPhoneToken, [], { #userId: userId, @@ -1355,36 +1593,36 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> createEmailVerification({required String? url}) => + _i7.Future<_i3.Token> createEmailVerification({required String? url}) => (super.noSuchMethod( Invocation.method(#createEmailVerification, [], {#url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createEmailVerification, [], {#url: url}), ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> createVerification({required String? url}) => + _i7.Future<_i3.Token> createVerification({required String? url}) => (super.noSuchMethod( Invocation.method(#createVerification, [], {#url: url}), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createVerification, [], {#url: url}), ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> updateEmailVerification({ + _i7.Future<_i3.Token> updateEmailVerification({ required String? userId, required String? secret, }) => @@ -1393,8 +1631,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#updateEmailVerification, [], { #userId: userId, @@ -1403,10 +1641,10 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> updateVerification({ + _i7.Future<_i3.Token> updateVerification({ required String? userId, required String? secret, }) => @@ -1415,8 +1653,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#updateVerification, [], { #userId: userId, @@ -1425,23 +1663,23 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> createPhoneVerification() => + _i7.Future<_i3.Token> createPhoneVerification() => (super.noSuchMethod( Invocation.method(#createPhoneVerification, []), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#createPhoneVerification, []), ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); @override - _i5.Future<_i3.Token> updatePhoneVerification({ + _i7.Future<_i3.Token> updatePhoneVerification({ required String? userId, required String? secret, }) => @@ -1450,8 +1688,8 @@ class MockAccount extends _i1.Mock implements _i4.Account { #userId: userId, #secret: secret, }), - returnValue: _i5.Future<_i3.Token>.value( - _FakeToken_15( + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( this, Invocation.method(#updatePhoneVerification, [], { #userId: userId, @@ -1460,5 +1698,200 @@ class MockAccount extends _i1.Mock implements _i4.Account { ), ), ) - as _i5.Future<_i3.Token>); + as _i7.Future<_i3.Token>); +} + +/// A class which mocks [FirebaseMessaging]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { + MockFirebaseMessaging() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.FirebaseApp get app => + (super.noSuchMethod( + Invocation.getter(#app), + returnValue: _FakeFirebaseApp_20(this, Invocation.getter(#app)), + ) + as _i4.FirebaseApp); + + @override + bool get isAutoInitEnabled => + (super.noSuchMethod( + Invocation.getter(#isAutoInitEnabled), + returnValue: false, + ) + as bool); + + @override + _i7.Stream get onTokenRefresh => + (super.noSuchMethod( + Invocation.getter(#onTokenRefresh), + returnValue: _i7.Stream.empty(), + ) + as _i7.Stream); + + @override + set app(_i4.FirebaseApp? value) => super.noSuchMethod( + Invocation.setter(#app, value), + returnValueForMissingStub: null, + ); + + @override + Map get pluginConstants => + (super.noSuchMethod( + Invocation.getter(#pluginConstants), + returnValue: {}, + ) + as Map); + + @override + _i7.Future<_i5.RemoteMessage?> getInitialMessage() => + (super.noSuchMethod( + Invocation.method(#getInitialMessage, []), + returnValue: _i7.Future<_i5.RemoteMessage?>.value(), + ) + as _i7.Future<_i5.RemoteMessage?>); + + @override + _i7.Future deleteToken() => + (super.noSuchMethod( + Invocation.method(#deleteToken, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future getAPNSToken() => + (super.noSuchMethod( + Invocation.method(#getAPNSToken, []), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future getToken({String? vapidKey}) => + (super.noSuchMethod( + Invocation.method(#getToken, [], {#vapidKey: vapidKey}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future isSupported() => + (super.noSuchMethod( + Invocation.method(#isSupported, []), + returnValue: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future<_i5.NotificationSettings> getNotificationSettings() => + (super.noSuchMethod( + Invocation.method(#getNotificationSettings, []), + returnValue: _i7.Future<_i5.NotificationSettings>.value( + _FakeNotificationSettings_21( + this, + Invocation.method(#getNotificationSettings, []), + ), + ), + ) + as _i7.Future<_i5.NotificationSettings>); + + @override + _i7.Future<_i5.NotificationSettings> requestPermission({ + bool? alert = true, + bool? announcement = false, + bool? badge = true, + bool? carPlay = false, + bool? criticalAlert = false, + bool? provisional = false, + bool? sound = true, + bool? providesAppNotificationSettings = false, + }) => + (super.noSuchMethod( + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: providesAppNotificationSettings, + }), + returnValue: _i7.Future<_i5.NotificationSettings>.value( + _FakeNotificationSettings_21( + this, + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: + providesAppNotificationSettings, + }), + ), + ), + ) + as _i7.Future<_i5.NotificationSettings>); + + @override + _i7.Future setAutoInitEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setAutoInitEnabled, [enabled]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future setForegroundNotificationPresentationOptions({ + bool? alert = false, + bool? badge = false, + bool? sound = false, + }) => + (super.noSuchMethod( + Invocation.method( + #setForegroundNotificationPresentationOptions, + [], + {#alert: alert, #badge: badge, #sound: sound}, + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future subscribeToTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#subscribeToTopic, [topic]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future unsubscribeFromTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#unsubscribeFromTopic, [topic]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); } diff --git a/test/features/profile/fake_profile_repository.dart b/test/features/profile/fake_profile_repository.dart new file mode 100644 index 00000000..25a94be3 --- /dev/null +++ b/test/features/profile/fake_profile_repository.dart @@ -0,0 +1,197 @@ +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/models/story.dart'; + +class FakeProfileRepository implements ProfileRepository { + // Configurable returns. + List createdStories = const []; + List likedStories = const []; + List followers = const []; + String? fcmToken = 'fake-token'; + bool usernameAvailableReturn = true; + bool emailAvailableReturn = true; + Object? changeEmailInAuthError; + Object? changeEmailInDatabasesError; + Object? createUsernameRowError; + + // Recorded calls. + String? isUsernameAvailableArg; + String? isUsernameAvailableCurrent; + FollowerUserModel? followedFollower; + String? unfollowedDocId; + int getFcmTokenCount = 0; + int deleteProfileImageCount = 0; + String? deletedImageId; + int uploadProfileImageCount = 0; + ({String uid, String email, String imagePath})? uploadArgs; + ({String uid, String url, String id})? updateProfileImageRowArgs; + String? clearedImageRowUid; + ({String uid, String email, String oldUsername, String newUsername})? + changeUsernameArgs; + ({String uid, String name})? updateDisplayNameArgs; + ({String username, String email})? createUsernameRowArgs; + String? updateAccountNameArg; + String? createUserRowUid; + Map? createUserRowData; + int markProfileCompleteCount = 0; + ({String email, String password})? changeEmailInAuthArgs; + ({String uid, String username, String email})? changeEmailInDatabasesArgs; + + @override + Future> fetchCreatedStories(String creatorId) async => + createdStories; + + @override + Future> fetchLikedStories(String creatorId) async => likedStories; + + @override + Future> fetchFollowers(String userId) async => + followers; + + @override + Future getFcmToken() async { + getFcmTokenCount++; + return fcmToken; + } + + @override + Future followCreator(FollowerUserModel follower) async { + followedFollower = follower; + } + + @override + Future unfollowCreator(String followerDocumentId) async { + unfollowedDocId = followerDocumentId; + } + + @override + Future isUsernameAvailable( + String username, { + String? currentUsername, + }) async { + isUsernameAvailableArg = username; + isUsernameAvailableCurrent = currentUsername; + return usernameAvailableReturn; + } + + @override + Future isEmailAvailable(String email) async => emailAvailableReturn; + + @override + Future changeEmailInAuth({ + required String email, + required String password, + }) async { + changeEmailInAuthArgs = (email: email, password: password); + if (changeEmailInAuthError != null) throw changeEmailInAuthError!; + } + + @override + Future changeEmailInDatabases({ + required String uid, + required String username, + required String email, + }) async { + changeEmailInDatabasesArgs = (uid: uid, username: username, email: email); + if (changeEmailInDatabasesError != null) throw changeEmailInDatabasesError!; + } + + @override + Future<({String url, String id})> uploadProfileImage({ + required String uid, + required String email, + required String imagePath, + }) async { + uploadProfileImageCount++; + uploadArgs = (uid: uid, email: email, imagePath: imagePath); + return (url: 'https://img/$uid', id: 'imgid-$uid'); + } + + @override + Future deleteProfileImage(String fileId) async { + deleteProfileImageCount++; + deletedImageId = fileId; + } + + @override + Future updateProfileImageRow({ + required String uid, + required String url, + required String id, + }) async { + updateProfileImageRowArgs = (uid: uid, url: url, id: id); + } + + @override + Future clearProfileImageRow(String uid) async { + clearedImageRowUid = uid; + } + + @override + Future changeUsername({ + required String uid, + required String email, + required String oldUsername, + required String newUsername, + }) async { + changeUsernameArgs = ( + uid: uid, + email: email, + oldUsername: oldUsername, + newUsername: newUsername, + ); + } + + @override + Future updateDisplayName({ + required String uid, + required String name, + }) async { + updateDisplayNameArgs = (uid: uid, name: name); + } + + @override + Future createUsernameRow({ + required String username, + required String email, + }) async { + createUsernameRowArgs = (username: username, email: email); + if (createUsernameRowError != null) throw createUsernameRowError!; + } + + @override + Future updateAccountName(String name) async { + updateAccountNameArg = name; + } + + @override + Future createUserRow({ + required String uid, + required String name, + required String username, + required String profileImageUrl, + required String dob, + required String email, + String? profileImageID, + }) async { + createUserRowUid = uid; + createUserRowData = { + 'name': name, + 'username': username, + 'profileImageUrl': profileImageUrl, + 'dob': dob, + 'email': email, + 'profileImageID': profileImageID, + }; + } + + @override + Future markProfileComplete() async { + markProfileCompleteCount++; + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError( + '${invocation.memberName} not stubbed in FakeProfileRepository', + ); +} diff --git a/test/features/profile/view/change_email_page_test.dart b/test/features/profile/view/change_email_page_test.dart new file mode 100644 index 00000000..c6de5e60 --- /dev/null +++ b/test/features/profile/view/change_email_page_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/profile/view/pages/change_email_page.dart'; + +import 'profile_test_helpers.dart'; + +void main() { + testWidgets('renders the email + password fields and a submit button', + (tester) async { + await pumpProfilePage( + tester, + const ChangeEmailPage(), + authState: AuthState.authenticated(fakeAuthUser()), + ); + + expect(find.byType(TextFormField), findsNWidgets(2)); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('password visibility toggle flips the eye icon', (tester) async { + await pumpProfilePage( + tester, + const ChangeEmailPage(), + authState: AuthState.authenticated(fakeAuthUser()), + ); + + expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.visibility_off_outlined)); + await tester.pump(); + expect(find.byIcon(Icons.visibility_outlined), findsOneWidget); + }); + + testWidgets('an invalid email blocks submission', (tester) async { + final repo = FakeProfileRepository(); + await pumpProfilePage( + tester, + const ChangeEmailPage(), + authState: AuthState.authenticated(fakeAuthUser()), + profileRepo: repo, + ); + + await tester.enterText(find.byType(TextFormField).at(0), 'not-an-email'); + await tester.enterText(find.byType(TextFormField).at(1), 'password123'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(repo.changeEmailInAuthArgs, isNull); + }); + + testWidgets('valid input runs the change-email flow', (tester) async { + final repo = FakeProfileRepository(); + await pumpProfilePage( + tester, + const ChangeEmailPage(), + authState: AuthState.authenticated(fakeAuthUser(userName: 'TestUser')), + profileRepo: repo, + ); + + await tester.enterText(find.byType(TextFormField).at(0), 'new@test.com'); + await tester.enterText(find.byType(TextFormField).at(1), 'password123'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(repo.changeEmailInAuthArgs?.email, 'new@test.com'); + }); +} diff --git a/test/features/profile/view/delete_account_page_test.dart b/test/features/profile/view/delete_account_page_test.dart new file mode 100644 index 00000000..e56155b4 --- /dev/null +++ b/test/features/profile/view/delete_account_page_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/profile/view/pages/delete_account_page.dart'; + +import 'profile_test_helpers.dart'; + +void main() { + ElevatedButton deleteButton(WidgetTester tester) => + tester.widget(find.byType(ElevatedButton)); + + testWidgets('delete button stays disabled until the typed username matches', + (tester) async { + await pumpProfilePage( + tester, + const DeleteAccountPage(), + authState: AuthState.authenticated(fakeAuthUser(userName: 'testuser')), + ); + + // Disabled on first render. + expect(deleteButton(tester).onPressed, isNull); + + // Wrong username keeps it disabled. + await tester.enterText(find.byType(TextField), 'wrong'); + await tester.pump(); + expect(deleteButton(tester).onPressed, isNull); + + // Exact match enables it. + await tester.enterText(find.byType(TextField), 'testuser'); + await tester.pump(); + expect(deleteButton(tester).onPressed, isNotNull); + + // Editing away from the match disables it again. + await tester.enterText(find.byType(TextField), 'testuse'); + await tester.pump(); + expect(deleteButton(tester).onPressed, isNull); + }); +} diff --git a/test/features/profile/view/profile_test_helpers.dart b/test/features/profile/view/profile_test_helpers.dart new file mode 100644 index 00000000..6c9d69cc --- /dev/null +++ b/test/features/profile/view/profile_test_helpers.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../fake_profile_repository.dart'; + +export '../../../helpers/test_root_container.dart' show fakeAuthUser; +export '../fake_profile_repository.dart'; + + +Widget profileTestApp(Widget child) { + return MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + + home: Builder( + builder: (context) { + UiSizes.init(context); + return child; + }, + ), + ); +} + +Future buildProfileContainer({ + required AuthState authState, + FakeProfileRepository? profileRepo, +}) async { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(FakeAuthRepository(authState)), + profileRepositoryProvider + .overrideWithValue(profileRepo ?? FakeProfileRepository()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + return container; +} + +Future pumpProfilePage( + WidgetTester tester, + Widget child, { + required AuthState authState, + FakeProfileRepository? profileRepo, +}) async { + final container = await buildProfileContainer( + authState: authState, + profileRepo: profileRepo, + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: profileTestApp(child), + ), + ); + await tester.pumpAndSettle(); + return container; +} diff --git a/test/features/profile/viewmodel/change_email_notifier_test.dart b/test/features/profile/viewmodel/change_email_notifier_test.dart new file mode 100644 index 00000000..8f05f567 --- /dev/null +++ b/test/features/profile/viewmodel/change_email_notifier_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:resonate/features/profile/viewmodel/change_email_notifier.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../fake_profile_repository.dart'; + +void main() { + late FakeProfileRepository repo; + late FakeAuthRepository auth; + late ProviderContainer container; + + setUp(() async { + repo = FakeProfileRepository(); + auth = FakeAuthRepository( + AuthState.authenticated( + fakeAuthUser(uid: 'u1', userName: 'TestUser', email: 'old@test.com'), + ), + ); + container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(auth), + profileRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + }); + + ChangeEmail notifier() => container.read(changeEmailProvider.notifier); + + test('togglePasswordVisible flips the flag', () { + expect(container.read(changeEmailProvider).passwordVisible, false); + notifier().togglePasswordVisible(); + expect(container.read(changeEmailProvider).passwordVisible, true); + }); + + test('returns emailExists and never touches auth when email is taken', + () async { + repo.emailAvailableReturn = false; + final status = await notifier().changeEmail( + email: 'new@test.com', + password: 'pw', + ); + expect(status, ChangeEmailStatus.emailExists); + expect(repo.changeEmailInAuthArgs, isNull); + }); + + test('maps invalid-credentials failure', () async { + repo.changeEmailInAuthError = ChangeEmailFailure.invalidCredentials; + final status = await notifier().changeEmail( + email: 'new@test.com', + password: 'wrong', + ); + expect(status, ChangeEmailStatus.invalidCredentials); + }); + + test('maps password-too-short failure', () async { + repo.changeEmailInAuthError = ChangeEmailFailure.passwordTooShort; + final status = await notifier().changeEmail( + email: 'new@test.com', + password: 'x', + ); + expect(status, ChangeEmailStatus.passwordTooShort); + }); + + test('happy path updates databases, refreshes auth, returns success', + () async { + final before = auth.loadCount; + final status = await notifier().changeEmail( + email: 'new@test.com', + password: 'pw', + ); + expect(status, ChangeEmailStatus.success); + expect(repo.changeEmailInAuthArgs?.email, 'new@test.com'); + expect(repo.changeEmailInDatabasesArgs?.uid, 'u1'); + expect(repo.changeEmailInDatabasesArgs?.username, 'TestUser'); + expect(repo.changeEmailInDatabasesArgs?.email, 'new@test.com'); + expect(auth.loadCount, before + 1); + expect(container.read(changeEmailProvider).isLoading, false); + }); + + test('returns failed when the database update throws', () async { + repo.changeEmailInDatabasesError = Exception('boom'); + final status = await notifier().changeEmail( + email: 'new@test.com', + password: 'pw', + ); + expect(status, ChangeEmailStatus.failed); + expect(container.read(changeEmailProvider).isLoading, false); + }); +} diff --git a/test/features/profile/viewmodel/delete_account_notifier_test.dart b/test/features/profile/viewmodel/delete_account_notifier_test.dart new file mode 100644 index 00000000..b187afd1 --- /dev/null +++ b/test/features/profile/viewmodel/delete_account_notifier_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/profile/viewmodel/delete_account_notifier.dart'; + +void main() { + group('DeleteAccount', () { + test('initial state is false (button disabled)', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(deleteAccountProvider), false); + }); + + test('setButtonActive toggles the enabled state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(deleteAccountProvider.notifier); + + notifier.setButtonActive(true); + expect(container.read(deleteAccountProvider), true); + + notifier.setButtonActive(false); + expect(container.read(deleteAccountProvider), false); + }); + }); +} diff --git a/test/features/profile/viewmodel/edit_profile_notifier_test.dart b/test/features/profile/viewmodel/edit_profile_notifier_test.dart new file mode 100644 index 00000000..0a0fa59e --- /dev/null +++ b/test/features/profile/viewmodel/edit_profile_notifier_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/edit_profile_state.dart'; +import 'package:resonate/features/profile/viewmodel/edit_profile_notifier.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../fake_profile_repository.dart'; + +void main() { + late FakeProfileRepository repo; + late FakeAuthRepository auth; + late ProviderContainer container; + + setUp(() async { + repo = FakeProfileRepository(); + auth = FakeAuthRepository( + AuthState.authenticated( + fakeAuthUser( + uid: 'u1', + displayName: 'Test User', + userName: 'testuser', + email: 'test@test.com', + profileImageUrl: 'http://img', + profileImageID: 'img123', + ), + ), + ); + container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(auth), + profileRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + }); + + EditProfile notifier() => container.read(editProfileProvider.notifier); + + test('change detectors compare against the signed-in user', () { + final n = notifier(); + expect(n.isUsernameChanged('newuser'), true); + expect(n.isUsernameChanged('testuser'), false); + expect(n.isDisplayNameChanged('New Name'), true); + expect(n.isDisplayNameChanged('Test User'), false); + }); + + test('isUsernameAvailable forwards the current username', () async { + repo.usernameAvailableReturn = true; + final result = await notifier().isUsernameAvailable('whatever'); + expect(result, true); + expect(repo.isUsernameAvailableArg, 'whatever'); + expect(repo.isUsernameAvailableCurrent, 'testuser'); + }); + + test('removeProfilePicture flags removal and clears the picked path', () { + final n = notifier(); + n.setProfileImagePath('local.jpg'); + expect(container.read(editProfileProvider).profileImagePath, 'local.jpg'); + n.removeProfilePicture(); + final state = container.read(editProfileProvider); + expect(state.removeImage, true); + expect(state.profileImagePath, isNull); + }); + + test('isProfilePictureChanged reflects path or remove flag', () { + final n = notifier(); + expect(n.isProfilePictureChanged(), false); + n.setProfileImagePath('local.jpg'); + expect(n.isProfilePictureChanged(), true); + }); + + test('saveProfile with no changes is a no-op and does not refresh auth', + () async { + final before = auth.loadCount; + final result = await notifier().saveProfile( + name: 'Test User', + username: 'testuser', + ); + expect(result, EditProfileSaveResult.noChange); + expect(repo.changeUsernameArgs, isNull); + expect(repo.updateDisplayNameArgs, isNull); + expect(auth.loadCount, before); // no DB writes → no auth refresh + }); + + test('saveProfile persists username + display name changes', () async { + final result = await notifier().saveProfile( + name: 'New Name', + username: 'newuser', + ); + expect(result, EditProfileSaveResult.saved); + expect(repo.changeUsernameArgs?.oldUsername, 'testuser'); + expect(repo.changeUsernameArgs?.newUsername, 'newuser'); + expect(repo.changeUsernameArgs?.uid, 'u1'); + expect(repo.updateDisplayNameArgs?.name, 'New Name'); + expect(container.read(editProfileProvider).isLoading, false); + }); + + test('saveProfile uploads a new picture and deletes the old one', () async { + final n = notifier(); + n.setProfileImagePath('local.jpg'); + final result = await n.saveProfile(name: 'Test User', username: 'testuser'); + + expect(result, EditProfileSaveResult.saved); + expect(repo.deleteProfileImageCount, 1); + expect(repo.deletedImageId, 'img123'); + expect(repo.uploadProfileImageCount, 1); + expect(repo.uploadArgs?.imagePath, 'local.jpg'); + expect(repo.updateProfileImageRowArgs?.uid, 'u1'); + // Path cleared after a successful save. + expect(container.read(editProfileProvider).profileImagePath, isNull); + }); + + test('saveProfile removes the picture: deletes the file then clears the row', + () async { + final n = notifier(); + n.removeProfilePicture(); + final result = await n.saveProfile(name: 'Test User', username: 'testuser'); + + expect(result, EditProfileSaveResult.saved); + expect(repo.deleteProfileImageCount, 1); + expect(repo.deletedImageId, 'img123'); + expect(repo.clearedImageRowUid, 'u1'); + expect(repo.uploadProfileImageCount, 0); + }); +} diff --git a/test/features/profile/viewmodel/onboarding_notifier_test.dart b/test/features/profile/viewmodel/onboarding_notifier_test.dart new file mode 100644 index 00000000..d8f4d58a --- /dev/null +++ b/test/features/profile/viewmodel/onboarding_notifier_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/model/onboarding_state.dart'; +import 'package:resonate/features/profile/viewmodel/onboarding_notifier.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../fake_profile_repository.dart'; + +void main() { + late FakeProfileRepository repo; + late FakeAuthRepository auth; + late ProviderContainer container; + + setUp(() async { + repo = FakeProfileRepository(); + auth = FakeAuthRepository( + AuthState.needsOnboarding( + fakeAuthUser( + uid: 'u1', + email: 'new@test.com', + isProfileComplete: false, + ), + ), + ); + container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(auth), + profileRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + }); + + Onboarding notifier() => container.read(onboardingProvider.notifier); + + Future save() => notifier().saveProfile( + name: 'New User', + username: 'newuser', + dob: '01-01-2000', + fallbackImageUrl: 'http://placeholder', + ); + + test('returns usernameUnavailable and skips creation when taken', () async { + repo.usernameAvailableReturn = false; + final result = await save(); + expect(result.status, OnboardingStatus.usernameUnavailable); + expect(repo.createUsernameRowArgs, isNull); + expect(container.read(onboardingProvider).usernameAvailable, false); + }); + + test('happy path without a picture uses the fallback url', () async { + final before = auth.loadCount; + final result = await save(); + + expect(result.status, OnboardingStatus.success); + expect(repo.createUsernameRowArgs?.username, 'newuser'); + expect(repo.updateAccountNameArg, 'New User'); + expect(repo.createUserRowUid, 'u1'); + expect(repo.createUserRowData?['profileImageUrl'], 'http://placeholder'); + expect(repo.createUserRowData?['profileImageID'], isNull); + expect(repo.markProfileCompleteCount, 1); + expect(auth.loadCount, before + 1); + }); + + test('happy path with a picture uploads and uses the returned url/id', + () async { + notifier().setProfileImagePath('local.jpg'); + final result = await save(); + + expect(result.status, OnboardingStatus.success); + expect(repo.uploadProfileImageCount, 1); + expect(repo.createUserRowData?['profileImageUrl'], 'https://img/u1'); + expect(repo.createUserRowData?['profileImageID'], 'imgid-u1'); + }); + + test('maps the invalid-documentId error to invalidUsernameFormat', () async { + repo.createUsernameRowError = Exception('Invalid `documentId` param'); + final result = await save(); + expect(result.status, OnboardingStatus.invalidUsernameFormat); + }); + + test('maps other errors to a generic error with a message', () async { + repo.createUsernameRowError = Exception('boom'); + final result = await save(); + expect(result.status, OnboardingStatus.error); + expect(result.message, contains('boom')); + expect(container.read(onboardingProvider).isLoading, false); + }); +} diff --git a/test/features/profile/viewmodel/profile_view_notifier_test.dart b/test/features/profile/viewmodel/profile_view_notifier_test.dart new file mode 100644 index 00000000..2dcf31b8 --- /dev/null +++ b/test/features/profile/viewmodel/profile_view_notifier_test.dart @@ -0,0 +1,151 @@ +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; +import 'package:resonate/models/follower_user_model.dart'; +import 'package:resonate/models/story.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../fake_profile_repository.dart'; + +Story _story(String id) => Story( + title: 'Story $id', + storyId: id, + description: 'desc', + userIsCreator: false, + category: StoryCategory.comedy, + coverImageUrl: 'http://cover/$id', + creatorId: 'creator1', + creatorName: 'Creator', + creatorImgUrl: 'http://img', + creationDate: DateTime(2024), + likesCount: 0, + isLikedByCurrentUser: false, + playDuration: 60, + tintColor: const Color(0xff0000FF), + chapters: const [], + ); + +FollowerUserModel _follower({required String uid, required String docId}) => + FollowerUserModel( + docId: docId, + uid: uid, + username: '$uid-name', + profileImageUrl: 'http://img/$uid', + name: 'Name $uid', + fcmToken: 'tok-$uid', + followingUserId: 'creator1', + followerRating: 5, + ); + +Future _buildContainer( + FakeProfileRepository repo, +) async { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue( + FakeAuthRepository( + AuthState.authenticated( + fakeAuthUser( + uid: 'me', + userName: 'meuser', + profileImageUrl: 'http://img/me', + displayName: 'Me', + ratingTotal: 10, + ratingCount: 2, + ), + ), + ), + ), + profileRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + return container; +} + +void main() { + group('ProfileView.build', () { + test('composes stories + followers and detects not-following', () async { + final repo = FakeProfileRepository() + ..createdStories = [_story('s1')] + ..likedStories = [_story('s2')] + ..followers = [_follower(uid: 'other', docId: 'f-other')]; + final container = await _buildContainer(repo); + + final data = await container.read(profileViewProvider('creator1').future); + + expect(data.createdStories.length, 1); + expect(data.likedStories.length, 1); + expect(data.followers.length, 1); + expect(data.isFollowing, false); + expect(data.followerDocumentId, isNull); + }); + + test('detects following when current user is in the followers list', + () async { + final repo = FakeProfileRepository() + ..followers = [_follower(uid: 'me', docId: 'my-follow-doc')]; + final container = await _buildContainer(repo); + + final data = await container.read(profileViewProvider('creator1').future); + + expect(data.isFollowing, true); + expect(data.followerDocumentId, 'my-follow-doc'); + }); + }); + + group('ProfileView.followCreator', () { + test('builds the follower from the current user and updates state', + () async { + final repo = FakeProfileRepository()..fcmToken = 'my-token'; + final container = await _buildContainer(repo); + await container.read(profileViewProvider('creator1').future); + + await container + .read(profileViewProvider('creator1').notifier) + .followCreator('creator1'); + + final follower = repo.followedFollower; + expect(follower, isNotNull); + expect(follower!.uid, 'me'); + expect(follower.username, 'meuser'); + expect(follower.profileImageUrl, 'http://img/me'); + expect(follower.name, 'Me'); + expect(follower.fcmToken, 'my-token'); + expect(follower.followingUserId, 'creator1'); + expect(follower.followerRating, 5); // 10 / 2 + + final data = container.read(profileViewProvider('creator1')).value!; + expect(data.isFollowing, true); + expect(data.followers.length, 1); + expect(data.followerDocumentId, follower.docId); + }); + }); + + group('ProfileView.unfollowCreator', () { + test('removes the current user and clears the follow doc id', () async { + final repo = FakeProfileRepository() + ..followers = [_follower(uid: 'me', docId: 'my-follow-doc')]; + final container = await _buildContainer(repo); + await container.read(profileViewProvider('creator1').future); + + await container + .read(profileViewProvider('creator1').notifier) + .unfollowCreator(); + + expect(repo.unfollowedDocId, 'my-follow-doc'); + final data = container.read(profileViewProvider('creator1')).value!; + expect(data.isFollowing, false); + expect(data.followers, isEmpty); + expect(data.followerDocumentId, isNull); + }); + }); +} diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index 2d09d74b..84b946be 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -10,7 +10,7 @@ import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -/// Reusable AuthUser for tests. +// Reusable AuthUser for tests. AuthUser fakeAuthUser({ String uid = '123', String email = 'test@test.com', From 1b215876acfdcff86b1336df805e1fc42875a515 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:11:10 +0530 Subject: [PATCH 04/12] chore: regenerate code-gen outputs after merge --- .../data/profile_repository_test.mocks.dart | 1897 +++++++++++++++ test/helpers/test_root_container.mocks.dart | 2054 +++++++++++++++++ 2 files changed, 3951 insertions(+) create mode 100644 test/features/profile/data/profile_repository_test.mocks.dart create mode 100644 test/helpers/test_root_container.mocks.dart diff --git a/test/features/profile/data/profile_repository_test.mocks.dart b/test/features/profile/data/profile_repository_test.mocks.dart new file mode 100644 index 00000000..f2367c8c --- /dev/null +++ b/test/features/profile/data/profile_repository_test.mocks.dart @@ -0,0 +1,1897 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in resonate/test/features/profile/data/profile_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i7; +import 'dart:typed_data' as _i10; + +import 'package:appwrite/appwrite.dart' as _i6; +import 'package:appwrite/enums.dart' as _i11; +import 'package:appwrite/models.dart' as _i3; +import 'package:appwrite/src/client.dart' as _i2; +import 'package:appwrite/src/input_file.dart' as _i8; +import 'package:appwrite/src/upload_progress.dart' as _i9; +import 'package:firebase_core/firebase_core.dart' as _i4; +import 'package:firebase_messaging/firebase_messaging.dart' as _i12; +import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { + _FakeClient_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransactionList_1 extends _i1.SmartFake + implements _i3.TransactionList { + _FakeTransactionList_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { + _FakeTransaction_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { + _FakeRowList_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { + _FakeRow_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFileList_5 extends _i1.SmartFake implements _i3.FileList { + _FakeFileList_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFile_6 extends _i1.SmartFake implements _i3.File { + _FakeFile_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeUser_7 extends _i1.SmartFake implements _i3.User { + _FakeUser_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeIdentityList_8 extends _i1.SmartFake implements _i3.IdentityList { + _FakeIdentityList_8(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeJwt_9 extends _i1.SmartFake implements _i3.Jwt { + _FakeJwt_9(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeLogList_10 extends _i1.SmartFake implements _i3.LogList { + _FakeLogList_10(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaType_11 extends _i1.SmartFake implements _i3.MfaType { + _FakeMfaType_11(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaChallenge_12 extends _i1.SmartFake implements _i3.MfaChallenge { + _FakeMfaChallenge_12(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSession_13 extends _i1.SmartFake implements _i3.Session { + _FakeSession_13(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaFactors_14 extends _i1.SmartFake implements _i3.MfaFactors { + _FakeMfaFactors_14(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaRecoveryCodes_15 extends _i1.SmartFake + implements _i3.MfaRecoveryCodes { + _FakeMfaRecoveryCodes_15(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakePreferences_16 extends _i1.SmartFake implements _i3.Preferences { + _FakePreferences_16(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeToken_17 extends _i1.SmartFake implements _i3.Token { + _FakeToken_17(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSessionList_18 extends _i1.SmartFake implements _i3.SessionList { + _FakeSessionList_18(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTarget_19 extends _i1.SmartFake implements _i3.Target { + _FakeTarget_19(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFirebaseApp_20 extends _i1.SmartFake implements _i4.FirebaseApp { + _FakeFirebaseApp_20(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeNotificationSettings_21 extends _i1.SmartFake + implements _i5.NotificationSettings { + _FakeNotificationSettings_21(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [TablesDB]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTablesDB extends _i1.Mock implements _i6.TablesDB { + MockTablesDB() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i7.Future<_i3.TransactionList> listTransactions({List? queries}) => + (super.noSuchMethod( + Invocation.method(#listTransactions, [], {#queries: queries}), + returnValue: _i7.Future<_i3.TransactionList>.value( + _FakeTransactionList_1( + this, + Invocation.method(#listTransactions, [], {#queries: queries}), + ), + ), + ) + as _i7.Future<_i3.TransactionList>); + + @override + _i7.Future<_i3.Transaction> createTransaction({int? ttl}) => + (super.noSuchMethod( + Invocation.method(#createTransaction, [], {#ttl: ttl}), + returnValue: _i7.Future<_i3.Transaction>.value( + _FakeTransaction_2( + this, + Invocation.method(#createTransaction, [], {#ttl: ttl}), + ), + ), + ) + as _i7.Future<_i3.Transaction>); + + @override + _i7.Future<_i3.Transaction> getTransaction({ + required String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Transaction>.value( + _FakeTransaction_2( + this, + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Transaction>); + + @override + _i7.Future<_i3.Transaction> updateTransaction({ + required String? transactionId, + bool? commit, + bool? rollback, + }) => + (super.noSuchMethod( + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + returnValue: _i7.Future<_i3.Transaction>.value( + _FakeTransaction_2( + this, + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + ), + ), + ) + as _i7.Future<_i3.Transaction>); + + @override + _i7.Future deleteTransaction({required String? transactionId}) => + (super.noSuchMethod( + Invocation.method(#deleteTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Transaction> createOperations({ + required String? transactionId, + List>? operations, + }) => + (super.noSuchMethod( + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + returnValue: _i7.Future<_i3.Transaction>.value( + _FakeTransaction_2( + this, + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + ), + ), + ) + as _i7.Future<_i3.Transaction>); + + @override + _i7.Future<_i3.RowList> listRows({ + required String? databaseId, + required String? tableId, + List? queries, + String? transactionId, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + returnValue: _i7.Future<_i3.RowList>.value( + _FakeRowList_3( + this, + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + ), + ), + ) + as _i7.Future<_i3.RowList>); + + @override + _i7.Future<_i3.Row> createRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + required Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); + + @override + _i7.Future<_i3.Row> getRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + List? queries, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); + + @override + _i7.Future<_i3.Row> upsertRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); + + @override + _i7.Future<_i3.Row> updateRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); + + @override + _i7.Future deleteRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #transactionId: transactionId, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Row> decrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? min, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); + + @override + _i7.Future<_i3.Row> incrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? max, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), + returnValue: _i7.Future<_i3.Row>.value( + _FakeRow_4( + this, + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), + ), + ), + ) + as _i7.Future<_i3.Row>); +} + +/// A class which mocks [Storage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockStorage extends _i1.Mock implements _i6.Storage { + MockStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i7.Future<_i3.FileList> listFiles({ + required String? bucketId, + List? queries, + String? search, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + returnValue: _i7.Future<_i3.FileList>.value( + _FakeFileList_5( + this, + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + ), + ), + ) + as _i7.Future<_i3.FileList>); + + @override + _i7.Future<_i3.File> createFile({ + required String? bucketId, + required String? fileId, + required _i8.InputFile? file, + List? permissions, + dynamic Function(_i9.UploadProgress)? onProgress, + }) => + (super.noSuchMethod( + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future<_i3.File> getFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future<_i3.File> updateFile({ + required String? bucketId, + required String? fileId, + String? name, + List? permissions, + }) => + (super.noSuchMethod( + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + returnValue: _i7.Future<_i3.File>.value( + _FakeFile_6( + this, + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + ), + ), + ) + as _i7.Future<_i3.File>); + + @override + _i7.Future deleteFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i10.Uint8List> getFileDownload({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileDownload, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); + + @override + _i7.Future<_i10.Uint8List> getFilePreview({ + required String? bucketId, + required String? fileId, + int? width, + int? height, + _i11.ImageGravity? gravity, + int? quality, + int? borderWidth, + String? borderColor, + int? borderRadius, + double? opacity, + int? rotation, + String? background, + _i11.ImageFormat? output, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFilePreview, [], { + #bucketId: bucketId, + #fileId: fileId, + #width: width, + #height: height, + #gravity: gravity, + #quality: quality, + #borderWidth: borderWidth, + #borderColor: borderColor, + #borderRadius: borderRadius, + #opacity: opacity, + #rotation: rotation, + #background: background, + #output: output, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); + + @override + _i7.Future<_i10.Uint8List> getFileView({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileView, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i7.Future<_i10.Uint8List>.value(_i10.Uint8List(0)), + ) + as _i7.Future<_i10.Uint8List>); +} + +/// A class which mocks [Account]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAccount extends _i1.Mock implements _i6.Account { + MockAccount() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i7.Future<_i3.User> get() => + (super.noSuchMethod( + Invocation.method(#get, []), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#get, [])), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.User> create({ + required String? userId, + required String? email, + required String? password, + String? name, + }) => + (super.noSuchMethod( + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.User> updateEmail({ + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.IdentityList> listIdentities({ + List? queries, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, + }), + returnValue: _i7.Future<_i3.IdentityList>.value( + _FakeIdentityList_8( + this, + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, + }), + ), + ), + ) + as _i7.Future<_i3.IdentityList>); + + @override + _i7.Future deleteIdentity({required String? identityId}) => + (super.noSuchMethod( + Invocation.method(#deleteIdentity, [], {#identityId: identityId}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Jwt> createJWT() => + (super.noSuchMethod( + Invocation.method(#createJWT, []), + returnValue: _i7.Future<_i3.Jwt>.value( + _FakeJwt_9(this, Invocation.method(#createJWT, [])), + ), + ) + as _i7.Future<_i3.Jwt>); + + @override + _i7.Future<_i3.LogList> listLogs({List? queries, bool? total}) => + (super.noSuchMethod( + Invocation.method(#listLogs, [], { + #queries: queries, + #total: total, + }), + returnValue: _i7.Future<_i3.LogList>.value( + _FakeLogList_10( + this, + Invocation.method(#listLogs, [], { + #queries: queries, + #total: total, + }), + ), + ), + ) + as _i7.Future<_i3.LogList>); + + @override + _i7.Future<_i3.User> updateMFA({required bool? mfa}) => + (super.noSuchMethod( + Invocation.method(#updateMFA, [], {#mfa: mfa}), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.MfaType> createMfaAuthenticator({ + required _i11.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#createMfaAuthenticator, [], {#type: type}), + returnValue: _i7.Future<_i3.MfaType>.value( + _FakeMfaType_11( + this, + Invocation.method(#createMfaAuthenticator, [], {#type: type}), + ), + ), + ) + as _i7.Future<_i3.MfaType>); + + @override + _i7.Future<_i3.MfaType> createMFAAuthenticator({ + required _i11.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#createMFAAuthenticator, [], {#type: type}), + returnValue: _i7.Future<_i3.MfaType>.value( + _FakeMfaType_11( + this, + Invocation.method(#createMFAAuthenticator, [], {#type: type}), + ), + ), + ) + as _i7.Future<_i3.MfaType>); + + @override + _i7.Future<_i3.User> updateMfaAuthenticator({ + required _i11.AuthenticatorType? type, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.User> updateMFAAuthenticator({ + required _i11.AuthenticatorType? type, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future deleteMfaAuthenticator({ + required _i11.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#deleteMfaAuthenticator, [], {#type: type}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future deleteMFAAuthenticator({ + required _i11.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#deleteMFAAuthenticator, [], {#type: type}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.MfaChallenge> createMfaChallenge({ + required _i11.AuthenticationFactor? factor, + }) => + (super.noSuchMethod( + Invocation.method(#createMfaChallenge, [], {#factor: factor}), + returnValue: _i7.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_12( + this, + Invocation.method(#createMfaChallenge, [], {#factor: factor}), + ), + ), + ) + as _i7.Future<_i3.MfaChallenge>); + + @override + _i7.Future<_i3.MfaChallenge> createMFAChallenge({ + required _i11.AuthenticationFactor? factor, + }) => + (super.noSuchMethod( + Invocation.method(#createMFAChallenge, [], {#factor: factor}), + returnValue: _i7.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_12( + this, + Invocation.method(#createMFAChallenge, [], {#factor: factor}), + ), + ), + ) + as _i7.Future<_i3.MfaChallenge>); + + @override + _i7.Future<_i3.Session> updateMfaChallenge({ + required String? challengeId, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> updateMFAChallenge({ + required String? challengeId, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.MfaFactors> listMfaFactors() => + (super.noSuchMethod( + Invocation.method(#listMfaFactors, []), + returnValue: _i7.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_14(this, Invocation.method(#listMfaFactors, [])), + ), + ) + as _i7.Future<_i3.MfaFactors>); + + @override + _i7.Future<_i3.MfaFactors> listMFAFactors() => + (super.noSuchMethod( + Invocation.method(#listMFAFactors, []), + returnValue: _i7.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_14(this, Invocation.method(#listMFAFactors, [])), + ), + ) + as _i7.Future<_i3.MfaFactors>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#getMfaRecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#getMfaRecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#getMFARecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#getMFARecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#createMfaRecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#createMfaRecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#createMFARecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#createMFARecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMfaRecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#updateMfaRecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMFARecoveryCodes, []), + returnValue: _i7.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_15( + this, + Invocation.method(#updateMFARecoveryCodes, []), + ), + ), + ) + as _i7.Future<_i3.MfaRecoveryCodes>); + + @override + _i7.Future<_i3.User> updateName({required String? name}) => + (super.noSuchMethod( + Invocation.method(#updateName, [], {#name: name}), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updateName, [], {#name: name}), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.User> updatePassword({ + required String? password, + String? oldPassword, + }) => + (super.noSuchMethod( + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.User> updatePhone({ + required String? phone, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, + }), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, + }), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.Preferences> getPrefs() => + (super.noSuchMethod( + Invocation.method(#getPrefs, []), + returnValue: _i7.Future<_i3.Preferences>.value( + _FakePreferences_16(this, Invocation.method(#getPrefs, [])), + ), + ) + as _i7.Future<_i3.Preferences>); + + @override + _i7.Future<_i3.User> updatePrefs({required Map? prefs}) => + (super.noSuchMethod( + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7( + this, + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + ), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.Token> createRecovery({ + required String? email, + required String? url, + }) => + (super.noSuchMethod( + Invocation.method(#createRecovery, [], {#email: email, #url: url}), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createRecovery, [], { + #email: email, + #url: url, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> updateRecovery({ + required String? userId, + required String? secret, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.SessionList> listSessions() => + (super.noSuchMethod( + Invocation.method(#listSessions, []), + returnValue: _i7.Future<_i3.SessionList>.value( + _FakeSessionList_18(this, Invocation.method(#listSessions, [])), + ), + ) + as _i7.Future<_i3.SessionList>); + + @override + _i7.Future deleteSessions() => + (super.noSuchMethod( + Invocation.method(#deleteSessions, []), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Session> createAnonymousSession() => + (super.noSuchMethod( + Invocation.method(#createAnonymousSession, []), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#createAnonymousSession, []), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> createEmailPasswordSession({ + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> updateMagicURLSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future createOAuth2Session({ + required _i11.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => + (super.noSuchMethod( + Invocation.method(#createOAuth2Session, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Session> updatePhoneSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> createSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> getSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#getSession, [], {#sessionId: sessionId}), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#getSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future<_i3.Session> updateSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + returnValue: _i7.Future<_i3.Session>.value( + _FakeSession_13( + this, + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i7.Future<_i3.Session>); + + @override + _i7.Future deleteSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#deleteSession, [], {#sessionId: sessionId}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.User> updateStatus() => + (super.noSuchMethod( + Invocation.method(#updateStatus, []), + returnValue: _i7.Future<_i3.User>.value( + _FakeUser_7(this, Invocation.method(#updateStatus, [])), + ), + ) + as _i7.Future<_i3.User>); + + @override + _i7.Future<_i3.Target> createPushTarget({ + required String? targetId, + required String? identifier, + String? providerId, + }) => + (super.noSuchMethod( + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + returnValue: _i7.Future<_i3.Target>.value( + _FakeTarget_19( + this, + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + ), + ), + ) + as _i7.Future<_i3.Target>); + + @override + _i7.Future<_i3.Target> updatePushTarget({ + required String? targetId, + required String? identifier, + }) => + (super.noSuchMethod( + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + returnValue: _i7.Future<_i3.Target>.value( + _FakeTarget_19( + this, + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + ), + ), + ) + as _i7.Future<_i3.Target>); + + @override + _i7.Future deletePushTarget({required String? targetId}) => + (super.noSuchMethod( + Invocation.method(#deletePushTarget, [], {#targetId: targetId}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Token> createEmailToken({ + required String? userId, + required String? email, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> createMagicURLToken({ + required String? userId, + required String? email, + String? url, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future createOAuth2Token({ + required _i11.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => + (super.noSuchMethod( + Invocation.method(#createOAuth2Token, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.Token> createPhoneToken({ + required String? userId, + required String? phone, + }) => + (super.noSuchMethod( + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> createEmailVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createEmailVerification, [], {#url: url}), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createEmailVerification, [], {#url: url}), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> createVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createVerification, [], {#url: url}), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createVerification, [], {#url: url}), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> updateEmailVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> updateVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> createPhoneVerification() => + (super.noSuchMethod( + Invocation.method(#createPhoneVerification, []), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#createPhoneVerification, []), + ), + ), + ) + as _i7.Future<_i3.Token>); + + @override + _i7.Future<_i3.Token> updatePhoneVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i7.Future<_i3.Token>.value( + _FakeToken_17( + this, + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i7.Future<_i3.Token>); +} + +/// A class which mocks [FirebaseMessaging]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { + MockFirebaseMessaging() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.FirebaseApp get app => + (super.noSuchMethod( + Invocation.getter(#app), + returnValue: _FakeFirebaseApp_20(this, Invocation.getter(#app)), + ) + as _i4.FirebaseApp); + + @override + bool get isAutoInitEnabled => + (super.noSuchMethod( + Invocation.getter(#isAutoInitEnabled), + returnValue: false, + ) + as bool); + + @override + _i7.Stream get onTokenRefresh => + (super.noSuchMethod( + Invocation.getter(#onTokenRefresh), + returnValue: _i7.Stream.empty(), + ) + as _i7.Stream); + + @override + set app(_i4.FirebaseApp? value) => super.noSuchMethod( + Invocation.setter(#app, value), + returnValueForMissingStub: null, + ); + + @override + Map get pluginConstants => + (super.noSuchMethod( + Invocation.getter(#pluginConstants), + returnValue: {}, + ) + as Map); + + @override + _i7.Future<_i5.RemoteMessage?> getInitialMessage() => + (super.noSuchMethod( + Invocation.method(#getInitialMessage, []), + returnValue: _i7.Future<_i5.RemoteMessage?>.value(), + ) + as _i7.Future<_i5.RemoteMessage?>); + + @override + _i7.Future deleteToken() => + (super.noSuchMethod( + Invocation.method(#deleteToken, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future getAPNSToken() => + (super.noSuchMethod( + Invocation.method(#getAPNSToken, []), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future getToken({String? vapidKey}) => + (super.noSuchMethod( + Invocation.method(#getToken, [], {#vapidKey: vapidKey}), + returnValue: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future isSupported() => + (super.noSuchMethod( + Invocation.method(#isSupported, []), + returnValue: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future<_i5.NotificationSettings> getNotificationSettings() => + (super.noSuchMethod( + Invocation.method(#getNotificationSettings, []), + returnValue: _i7.Future<_i5.NotificationSettings>.value( + _FakeNotificationSettings_21( + this, + Invocation.method(#getNotificationSettings, []), + ), + ), + ) + as _i7.Future<_i5.NotificationSettings>); + + @override + _i7.Future<_i5.NotificationSettings> requestPermission({ + bool? alert = true, + bool? announcement = false, + bool? badge = true, + bool? carPlay = false, + bool? criticalAlert = false, + bool? provisional = false, + bool? sound = true, + bool? providesAppNotificationSettings = false, + }) => + (super.noSuchMethod( + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: providesAppNotificationSettings, + }), + returnValue: _i7.Future<_i5.NotificationSettings>.value( + _FakeNotificationSettings_21( + this, + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: + providesAppNotificationSettings, + }), + ), + ), + ) + as _i7.Future<_i5.NotificationSettings>); + + @override + _i7.Future setAutoInitEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setAutoInitEnabled, [enabled]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future setForegroundNotificationPresentationOptions({ + bool? alert = false, + bool? badge = false, + bool? sound = false, + }) => + (super.noSuchMethod( + Invocation.method( + #setForegroundNotificationPresentationOptions, + [], + {#alert: alert, #badge: badge, #sound: sound}, + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future subscribeToTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#subscribeToTopic, [topic]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future unsubscribeFromTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#unsubscribeFromTopic, [topic]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} diff --git a/test/helpers/test_root_container.mocks.dart b/test/helpers/test_root_container.mocks.dart new file mode 100644 index 00000000..128e0b80 --- /dev/null +++ b/test/helpers/test_root_container.mocks.dart @@ -0,0 +1,2054 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in resonate/test/helpers/test_root_container.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:appwrite/appwrite.dart' as _i8; +import 'package:appwrite/enums.dart' as _i9; +import 'package:appwrite/models.dart' as _i3; +import 'package:appwrite/src/client.dart' as _i2; +import 'package:appwrite/src/realtime.dart' as _i10; +import 'package:appwrite/src/realtime_message.dart' as _i11; +import 'package:appwrite/src/realtime_subscription.dart' as _i4; +import 'package:firebase_core/firebase_core.dart' as _i6; +import 'package:firebase_messaging/firebase_messaging.dart' as _i12; +import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' + as _i7; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i13; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { + _FakeClient_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeUser_1 extends _i1.SmartFake implements _i3.User { + _FakeUser_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeIdentityList_2 extends _i1.SmartFake implements _i3.IdentityList { + _FakeIdentityList_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeJwt_3 extends _i1.SmartFake implements _i3.Jwt { + _FakeJwt_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeLogList_4 extends _i1.SmartFake implements _i3.LogList { + _FakeLogList_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaType_5 extends _i1.SmartFake implements _i3.MfaType { + _FakeMfaType_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaChallenge_6 extends _i1.SmartFake implements _i3.MfaChallenge { + _FakeMfaChallenge_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSession_7 extends _i1.SmartFake implements _i3.Session { + _FakeSession_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaFactors_8 extends _i1.SmartFake implements _i3.MfaFactors { + _FakeMfaFactors_8(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMfaRecoveryCodes_9 extends _i1.SmartFake + implements _i3.MfaRecoveryCodes { + _FakeMfaRecoveryCodes_9(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakePreferences_10 extends _i1.SmartFake implements _i3.Preferences { + _FakePreferences_10(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeToken_11 extends _i1.SmartFake implements _i3.Token { + _FakeToken_11(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSessionList_12 extends _i1.SmartFake implements _i3.SessionList { + _FakeSessionList_12(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTarget_13 extends _i1.SmartFake implements _i3.Target { + _FakeTarget_13(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransactionList_14 extends _i1.SmartFake + implements _i3.TransactionList { + _FakeTransactionList_14(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransaction_15 extends _i1.SmartFake implements _i3.Transaction { + _FakeTransaction_15(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRowList_16 extends _i1.SmartFake implements _i3.RowList { + _FakeRowList_16(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRow_17 extends _i1.SmartFake implements _i3.Row { + _FakeRow_17(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRealtimeSubscription_18 extends _i1.SmartFake + implements _i4.RealtimeSubscription { + _FakeRealtimeSubscription_18(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeExecutionList_19 extends _i1.SmartFake implements _i3.ExecutionList { + _FakeExecutionList_19(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeExecution_20 extends _i1.SmartFake implements _i3.Execution { + _FakeExecution_20(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStreamController_21 extends _i1.SmartFake + implements _i5.StreamController { + _FakeStreamController_21(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFirebaseApp_22 extends _i1.SmartFake implements _i6.FirebaseApp { + _FakeFirebaseApp_22(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeNotificationSettings_23 extends _i1.SmartFake + implements _i7.NotificationSettings { + _FakeNotificationSettings_23(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Account]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAccount extends _i1.Mock implements _i8.Account { + MockAccount() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i5.Future<_i3.User> get() => + (super.noSuchMethod( + Invocation.method(#get, []), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#get, [])), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> create({ + required String? userId, + required String? email, + required String? password, + String? name, + }) => + (super.noSuchMethod( + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#create, [], { + #userId: userId, + #email: email, + #password: password, + #name: name, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> updateEmail({ + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updateEmail, [], { + #email: email, + #password: password, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.IdentityList> listIdentities({ + List? queries, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, + }), + returnValue: _i5.Future<_i3.IdentityList>.value( + _FakeIdentityList_2( + this, + Invocation.method(#listIdentities, [], { + #queries: queries, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.IdentityList>); + + @override + _i5.Future deleteIdentity({required String? identityId}) => + (super.noSuchMethod( + Invocation.method(#deleteIdentity, [], {#identityId: identityId}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Jwt> createJWT() => + (super.noSuchMethod( + Invocation.method(#createJWT, []), + returnValue: _i5.Future<_i3.Jwt>.value( + _FakeJwt_3(this, Invocation.method(#createJWT, [])), + ), + ) + as _i5.Future<_i3.Jwt>); + + @override + _i5.Future<_i3.LogList> listLogs({List? queries, bool? total}) => + (super.noSuchMethod( + Invocation.method(#listLogs, [], { + #queries: queries, + #total: total, + }), + returnValue: _i5.Future<_i3.LogList>.value( + _FakeLogList_4( + this, + Invocation.method(#listLogs, [], { + #queries: queries, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.LogList>); + + @override + _i5.Future<_i3.User> updateMFA({required bool? mfa}) => + (super.noSuchMethod( + Invocation.method(#updateMFA, [], {#mfa: mfa}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#updateMFA, [], {#mfa: mfa})), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.MfaType> createMfaAuthenticator({ + required _i9.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#createMfaAuthenticator, [], {#type: type}), + returnValue: _i5.Future<_i3.MfaType>.value( + _FakeMfaType_5( + this, + Invocation.method(#createMfaAuthenticator, [], {#type: type}), + ), + ), + ) + as _i5.Future<_i3.MfaType>); + + @override + _i5.Future<_i3.MfaType> createMFAAuthenticator({ + required _i9.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#createMFAAuthenticator, [], {#type: type}), + returnValue: _i5.Future<_i3.MfaType>.value( + _FakeMfaType_5( + this, + Invocation.method(#createMFAAuthenticator, [], {#type: type}), + ), + ), + ) + as _i5.Future<_i3.MfaType>); + + @override + _i5.Future<_i3.User> updateMfaAuthenticator({ + required _i9.AuthenticatorType? type, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updateMfaAuthenticator, [], { + #type: type, + #otp: otp, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> updateMFAAuthenticator({ + required _i9.AuthenticatorType? type, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updateMFAAuthenticator, [], { + #type: type, + #otp: otp, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future deleteMfaAuthenticator({ + required _i9.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#deleteMfaAuthenticator, [], {#type: type}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteMFAAuthenticator({ + required _i9.AuthenticatorType? type, + }) => + (super.noSuchMethod( + Invocation.method(#deleteMFAAuthenticator, [], {#type: type}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.MfaChallenge> createMfaChallenge({ + required _i9.AuthenticationFactor? factor, + }) => + (super.noSuchMethod( + Invocation.method(#createMfaChallenge, [], {#factor: factor}), + returnValue: _i5.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_6( + this, + Invocation.method(#createMfaChallenge, [], {#factor: factor}), + ), + ), + ) + as _i5.Future<_i3.MfaChallenge>); + + @override + _i5.Future<_i3.MfaChallenge> createMFAChallenge({ + required _i9.AuthenticationFactor? factor, + }) => + (super.noSuchMethod( + Invocation.method(#createMFAChallenge, [], {#factor: factor}), + returnValue: _i5.Future<_i3.MfaChallenge>.value( + _FakeMfaChallenge_6( + this, + Invocation.method(#createMFAChallenge, [], {#factor: factor}), + ), + ), + ) + as _i5.Future<_i3.MfaChallenge>); + + @override + _i5.Future<_i3.Session> updateMfaChallenge({ + required String? challengeId, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateMfaChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> updateMFAChallenge({ + required String? challengeId, + required String? otp, + }) => + (super.noSuchMethod( + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateMFAChallenge, [], { + #challengeId: challengeId, + #otp: otp, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.MfaFactors> listMfaFactors() => + (super.noSuchMethod( + Invocation.method(#listMfaFactors, []), + returnValue: _i5.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_8(this, Invocation.method(#listMfaFactors, [])), + ), + ) + as _i5.Future<_i3.MfaFactors>); + + @override + _i5.Future<_i3.MfaFactors> listMFAFactors() => + (super.noSuchMethod( + Invocation.method(#listMFAFactors, []), + returnValue: _i5.Future<_i3.MfaFactors>.value( + _FakeMfaFactors_8(this, Invocation.method(#listMFAFactors, [])), + ), + ) + as _i5.Future<_i3.MfaFactors>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> getMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#getMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#getMfaRecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> getMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#getMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#getMFARecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> createMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#createMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#createMfaRecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> createMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#createMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#createMFARecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> updateMfaRecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMfaRecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#updateMfaRecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.MfaRecoveryCodes> updateMFARecoveryCodes() => + (super.noSuchMethod( + Invocation.method(#updateMFARecoveryCodes, []), + returnValue: _i5.Future<_i3.MfaRecoveryCodes>.value( + _FakeMfaRecoveryCodes_9( + this, + Invocation.method(#updateMFARecoveryCodes, []), + ), + ), + ) + as _i5.Future<_i3.MfaRecoveryCodes>); + + @override + _i5.Future<_i3.User> updateName({required String? name}) => + (super.noSuchMethod( + Invocation.method(#updateName, [], {#name: name}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updateName, [], {#name: name}), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> updatePassword({ + required String? password, + String? oldPassword, + }) => + (super.noSuchMethod( + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updatePassword, [], { + #password: password, + #oldPassword: oldPassword, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.User> updatePhone({ + required String? phone, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, + }), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updatePhone, [], { + #phone: phone, + #password: password, + }), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.Preferences> getPrefs() => + (super.noSuchMethod( + Invocation.method(#getPrefs, []), + returnValue: _i5.Future<_i3.Preferences>.value( + _FakePreferences_10(this, Invocation.method(#getPrefs, [])), + ), + ) + as _i5.Future<_i3.Preferences>); + + @override + _i5.Future<_i3.User> updatePrefs({required Map? prefs}) => + (super.noSuchMethod( + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1( + this, + Invocation.method(#updatePrefs, [], {#prefs: prefs}), + ), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.Token> createRecovery({ + required String? email, + required String? url, + }) => + (super.noSuchMethod( + Invocation.method(#createRecovery, [], {#email: email, #url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createRecovery, [], { + #email: email, + #url: url, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updateRecovery({ + required String? userId, + required String? secret, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updateRecovery, [], { + #userId: userId, + #secret: secret, + #password: password, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.SessionList> listSessions() => + (super.noSuchMethod( + Invocation.method(#listSessions, []), + returnValue: _i5.Future<_i3.SessionList>.value( + _FakeSessionList_12(this, Invocation.method(#listSessions, [])), + ), + ) + as _i5.Future<_i3.SessionList>); + + @override + _i5.Future deleteSessions() => + (super.noSuchMethod( + Invocation.method(#deleteSessions, []), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Session> createAnonymousSession() => + (super.noSuchMethod( + Invocation.method(#createAnonymousSession, []), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#createAnonymousSession, []), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> createEmailPasswordSession({ + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#createEmailPasswordSession, [], { + #email: email, + #password: password, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> updateMagicURLSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateMagicURLSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future createOAuth2Session({ + required _i9.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => + (super.noSuchMethod( + Invocation.method(#createOAuth2Session, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Session> updatePhoneSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updatePhoneSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> createSession({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#createSession, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> getSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#getSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#getSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future<_i3.Session> updateSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_7( + this, + Invocation.method(#updateSession, [], {#sessionId: sessionId}), + ), + ), + ) + as _i5.Future<_i3.Session>); + + @override + _i5.Future deleteSession({required String? sessionId}) => + (super.noSuchMethod( + Invocation.method(#deleteSession, [], {#sessionId: sessionId}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.User> updateStatus() => + (super.noSuchMethod( + Invocation.method(#updateStatus, []), + returnValue: _i5.Future<_i3.User>.value( + _FakeUser_1(this, Invocation.method(#updateStatus, [])), + ), + ) + as _i5.Future<_i3.User>); + + @override + _i5.Future<_i3.Target> createPushTarget({ + required String? targetId, + required String? identifier, + String? providerId, + }) => + (super.noSuchMethod( + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + returnValue: _i5.Future<_i3.Target>.value( + _FakeTarget_13( + this, + Invocation.method(#createPushTarget, [], { + #targetId: targetId, + #identifier: identifier, + #providerId: providerId, + }), + ), + ), + ) + as _i5.Future<_i3.Target>); + + @override + _i5.Future<_i3.Target> updatePushTarget({ + required String? targetId, + required String? identifier, + }) => + (super.noSuchMethod( + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + returnValue: _i5.Future<_i3.Target>.value( + _FakeTarget_13( + this, + Invocation.method(#updatePushTarget, [], { + #targetId: targetId, + #identifier: identifier, + }), + ), + ), + ) + as _i5.Future<_i3.Target>); + + @override + _i5.Future deletePushTarget({required String? targetId}) => + (super.noSuchMethod( + Invocation.method(#deletePushTarget, [], {#targetId: targetId}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Token> createEmailToken({ + required String? userId, + required String? email, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createEmailToken, [], { + #userId: userId, + #email: email, + #phrase: phrase, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createMagicURLToken({ + required String? userId, + required String? email, + String? url, + bool? phrase, + }) => + (super.noSuchMethod( + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createMagicURLToken, [], { + #userId: userId, + #email: email, + #url: url, + #phrase: phrase, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future createOAuth2Token({ + required _i9.OAuthProvider? provider, + String? success, + String? failure, + List? scopes, + }) => + (super.noSuchMethod( + Invocation.method(#createOAuth2Token, [], { + #provider: provider, + #success: success, + #failure: failure, + #scopes: scopes, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Token> createPhoneToken({ + required String? userId, + required String? phone, + }) => + (super.noSuchMethod( + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createPhoneToken, [], { + #userId: userId, + #phone: phone, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createEmailVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createEmailVerification, [], {#url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createEmailVerification, [], {#url: url}), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createVerification({required String? url}) => + (super.noSuchMethod( + Invocation.method(#createVerification, [], {#url: url}), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createVerification, [], {#url: url}), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updateEmailVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updateEmailVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updateVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updateVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> createPhoneVerification() => + (super.noSuchMethod( + Invocation.method(#createPhoneVerification, []), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#createPhoneVerification, []), + ), + ), + ) + as _i5.Future<_i3.Token>); + + @override + _i5.Future<_i3.Token> updatePhoneVerification({ + required String? userId, + required String? secret, + }) => + (super.noSuchMethod( + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + returnValue: _i5.Future<_i3.Token>.value( + _FakeToken_11( + this, + Invocation.method(#updatePhoneVerification, [], { + #userId: userId, + #secret: secret, + }), + ), + ), + ) + as _i5.Future<_i3.Token>); +} + +/// A class which mocks [TablesDB]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTablesDB extends _i1.Mock implements _i8.TablesDB { + MockTablesDB() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => + (super.noSuchMethod( + Invocation.method(#listTransactions, [], {#queries: queries}), + returnValue: _i5.Future<_i3.TransactionList>.value( + _FakeTransactionList_14( + this, + Invocation.method(#listTransactions, [], {#queries: queries}), + ), + ), + ) + as _i5.Future<_i3.TransactionList>); + + @override + _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => + (super.noSuchMethod( + Invocation.method(#createTransaction, [], {#ttl: ttl}), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#createTransaction, [], {#ttl: ttl}), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.Transaction> getTransaction({ + required String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#getTransaction, [], { + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.Transaction> updateTransaction({ + required String? transactionId, + bool? commit, + bool? rollback, + }) => + (super.noSuchMethod( + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#updateTransaction, [], { + #transactionId: transactionId, + #commit: commit, + #rollback: rollback, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future deleteTransaction({required String? transactionId}) => + (super.noSuchMethod( + Invocation.method(#deleteTransaction, [], { + #transactionId: transactionId, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Transaction> createOperations({ + required String? transactionId, + List>? operations, + }) => + (super.noSuchMethod( + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + returnValue: _i5.Future<_i3.Transaction>.value( + _FakeTransaction_15( + this, + Invocation.method(#createOperations, [], { + #transactionId: transactionId, + #operations: operations, + }), + ), + ), + ) + as _i5.Future<_i3.Transaction>); + + @override + _i5.Future<_i3.RowList> listRows({ + required String? databaseId, + required String? tableId, + List? queries, + String? transactionId, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + returnValue: _i5.Future<_i3.RowList>.value( + _FakeRowList_16( + this, + Invocation.method(#listRows, [], { + #databaseId: databaseId, + #tableId: tableId, + #queries: queries, + #transactionId: transactionId, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.RowList>); + + @override + _i5.Future<_i3.Row> createRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + required Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#createRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> getRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + List? queries, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#getRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #queries: queries, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> upsertRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#upsertRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> updateRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + Map? data, + List? permissions, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#updateRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #data: data, + #permissions: permissions, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future deleteRow({ + required String? databaseId, + required String? tableId, + required String? rowId, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteRow, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #transactionId: transactionId, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i3.Row> decrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? min, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#decrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #min: min, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); + + @override + _i5.Future<_i3.Row> incrementRowColumn({ + required String? databaseId, + required String? tableId, + required String? rowId, + required String? column, + double? value, + double? max, + String? transactionId, + }) => + (super.noSuchMethod( + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), + returnValue: _i5.Future<_i3.Row>.value( + _FakeRow_17( + this, + Invocation.method(#incrementRowColumn, [], { + #databaseId: databaseId, + #tableId: tableId, + #rowId: rowId, + #column: column, + #value: value, + #max: max, + #transactionId: transactionId, + }), + ), + ), + ) + as _i5.Future<_i3.Row>); +} + +/// A class which mocks [Realtime]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRealtime extends _i1.Mock implements _i10.Realtime { + MockRealtime() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i4.RealtimeSubscription subscribe(List? channels) => + (super.noSuchMethod( + Invocation.method(#subscribe, [channels]), + returnValue: _FakeRealtimeSubscription_18( + this, + Invocation.method(#subscribe, [channels]), + ), + ) + as _i4.RealtimeSubscription); +} + +/// A class which mocks [Functions]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFunctions extends _i1.Mock implements _i8.Functions { + MockFunctions() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i5.Future<_i3.ExecutionList> listExecutions({ + required String? functionId, + List? queries, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), + returnValue: _i5.Future<_i3.ExecutionList>.value( + _FakeExecutionList_19( + this, + Invocation.method(#listExecutions, [], { + #functionId: functionId, + #queries: queries, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.ExecutionList>); + + @override + _i5.Future<_i3.Execution> createExecution({ + required String? functionId, + String? body, + bool? xasync, + String? path, + _i9.ExecutionMethod? method, + Map? headers, + String? scheduledAt, + }) => + (super.noSuchMethod( + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, + }), + returnValue: _i5.Future<_i3.Execution>.value( + _FakeExecution_20( + this, + Invocation.method(#createExecution, [], { + #functionId: functionId, + #body: body, + #xasync: xasync, + #path: path, + #method: method, + #headers: headers, + #scheduledAt: scheduledAt, + }), + ), + ), + ) + as _i5.Future<_i3.Execution>); + + @override + _i5.Future<_i3.Execution> getExecution({ + required String? functionId, + required String? executionId, + }) => + (super.noSuchMethod( + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, + }), + returnValue: _i5.Future<_i3.Execution>.value( + _FakeExecution_20( + this, + Invocation.method(#getExecution, [], { + #functionId: functionId, + #executionId: executionId, + }), + ), + ), + ) + as _i5.Future<_i3.Execution>); +} + +/// A class which mocks [RealtimeSubscription]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRealtimeSubscription extends _i1.Mock + implements _i4.RealtimeSubscription { + MockRealtimeSubscription() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Stream<_i11.RealtimeMessage> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i5.Stream<_i11.RealtimeMessage>.empty(), + ) + as _i5.Stream<_i11.RealtimeMessage>); + + @override + _i5.StreamController<_i11.RealtimeMessage> get controller => + (super.noSuchMethod( + Invocation.getter(#controller), + returnValue: _FakeStreamController_21<_i11.RealtimeMessage>( + this, + Invocation.getter(#controller), + ), + ) + as _i5.StreamController<_i11.RealtimeMessage>); + + @override + List get channels => + (super.noSuchMethod(Invocation.getter(#channels), returnValue: []) + as List); + + @override + _i5.Future Function() get close => + (super.noSuchMethod( + Invocation.getter(#close), + returnValue: () => _i5.Future.value(), + ) + as _i5.Future Function()); + + @override + set channels(List? value) => super.noSuchMethod( + Invocation.setter(#channels, value), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [FirebaseMessaging]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { + MockFirebaseMessaging() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.FirebaseApp get app => + (super.noSuchMethod( + Invocation.getter(#app), + returnValue: _FakeFirebaseApp_22(this, Invocation.getter(#app)), + ) + as _i6.FirebaseApp); + + @override + bool get isAutoInitEnabled => + (super.noSuchMethod( + Invocation.getter(#isAutoInitEnabled), + returnValue: false, + ) + as bool); + + @override + _i5.Stream get onTokenRefresh => + (super.noSuchMethod( + Invocation.getter(#onTokenRefresh), + returnValue: _i5.Stream.empty(), + ) + as _i5.Stream); + + @override + set app(_i6.FirebaseApp? value) => super.noSuchMethod( + Invocation.setter(#app, value), + returnValueForMissingStub: null, + ); + + @override + Map get pluginConstants => + (super.noSuchMethod( + Invocation.getter(#pluginConstants), + returnValue: {}, + ) + as Map); + + @override + _i5.Future<_i7.RemoteMessage?> getInitialMessage() => + (super.noSuchMethod( + Invocation.method(#getInitialMessage, []), + returnValue: _i5.Future<_i7.RemoteMessage?>.value(), + ) + as _i5.Future<_i7.RemoteMessage?>); + + @override + _i5.Future deleteToken() => + (super.noSuchMethod( + Invocation.method(#deleteToken, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future getAPNSToken() => + (super.noSuchMethod( + Invocation.method(#getAPNSToken, []), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future getToken({String? vapidKey}) => + (super.noSuchMethod( + Invocation.method(#getToken, [], {#vapidKey: vapidKey}), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future isSupported() => + (super.noSuchMethod( + Invocation.method(#isSupported, []), + returnValue: _i5.Future.value(false), + ) + as _i5.Future); + + @override + _i5.Future<_i7.NotificationSettings> getNotificationSettings() => + (super.noSuchMethod( + Invocation.method(#getNotificationSettings, []), + returnValue: _i5.Future<_i7.NotificationSettings>.value( + _FakeNotificationSettings_23( + this, + Invocation.method(#getNotificationSettings, []), + ), + ), + ) + as _i5.Future<_i7.NotificationSettings>); + + @override + _i5.Future<_i7.NotificationSettings> requestPermission({ + bool? alert = true, + bool? announcement = false, + bool? badge = true, + bool? carPlay = false, + bool? criticalAlert = false, + bool? provisional = false, + bool? sound = true, + bool? providesAppNotificationSettings = false, + }) => + (super.noSuchMethod( + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: providesAppNotificationSettings, + }), + returnValue: _i5.Future<_i7.NotificationSettings>.value( + _FakeNotificationSettings_23( + this, + Invocation.method(#requestPermission, [], { + #alert: alert, + #announcement: announcement, + #badge: badge, + #carPlay: carPlay, + #criticalAlert: criticalAlert, + #provisional: provisional, + #sound: sound, + #providesAppNotificationSettings: + providesAppNotificationSettings, + }), + ), + ), + ) + as _i5.Future<_i7.NotificationSettings>); + + @override + _i5.Future setAutoInitEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setAutoInitEnabled, [enabled]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future setForegroundNotificationPresentationOptions({ + bool? alert = false, + bool? badge = false, + bool? sound = false, + }) => + (super.noSuchMethod( + Invocation.method( + #setForegroundNotificationPresentationOptions, + [], + {#alert: alert, #badge: badge, #sound: sound}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future subscribeToTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#subscribeToTopic, [topic]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future unsubscribeFromTopic(String? topic) => + (super.noSuchMethod( + Invocation.method(#unsubscribeFromTopic, [topic]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); +} + +/// A class which mocks [Execution]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockExecution extends _i1.Mock implements _i3.Execution { + MockExecution() { + _i1.throwOnMissingStub(this); + } + + @override + String get $id => + (super.noSuchMethod( + Invocation.getter(#$id), + returnValue: _i13.dummyValue(this, Invocation.getter(#$id)), + ) + as String); + + @override + String get $createdAt => + (super.noSuchMethod( + Invocation.getter(#$createdAt), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#$createdAt), + ), + ) + as String); + + @override + String get $updatedAt => + (super.noSuchMethod( + Invocation.getter(#$updatedAt), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#$updatedAt), + ), + ) + as String); + + @override + List get $permissions => + (super.noSuchMethod( + Invocation.getter(#$permissions), + returnValue: [], + ) + as List); + + @override + String get functionId => + (super.noSuchMethod( + Invocation.getter(#functionId), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#functionId), + ), + ) + as String); + + @override + String get deploymentId => + (super.noSuchMethod( + Invocation.getter(#deploymentId), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#deploymentId), + ), + ) + as String); + + @override + _i9.ExecutionTrigger get trigger => + (super.noSuchMethod( + Invocation.getter(#trigger), + returnValue: _i9.ExecutionTrigger.http, + ) + as _i9.ExecutionTrigger); + + @override + _i9.ExecutionStatus get status => + (super.noSuchMethod( + Invocation.getter(#status), + returnValue: _i9.ExecutionStatus.waiting, + ) + as _i9.ExecutionStatus); + + @override + String get requestMethod => + (super.noSuchMethod( + Invocation.getter(#requestMethod), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#requestMethod), + ), + ) + as String); + + @override + String get requestPath => + (super.noSuchMethod( + Invocation.getter(#requestPath), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#requestPath), + ), + ) + as String); + + @override + List<_i3.Headers> get requestHeaders => + (super.noSuchMethod( + Invocation.getter(#requestHeaders), + returnValue: <_i3.Headers>[], + ) + as List<_i3.Headers>); + + @override + int get responseStatusCode => + (super.noSuchMethod( + Invocation.getter(#responseStatusCode), + returnValue: 0, + ) + as int); + + @override + String get responseBody => + (super.noSuchMethod( + Invocation.getter(#responseBody), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#responseBody), + ), + ) + as String); + + @override + List<_i3.Headers> get responseHeaders => + (super.noSuchMethod( + Invocation.getter(#responseHeaders), + returnValue: <_i3.Headers>[], + ) + as List<_i3.Headers>); + + @override + String get logs => + (super.noSuchMethod( + Invocation.getter(#logs), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#logs), + ), + ) + as String); + + @override + String get errors => + (super.noSuchMethod( + Invocation.getter(#errors), + returnValue: _i13.dummyValue( + this, + Invocation.getter(#errors), + ), + ) + as String); + + @override + double get duration => + (super.noSuchMethod(Invocation.getter(#duration), returnValue: 0.0) + as double); + + @override + Map toMap() => + (super.noSuchMethod( + Invocation.method(#toMap, []), + returnValue: {}, + ) + as Map); +} From 3e689c74318d8c271e0b41cd5f0a9cb3041306cd Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:01:15 +0530 Subject: [PATCH 05/12] feat: friends migration --- .../chapter_player_controller.dart | 16 +- lib/controllers/friend_call_screen.dart | 186 ----- .../friend_calling_controller.dart | 264 ------ lib/controllers/friends_controller.dart | 161 ---- lib/controllers/livekit_controller.dart | 7 - lib/controllers/pair_chat_controller.dart | 292 ------- lib/core/legacy_dependencies.dart | 4 +- lib/features/auth/data/callkit_service.dart | 36 +- .../data/generated/callkit_service.g.dart | 51 ++ .../viewmodel/app_bootstrap_notifier.dart | 18 +- .../generated/app_bootstrap_notifier.g.dart | 2 +- .../friends/data/friend_call_repository.dart | 156 ++++ .../friends/data/friends_repository.dart | 167 ++++ .../generated/friend_call_repository.g.dart | 58 ++ .../data/generated/friends_repository.g.dart | 57 ++ .../generated/pair_chat_repository.g.dart | 58 ++ .../friends/data/pair_chat_repository.dart | 210 +++++ lib/features/friends/friends_routes.dart | 31 + .../friends/model}/friend_call_model.dart | 0 .../friends/model/friend_call_state.dart | 13 + .../friends/model}/friends_model.dart | 4 +- lib/features/friends/model/friends_state.dart | 29 + .../generated/friend_call_model.freezed.dart | 0 .../model}/generated/friend_call_model.g.dart | 0 .../generated/friend_call_state.freezed.dart | 301 +++++++ .../generated/friends_model.freezed.dart | 0 .../model}/generated/friends_model.g.dart | 0 .../generated/friends_state.freezed.dart | 620 ++++++++++++++ .../generated/pair_chat_state.freezed.dart | 312 +++++++ .../friends/model/pair_chat_state.dart | 23 + .../friends/view/pages/friend_call_page.dart | 103 +++ .../view/pages/friend_requests_page.dart | 46 + .../friends/view/pages/friends_page.dart | 38 + .../friends/view/pages/pair_chat_page.dart | 156 ++++ .../view/pages/pair_chat_users_page.dart | 83 ++ .../friends/view/pages/pairing_page.dart | 140 ++++ .../friends/view/pages/ringing_page.dart | 131 +++ .../view/widgets/call_control_panel.dart | 72 ++ .../view/widgets/call_user_info_row.dart | 37 + .../view/widgets/friend_list_tile.dart | 173 ++++ .../view/widgets/friends_empty_view.dart} | 6 +- .../view}/widgets/pair_chat_dialog.dart | 183 ++-- .../friends/view/widgets/rating_sheet.dart | 61 ++ .../viewmodel/friend_call_notifier.dart | 224 +++++ .../friends/viewmodel/friends_notifier.dart | 125 +++ .../generated/friend_call_notifier.g.dart | 63 ++ .../generated/friends_notifier.g.dart | 54 ++ .../generated/pair_chat_notifier.g.dart | 62 ++ .../friends/viewmodel/pair_chat_notifier.dart | 325 ++++++++ .../profile/view/pages/profile_page.dart | 171 ++-- lib/features/rooms/data/livekit_join.dart | 15 + lib/features/rooms/data/rooms_repository.dart | 33 +- .../generated/livekit_notifier.g.dart | 2 +- .../rooms/viewmodel/livekit_notifier.dart | 3 + lib/routes/app_router.dart | 27 +- lib/services/room_service.dart | 14 - lib/views/screens/chapter_play_screen.dart | 90 +- lib/views/screens/friend_requests_screen.dart | 38 - lib/views/screens/friends_screen.dart | 32 - lib/views/screens/pair_chat_screen.dart | 178 ---- lib/views/screens/pair_chat_users_screen.dart | 83 -- lib/views/screens/pairing_screen.dart | 146 ---- lib/views/screens/ringing_screen.dart | 148 ---- lib/views/screens/tabview_screen.dart | 10 +- lib/views/widgets/chapter_player.dart | 8 +- .../widgets/friend_request_list_tile.dart | 184 ---- lib/views/widgets/rating_sheet.dart | 83 -- pubspec.lock | 11 +- pubspec.yaml | 9 +- .../chapter_player_controller_test.dart | 12 +- .../friend_calling_controller_test.dart | 362 -------- .../friend_calling_controller_test.mocks.dart | 619 -------------- test/controllers/friends_controller_test.dart | 334 -------- .../friends_controller_test.mocks.dart | 789 ------------------ .../viewmodel/friend_call_notifier_test.dart | 325 ++++++++ .../viewmodel/friends_notifier_test.dart | 271 ++++++ .../viewmodel/pair_chat_notifier_test.dart | 412 +++++++++ .../rooms/data/livekit_join_test.dart | 27 + test/helpers/test_root_container.dart | 68 ++ 79 files changed, 5426 insertions(+), 4236 deletions(-) delete mode 100644 lib/controllers/friend_call_screen.dart delete mode 100644 lib/controllers/friend_calling_controller.dart delete mode 100644 lib/controllers/friends_controller.dart delete mode 100644 lib/controllers/pair_chat_controller.dart create mode 100644 lib/features/auth/data/generated/callkit_service.g.dart create mode 100644 lib/features/friends/data/friend_call_repository.dart create mode 100644 lib/features/friends/data/friends_repository.dart create mode 100644 lib/features/friends/data/generated/friend_call_repository.g.dart create mode 100644 lib/features/friends/data/generated/friends_repository.g.dart create mode 100644 lib/features/friends/data/generated/pair_chat_repository.g.dart create mode 100644 lib/features/friends/data/pair_chat_repository.dart create mode 100644 lib/features/friends/friends_routes.dart rename lib/{models => features/friends/model}/friend_call_model.dart (100%) create mode 100644 lib/features/friends/model/friend_call_state.dart rename lib/{models => features/friends/model}/friends_model.dart (92%) create mode 100644 lib/features/friends/model/friends_state.dart rename lib/{models => features/friends/model}/generated/friend_call_model.freezed.dart (100%) rename lib/{models => features/friends/model}/generated/friend_call_model.g.dart (100%) create mode 100644 lib/features/friends/model/generated/friend_call_state.freezed.dart rename lib/{models => features/friends/model}/generated/friends_model.freezed.dart (100%) rename lib/{models => features/friends/model}/generated/friends_model.g.dart (100%) create mode 100644 lib/features/friends/model/generated/friends_state.freezed.dart create mode 100644 lib/features/friends/model/generated/pair_chat_state.freezed.dart create mode 100644 lib/features/friends/model/pair_chat_state.dart create mode 100644 lib/features/friends/view/pages/friend_call_page.dart create mode 100644 lib/features/friends/view/pages/friend_requests_page.dart create mode 100644 lib/features/friends/view/pages/friends_page.dart create mode 100644 lib/features/friends/view/pages/pair_chat_page.dart create mode 100644 lib/features/friends/view/pages/pair_chat_users_page.dart create mode 100644 lib/features/friends/view/pages/pairing_page.dart create mode 100644 lib/features/friends/view/pages/ringing_page.dart create mode 100644 lib/features/friends/view/widgets/call_control_panel.dart create mode 100644 lib/features/friends/view/widgets/call_user_info_row.dart create mode 100644 lib/features/friends/view/widgets/friend_list_tile.dart rename lib/{views/screens/friends_empty_screen.dart => features/friends/view/widgets/friends_empty_view.dart} (93%) rename lib/{views => features/friends/view}/widgets/pair_chat_dialog.dart (52%) create mode 100644 lib/features/friends/view/widgets/rating_sheet.dart create mode 100644 lib/features/friends/viewmodel/friend_call_notifier.dart create mode 100644 lib/features/friends/viewmodel/friends_notifier.dart create mode 100644 lib/features/friends/viewmodel/generated/friend_call_notifier.g.dart create mode 100644 lib/features/friends/viewmodel/generated/friends_notifier.g.dart create mode 100644 lib/features/friends/viewmodel/generated/pair_chat_notifier.g.dart create mode 100644 lib/features/friends/viewmodel/pair_chat_notifier.dart create mode 100644 lib/features/rooms/data/livekit_join.dart delete mode 100644 lib/views/screens/friend_requests_screen.dart delete mode 100644 lib/views/screens/friends_screen.dart delete mode 100644 lib/views/screens/pair_chat_screen.dart delete mode 100644 lib/views/screens/pair_chat_users_screen.dart delete mode 100644 lib/views/screens/pairing_screen.dart delete mode 100644 lib/views/screens/ringing_screen.dart delete mode 100644 lib/views/widgets/friend_request_list_tile.dart delete mode 100644 lib/views/widgets/rating_sheet.dart delete mode 100644 test/controllers/friend_calling_controller_test.dart delete mode 100644 test/controllers/friend_calling_controller_test.mocks.dart delete mode 100644 test/controllers/friends_controller_test.dart delete mode 100644 test/controllers/friends_controller_test.mocks.dart create mode 100644 test/features/friends/viewmodel/friend_call_notifier_test.dart create mode 100644 test/features/friends/viewmodel/friends_notifier_test.dart create mode 100644 test/features/friends/viewmodel/pair_chat_notifier_test.dart create mode 100644 test/features/rooms/data/livekit_join_test.dart diff --git a/lib/controllers/chapter_player_controller.dart b/lib/controllers/chapter_player_controller.dart index 7a684086..e7dec835 100644 --- a/lib/controllers/chapter_player_controller.dart +++ b/lib/controllers/chapter_player_controller.dart @@ -1,29 +1,24 @@ import 'package:get/get.dart'; import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter_lyric/lyrics_reader_model.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; class ChapterPlayerController extends GetxController { Rx currentPage = 0.obs; - Rx lyricProgress = 0.obs; Rx sliderProgress = 0.0.obs; Rx isPlaying = false.obs; AudioPlayer? audioPlayer; late Duration chapterDuration; - late LyricsReaderModel lyricModel; + final LyricController lyricController = LyricController(); - void initialize( - AudioPlayer player, - LyricsReaderModel model, - Duration duration, - ) { + void initialize(AudioPlayer player, String lyrics, Duration duration) { audioPlayer = player; - lyricModel = model; + lyricController.loadLyric(lyrics); chapterDuration = duration; audioPlayer?.setReleaseMode(ReleaseMode.stop); audioPlayer?.onPositionChanged.listen((Duration event) { sliderProgress.value = event.inMilliseconds.toDouble(); - lyricProgress.value = event.inMilliseconds; + lyricController.setProgress(event); }); audioPlayer?.onPlayerStateChanged.listen((PlayerState state) { @@ -43,6 +38,7 @@ class ChapterPlayerController extends GetxController { @override void onClose() { audioPlayer?.release(); + lyricController.dispose(); super.onClose(); } } diff --git a/lib/controllers/friend_call_screen.dart b/lib/controllers/friend_call_screen.dart deleted file mode 100644 index 7890322e..00000000 --- a/lib/controllers/friend_call_screen.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/friend_calling_controller.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/features/rooms/view/widgets/room_header.dart'; -import 'package:resonate/views/widgets/audio_selector_dialog.dart'; - -import 'package:resonate/l10n/app_localizations.dart'; - -class FriendCallScreen extends StatelessWidget { - final FriendCallingController controller = - Get.find(); - final ThemeController themeController = Get.find(); - - FriendCallScreen({super.key}); - - @override - Widget build(BuildContext context) { - final currentBrightness = Theme.of(context).brightness; - - return PopScope( - canPop: false, - child: Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_10, - horizontal: UiSizes.width_20, - ), - child: Column( - children: [ - RoomHeader( - roomName: AppLocalizations.of(context)!.title, - roomDescription: AppLocalizations.of( - context, - )!.roomDescription, - ), - SizedBox(height: UiSizes.height_24_6), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildUserInfoRow( - controller - .friendCallModel - .value! - .callerProfileImageUrl, - controller.friendCallModel.value!.callerName, - ), - SizedBox(height: UiSizes.height_20), - _buildUserInfoRow( - controller - .friendCallModel - .value! - .recieverProfileImageUrl, - controller.friendCallModel.value!.recieverName, - ), - ], - ), - SizedBox(height: UiSizes.height_24_6), - ], - ), - ), - const Spacer(), - _buildBottomControlPanel(currentBrightness, context), - ], - ), - ), - ), - ); - } - - Widget _buildUserInfoRow(String imageUrl, String userName) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - backgroundImage: NetworkImage(imageUrl), - radius: UiSizes.width_66, - ), - SizedBox(width: UiSizes.width_16), - Container( - alignment: Alignment.center, - width: UiSizes.width_100, - child: Text( - userName, - style: TextStyle(fontSize: UiSizes.size_16), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - - Widget _buildBottomControlPanel( - Brightness currentBrightness, - BuildContext context, - ) { - return Container( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), - color: currentBrightness == Brightness.light - ? Theme.of(context).colorScheme.primary - : Colors.black, - height: UiSizes.height_131, - child: Obx(() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildControlButton( - icon: controller.isMicOn.value ? Icons.mic : Icons.mic_off, - label: AppLocalizations.of(context)!.mute, - onPressed: controller.toggleMic, - backgroundColor: controller.isMicOn.value - ? _getControlButtonBackgroundColor(currentBrightness) - : Theme.of(context).colorScheme.primary, - heroTag: "mic", - ), - _buildControlButton( - icon: Icons.volume_up, - label: AppLocalizations.of(context)!.speakerLabel, - onPressed: controller.toggleLoudSpeaker, - backgroundColor: controller.isLoudSpeakerOn.value - ? Theme.of(context).colorScheme.primary - : _getControlButtonBackgroundColor(currentBrightness), - heroTag: "speaker", - ), - _buildControlButton( - icon: Icons.settings_voice, - label: AppLocalizations.of(context)!.audioOptions, - onPressed: () async => await showAudioDeviceSelector(context), - backgroundColor: _getControlButtonBackgroundColor( - currentBrightness, - ), - heroTag: "audio-settings", - ), - _buildControlButton( - icon: Icons.cancel_outlined, - label: AppLocalizations.of(context)!.end, - onPressed: () async { - await controller.onEndedCall({ - "call_id": controller.friendCallModel.value!.docId, - }); - }, - backgroundColor: Colors.redAccent, - heroTag: "end-chat", - ), - ], - ); - }), - ); - } - - Color _getControlButtonBackgroundColor(Brightness brightness) { - return brightness == Brightness.light - ? Colors.white.withValues(alpha: 0.5) - : Colors.white54; - } - - Widget _buildControlButton({ - required IconData icon, - required String label, - required VoidCallback onPressed, - required Color backgroundColor, - required String heroTag, - }) { - return Column( - children: [ - SizedBox( - height: UiSizes.height_56, - width: UiSizes.width_56, - child: FloatingActionButton( - elevation: 0, - heroTag: heroTag, - onPressed: onPressed, - backgroundColor: backgroundColor, - child: Icon(icon, size: UiSizes.size_24), - ), - ), - SizedBox(height: UiSizes.height_4), - Text(label, style: TextStyle(fontSize: UiSizes.height_14)), - ], - ); - } -} diff --git a/lib/controllers/friend_calling_controller.dart b/lib/controllers/friend_calling_controller.dart deleted file mode 100644 index e45643c6..00000000 --- a/lib/controllers/friend_calling_controller.dart +++ /dev/null @@ -1,264 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_callkit_incoming/entities/entities.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:get/get.dart'; -import 'package:livekit_client/livekit_client.dart'; -import 'package:resonate/controllers/audio_device_controller.dart'; -import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/friend_call_model.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/services/room_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/friend_call_status.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class FriendCallingController extends GetxController { - final Rx friendCallModel = Rx(null); - - final TablesDB tables; - final Functions functions; - final Realtime realtime; - final RxBool isMicOn = false.obs; - final RxBool isLoudSpeakerOn = true.obs; - RealtimeSubscription? callSubscription; - FriendCallingController({ - TablesDB? tables, - Functions? functions, - Realtime? realtime, - }) : tables = tables ?? AppwriteService.getTables(), - functions = functions ?? AppwriteService.getFunctions(), - realtime = realtime ?? AppwriteService.getRealtime(); - - Future startCall( - String callerName, - String recieverName, - String callerUsername, - String recieverUsername, - String callerUid, - String recieverUid, - String callerProfileImageUrl, - String recieverProfileImageUrl, - String livekitRoomId, - String recieverFCMToken, - ) async { - final callModel = FriendCallModel( - callerName: callerName, - recieverName: recieverName, - callerUsername: callerUsername, - recieverUsername: recieverUsername, - callerUid: callerUid, - recieverUid: recieverUid, - callerProfileImageUrl: callerProfileImageUrl, - recieverProfileImageUrl: recieverProfileImageUrl, - livekitRoomId: livekitRoomId, - callStatus: FriendCallStatus.waiting, - docId: ID.unique(), - ); - await tables.createRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: callModel.docId, - data: callModel.toJson(), - ); - - final notificationData = { - "recieverFCMToken": recieverFCMToken, - "data": { - "caller_name": callerName, - "caller_username": callerUsername, - "caller_profile_image_url": callerProfileImageUrl, - "caller_uid": callerUid, - - "call_id": callModel.docId, - "type": "incoming_call", - "extra": jsonEncode(callModel.toJson()), - "livekit_room_id": callModel.livekitRoomId, - }, - }; - await functions.createExecution( - functionId: startFriendCallFunctionID, - body: jsonEncode(notificationData), - ); - friendCallModel.value = callModel; - listenToCallChanges(); - - appRouter.push(RoutePaths.ringingScreen); - } - - Future joinCall(dynamic roomId, String userId) async { - await RoomService.joinLivekitPairChat(roomId: roomId, userId: userId); - - appRouter.push(RoutePaths.friendCallScreen); - } - - static Future onCallRecieved(RemoteMessage message) async { - log(message.data['extra']); - final params = CallKitParams( - id: message.data['call_id'], - nameCaller: message.data['caller_name'], - avatar: message.data['caller_profile_image_url'], - handle: message.data['caller_username'], - type: 0, // 0 = audio, 1 = video - duration: 30000, // ringing timeout - extra: { - "docData": jsonDecode(message.data['extra']), - "livekit_room_id": message.data['livekit_room_id'], - "call_id": message.data['call_id'], - }, - appName: "Resonate", - android: AndroidParams(isShowFullLockedScreen: true), - ); - if (!Get.testMode) { - await FlutterCallkitIncoming.showCallkitIncoming(params); - } - } - - Future onAnswerCall(Map extra) async { - final callDoc = await tables.getRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: extra['call_id'], - ); - FriendCallModel callModel = FriendCallModel.fromJson(callDoc.data); - log(callDoc.data.toString()); - log(callModel.toString()); - if (callModel.callStatus == FriendCallStatus.ended) { - return; - } else { - callModel = callModel.copyWith(callStatus: FriendCallStatus.connected); - await tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: callModel.docId, - data: callModel.toJson(), - ); - friendCallModel.value = callModel; - if (callSubscription == null) { - listenToCallChanges(); - } - if (!Get.testMode) { - await joinCall(callModel.livekitRoomId, callModel.recieverUid); - } - } - } - - Future onDeclinedCall(Map extra) async { - final callDoc = await tables.getRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: extra['call_id'], - ); - FriendCallModel callModel = FriendCallModel.fromJson(callDoc.data); - - callModel = callModel.copyWith(callStatus: FriendCallStatus.declined); - - await tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: callModel.docId, - data: callModel.toJson(), - ); - friendCallModel.value = callModel; - } - - Future onEndedCall(Map extra) async { - final callDoc = await tables.getRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: extra['call_id'], - ); - - FriendCallModel callModel = FriendCallModel.fromJson(callDoc.data); - - callModel = callModel.copyWith(callStatus: FriendCallStatus.ended); - await tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: callModel.docId, - data: callModel.toJson(), - ); - friendCallModel.value = callModel; - await Get.delete(force: true); - await Get.delete(force: true); - if (!Get.testMode) { - FlutterCallkitIncoming.endAllCalls(); - } - appRouter.go(RoutePaths.tabview); - } - - void listenToCallChanges() async { - String channel = - 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.${friendCallModel.value!.docId}'; - callSubscription = realtime.subscribe([channel]); - callSubscription?.stream.listen((data) async { - if (data.payload.isNotEmpty) { - if (data.events.first.endsWith('.update')) { - if (data.payload['callStatus'] == FriendCallStatus.connected.name) { - friendCallModel.value = friendCallModel.value!.copyWith( - callStatus: FriendCallStatus.connected, - ); - if (!Get.testMode) { - await joinCall( - friendCallModel.value!.livekitRoomId, - friendCallModel.value!.callerUid, - ); - } - } - if (data.payload['callStatus'] == FriendCallStatus.ended.name) { - if (!Get.testMode) { - FlutterCallkitIncoming.endAllCalls(); - } - friendCallModel.value = friendCallModel.value!.copyWith( - callStatus: FriendCallStatus.ended, - ); - await Get.delete(force: true); - await Get.delete(force: true); - appRouter.go(RoutePaths.tabview); - } - if (data.payload['callStatus'] == FriendCallStatus.declined.name) { - await Get.delete(force: true); - await Get.delete(force: true); - if (!Get.testMode) { - FlutterCallkitIncoming.endAllCalls(); - } - friendCallModel.value = friendCallModel.value!.copyWith( - callStatus: FriendCallStatus.declined, - ); - customSnackbar( - AppLocalizations.of(Get.context!)!.callDeclined, - AppLocalizations.of( - Get.context!, - )!.callDeclinedTo(friendCallModel.value!.recieverName), - LogType.info, - ); - appRouter.go(RoutePaths.tabview); - } - } - } - }); - } - - void toggleMic() async { - isMicOn.value = !isMicOn.value; - if (!Get.testMode) { - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(isMicOn.value); - } - } - - void toggleLoudSpeaker() { - isLoudSpeakerOn.value = !isLoudSpeakerOn.value; - if (!Get.testMode) { - Hardware.instance.setSpeakerphoneOn(isLoudSpeakerOn.value); - } - } -} diff --git a/lib/controllers/friends_controller.dart b/lib/controllers/friends_controller.dart deleted file mode 100644 index 79c70ffe..00000000 --- a/lib/controllers/friends_controller.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/models/friends_model.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/friend_request_status.dart'; - -class FriendsController extends GetxController { - final TablesDB tables; - final FirebaseMessaging firebaseMessaging; - final Functions functions; - AuthUser get authStateController => requireCurrentAuthUser; - final RxList friendsList = [].obs; - final RxList friendRequestsList = [].obs; - final RxBool isLoadingFriends = false.obs; - late RealtimeSubscription friendRequestsSubscription; - final Realtime realtime; - - FriendsController({ - TablesDB? tables, - Functions? functions, - Realtime? realtime, - FirebaseMessaging? firebaseMessaging, - }) : tables = tables ?? AppwriteService.getTables(), - functions = functions ?? AppwriteService.getFunctions(), - realtime = realtime ?? AppwriteService.getRealtime(), - firebaseMessaging = firebaseMessaging ?? FirebaseMessaging.instance; - - @override - void onInit() async { - super.onInit(); - await getFriendsList(); - listenForChangesInFriends(); - } - - Future sendFriendRequest( - String recieverId, - String recieverProfileImageUrl, - String recieverUsername, - String recieverName, - double recieverRating, - ) async { - final docId = ID.unique(); - final userFCMToken = await firebaseMessaging.getToken(); - - final friendModel = FriendsModel( - senderId: authStateController.uid, - recieverId: recieverId, - senderProfileImgUrl: authStateController.profileImageUrl!, - recieverProfileImgUrl: recieverProfileImageUrl, - senderUsername: authStateController.userName!, - recieverUsername: recieverUsername, - senderName: authStateController.displayName, - recieverName: recieverName, - requestStatus: FriendRequestStatus.sent, - requestSentByUserId: authStateController.uid, - docId: docId, - senderFCMToken: userFCMToken, - users: [authStateController.uid, recieverId], - senderRating: - authStateController.ratingTotal / authStateController.ratingCount, - recieverRating: recieverRating, - ); - await tables.createRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: docId, - data: friendModel.toJson(), - ); - friendRequestsList.add(friendModel); - } - - Future removeFriend(FriendsModel friendModel) async { - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: friendModel.docId, - ); - friendsList.removeWhere((friend) => friend.docId == friendModel.docId); - friendRequestsList.removeWhere( - (request) => request.docId == friendModel.docId, - ); - } - - Future getFriendsList() async { - isLoadingFriends.value = true; - friendsList.clear(); - friendRequestsList.clear(); - - final userDoc = await tables.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid, - queries: [ - Query.select(["*", "friends.*"]), - ], - ); - for (var friend in (userDoc.data["friends"] ?? []) as List) { - final friendModel = FriendsModel.fromJson(friend); - if (friendModel.requestStatus == FriendRequestStatus.accepted) { - friendsList.add(friendModel); - } else { - friendRequestsList.add(friendModel); - } - } - isLoadingFriends.value = false; - } - - Future acceptFriendRequest(FriendsModel friendModel) async { - final userFCMToken = await firebaseMessaging.getToken(); - - final updatedFriendModel = friendModel.copyWith( - requestStatus: FriendRequestStatus.accepted, - recieverFCMToken: userFCMToken, - users: [friendModel.senderId, friendModel.recieverId], - ); - - await tables.updateRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: friendModel.docId, - data: updatedFriendModel.toJson(), - ); - - friendRequestsList.removeWhere( - (request) => request.docId == friendModel.docId, - ); - friendsList.add(updatedFriendModel); - } - - Future declineFriendRequest(FriendsModel friendModel) async { - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: friendModel.docId, - ); - friendRequestsList.removeWhere( - (request) => request.docId == friendModel.docId, - ); - } - - void listenForChangesInFriends() { - String channel = 'databases.$userDatabaseID.tables.$friendsTableID.rows'; - friendRequestsSubscription = realtime.subscribe([channel]); - friendRequestsSubscription.stream.listen((data) async { - if (data.payload.isNotEmpty) { - if (data.payload['senderId'] == authStateController.uid || - data.payload['recieverId'] == authStateController.uid) { - log('data updated'); - // If a change is detected in the user doc - getFriendsList(); - } - } - }); - } -} diff --git a/lib/controllers/livekit_controller.dart b/lib/controllers/livekit_controller.dart index 8133cd79..9dcc879f 100644 --- a/lib/controllers/livekit_controller.dart +++ b/lib/controllers/livekit_controller.dart @@ -5,7 +5,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:get/get.dart'; import 'package:livekit_client/livekit_client.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; class LiveKitController extends GetxController { late final Room liveKitRoom; @@ -174,12 +173,6 @@ class LiveKitController extends GetxController { if (event.reason != null) { log('Room disconnected: reason => ${event.reason}'); await handleDisconnection(); - - WidgetsBindingCompatible.instance?.addPostFrameCallback((timeStamp) { - if (Get.isRegistered()) { - Get.find().endChat(); - } - }); } }) ..on((event) { diff --git a/lib/controllers/pair_chat_controller.dart b/lib/controllers/pair_chat_controller.dart deleted file mode 100644 index 3309ebe5..00000000 --- a/lib/controllers/pair_chat_controller.dart +++ /dev/null @@ -1,292 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:get/get.dart'; -import 'package:livekit_client/livekit_client.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/models/resonate_user.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/services/room_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/views/widgets/rating_sheet.dart'; - -import 'livekit_controller.dart'; - -class PairChatController extends GetxController { - RxBool isLoading = false.obs; - RxBool isAnonymous = true.obs; - String languageIso = "en"; - - RxBool isMicOn = false.obs; - RxBool isLoudSpeakerOn = true.obs; - String? requestDocId; - String? activePairDocId; - int? myRoomUserId; - - String? pairUsername; - String? pairProfileImageUrl; - RxDouble pairRating = 2.5.obs; - - Client client = AppwriteService.getClient(); - final Realtime realtime = AppwriteService.getRealtime(); - final TablesDB tablesDB = AppwriteService.getTables(); - late RealtimeSubscription? subscription; - RealtimeSubscription? userAddedSubscription; - AuthUser get authController => requireCurrentAuthUser; - - RxList usersList = [].obs; - final RxBool isUserListLoading = true.obs; - - void quickMatch() async { - String uid = authController.uid; - String userName = authController.userName!; - - // Open realtime stream to check whether the request is paired - getRealtimeStream(); - - Map requestData = { - "languageIso": languageIso, - "isAnonymous": isAnonymous.value, - "uid": uid, - "isRandom": true, - }; - requestData.addIf(!isAnonymous.value, "userName", userName); - - // Add request to pair-request collection - Row requestDoc = await tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: pairRequestTableId, - rowId: ID.unique(), - data: requestData, - ); - requestDocId = requestDoc.$id; - - // Go to pairing screen - appRouter.push(RoutePaths.pairing); - } - - void choosePartner() async { - checkForNewUsers(); - getRealtimeStream(); - isAnonymous.value = false; - Map requestData = { - "languageIso": languageIso, - "isAnonymous": false, - "uid": authController.uid, - "isRandom": false, - "profileImageUrl": authController.profileImageUrl, - "userName": authController.userName, - "name": authController.displayName, - "userRating": authController.ratingTotal / authController.ratingCount, - }; - - // Add request to pair-request collection - Row requestDoc = await tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: pairRequestTableId, - rowId: ID.unique(), - data: requestData, - ); - requestDocId = requestDoc.$id; - appRouter.push(RoutePaths.pairChatUsers); - } - - Future convertToRandom() async { - userAddedSubscription?.close(); - getRealtimeStream(); - await tablesDB.updateRow( - databaseId: masterDatabaseId, - tableId: pairRequestTableId, - rowId: requestDocId!, - data: {'isRandom': true}, - ); - appRouter.push(RoutePaths.pairing); - } - - void getRealtimeStream() { - String uid = authController.uid; - String channel = - 'databases.$masterDatabaseId.tables.$activePairsTableId.rows'; - subscription = realtime.subscribe([channel]); - subscription?.stream.listen((data) async { - if (data.payload.isNotEmpty) { - String uid1 = data.payload["uid1"]; - String uid2 = data.payload["uid2"]; - - // If the request was served and the user was paired - if (uid1 == uid || uid2 == uid) { - log(data.toString()); - var docId = data.payload["\$id"].toString(); - String action = data.events.first.substring( - channel.length + 1 + (docId.length) + 1, - ); - switch (action) { - case 'create': - { - if (uid1 == uid) { - myRoomUserId = 1; - pairUsername = data.payload["userName2"]; - Row participantDoc = await tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: data.payload["uid2"], - ); - pairProfileImageUrl = participantDoc.data["profileImageUrl"]; - } else { - myRoomUserId = 2; - pairUsername = data.payload["userName1"]; - Row participantDoc = await tablesDB.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: data.payload["uid1"], - ); - pairProfileImageUrl = participantDoc.data["profileImageUrl"]; - } - activePairDocId = data.payload["\$id"]; - await joinPairChat(activePairDocId, uid); - break; - } - case 'delete': - { - endChat(); - } - } - } - } - }); - } - - Future pairWithSelectedUser(ResonateUser user) async { - log('pairing'); - await tablesDB.createRow( - databaseId: masterDatabaseId, - tableId: activePairsTableId, - rowId: ID.unique(), - data: { - 'uid1': authController.uid, - 'uid2': user.uid, - 'userName1': authController.userName, - 'userName2': user.userName, - 'userDocId1': requestDocId, - 'userDocId2': user.docId, - }, - ); - } - - void checkForNewUsers() { - log('listening for new users'); - String channel = - 'databases.$masterDatabaseId.tables.$pairRequestTableId.rows'; - userAddedSubscription = realtime.subscribe([channel]); - userAddedSubscription?.stream.listen((data) async { - final event = data.events.first; - if (data.payload.isNotEmpty) { - if (event.endsWith('.create')) { - log('adding new user'); - // If a new user is added to the pair request collection - final userData = data.payload; - final eventSplit = event.split('.'); - final docId = - eventSplit[eventSplit.length - 2]; // Get the second last - userData['docId'] = docId; // Add docId to the user - ResonateUser newUser = ResonateUser.fromJson(userData); - - usersList.add(newUser); - } else if (event.endsWith('.delete')) { - ResonateUser removedUser = ResonateUser.fromJson(data.payload); - usersList.removeWhere((user) => user.uid == removedUser.uid); - } - } - }); - } - - Future loadUsers() async { - isUserListLoading.value = true; - log("Loading users"); - usersList.clear(); - final result = await tablesDB.listRows( - databaseId: masterDatabaseId, - tableId: pairRequestTableId, - queries: [ - Query.notEqual('uid', authController.uid), - Query.notEqual('isAnonymous', true), - Query.limit(100), - ], - ); - if (result.rows.isEmpty) { - isUserListLoading.value = false; - return; - } else { - usersList.addAll( - result.rows.map((doc) { - final userData = doc.data; - userData['docId'] = doc.$id; // Add docId to the user data - ResonateUser user = ResonateUser.fromJson(userData); - - return user; - }).toList(), - ); - } - isUserListLoading.value = false; - } - - Future joinPairChat(roomId, userId) async { - await RoomService.joinLivekitPairChat(roomId: roomId, userId: userId); - appRouter.push(RoutePaths.pairChat); - } - -Future cancelRequest() async { - try { - if (requestDocId != null) { - await tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: pairRequestTableId, - rowId: requestDocId!, - ); - } - - requestDocId = null; - - await subscription?.close(); - await userAddedSubscription?.close(); - } catch (e) { - log('Cancel request failed: $e'); - } finally { - appRouter.go(RoutePaths.tabview); - } -} - void toggleMic() async { - isMicOn.value = !isMicOn.value; - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(isMicOn.value); - } - - void toggleLoudSpeaker() { - isLoudSpeakerOn.value = !isLoudSpeakerOn.value; - Hardware.instance.setSpeakerphoneOn(isLoudSpeakerOn.value); - } - - Future endChat() async { - subscription?.close(); - try { - await tablesDB.deleteRow( - databaseId: masterDatabaseId, - tableId: activePairsTableId, - rowId: activePairDocId!, - ); - } catch (e) { - if (!(e is AppwriteException && e.type == 'document_not_found')) { - rethrow; - } - } - await Get.delete(force: true); - - await Get.bottomSheet(RatingSheetWidget()); - appRouter.go(RoutePaths.tabview); - } -} diff --git a/lib/core/legacy_dependencies.dart b/lib/core/legacy_dependencies.dart index 2099ea42..2e7f8e8c 100644 --- a/lib/core/legacy_dependencies.dart +++ b/lib/core/legacy_dependencies.dart @@ -1,14 +1,12 @@ import 'package:get/get.dart'; import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/controllers/friends_controller.dart'; import 'package:resonate/controllers/network_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; // Registers the GetX controllers that are still in use after the auth, -// rooms, and profile features migrated to Riverpod. +// rooms, profile, and friends features migrated to Riverpod. void setupLegacyGetXDependencies() { Get.put(NetworkController(), permanent: true); Get.lazyPut(() => TabViewController()); Get.lazyPut(() => ExploreStoryController()); - Get.lazyPut(() => FriendsController(), fenix: true); } diff --git a/lib/features/auth/data/callkit_service.dart b/lib/features/auth/data/callkit_service.dart index 3cea8df9..2ca8f44c 100644 --- a/lib/features/auth/data/callkit_service.dart +++ b/lib/features/auth/data/callkit_service.dart @@ -1,9 +1,17 @@ import 'dart:async'; +import 'dart:convert'; -import 'package:flutter_callkit_incoming/entities/call_event.dart'; -import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'generated/callkit_service.g.dart'; + +@Riverpod(keepAlive: true) +CallKitService callKitService(Ref ref) => CallKitService(); + +// Wraps the FlutterCallkitIncoming statics so callers stay testable. class CallKitService { StreamSubscription? _sub; @@ -33,4 +41,28 @@ class CallKitService { await _sub?.cancel(); _sub = null; } + + // Presents the native incoming-call UI for an `incoming_call` FCM message. + Future showIncomingCall(RemoteMessage message) async { + final params = CallKitParams( + id: message.data['call_id'], + nameCaller: message.data['caller_name'], + avatar: message.data['caller_profile_image_url'], + handle: message.data['caller_username'], + type: 0, // 0 = audio, 1 = video + duration: 30000, // ringing timeout + extra: { + "docData": jsonDecode(message.data['extra']), + "livekit_room_id": message.data['livekit_room_id'], + "call_id": message.data['call_id'], + }, + appName: "Resonate", + android: AndroidParams(isShowFullLockedScreen: true), + ); + await FlutterCallkitIncoming.showCallkitIncoming(params); + } + + Future endAllCalls() async { + await FlutterCallkitIncoming.endAllCalls(); + } } diff --git a/lib/features/auth/data/generated/callkit_service.g.dart b/lib/features/auth/data/generated/callkit_service.g.dart new file mode 100644 index 00000000..d72d49af --- /dev/null +++ b/lib/features/auth/data/generated/callkit_service.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../callkit_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(callKitService) +final callKitServiceProvider = CallKitServiceProvider._(); + +final class CallKitServiceProvider + extends $FunctionalProvider + with $Provider { + CallKitServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'callKitServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$callKitServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + CallKitService create(Ref ref) { + return callKitService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CallKitService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$callKitServiceHash() => r'92c38ce82f1b4af12d76e0a87bc8c7c1c08dd888'; diff --git a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart index 2b38e784..4b43e0c9 100644 --- a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart +++ b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart @@ -4,11 +4,11 @@ import 'dart:developer'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:get/get.dart'; -import 'package:resonate/controllers/friend_calling_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/features/auth/data/callkit_service.dart'; import 'package:resonate/features/auth/data/notification_service.dart'; +import 'package:resonate/features/friends/viewmodel/friend_call_notifier.dart'; import 'package:resonate/routes/app_router.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -46,7 +46,9 @@ class AppBootstrap extends _$AppBootstrap { log('Got a message whilst in the foreground!'); if (message.data['type'] == 'incoming_call') { log('saw incoming call'); - await FriendCallingController.onCallRecieved(message); + if (!Get.testMode) { + await ref.read(callKitServiceProvider).showIncomingCall(message); + } return; } final notification = message.notification; @@ -67,14 +69,12 @@ class AppBootstrap extends _$AppBootstrap { ref.onDispose(fcmSub.cancel); if (!Get.testMode) { - final friendCalling = Get.put( - FriendCallingController(), - permanent: true, - ); - final callKit = CallKitService() + final callKit = ref.read(callKitServiceProvider) ..start( - onAccept: friendCalling.onAnswerCall, - onDecline: friendCalling.onDeclinedCall, + onAccept: (extra) => + ref.read(friendCallProvider.notifier).onAnswerCall(extra), + onDecline: (extra) => + ref.read(friendCallProvider.notifier).onDeclinedCall(extra), ); ref.onDispose(callKit.stop); } diff --git a/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart b/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart index 305ac4f0..b069fad0 100644 --- a/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart +++ b/lib/features/auth/viewmodel/generated/app_bootstrap_notifier.g.dart @@ -33,7 +33,7 @@ final class AppBootstrapProvider AppBootstrap create() => AppBootstrap(); } -String _$appBootstrapHash() => r'5e3576c9a41cc4f4120a281cdfd59d09ad805127'; +String _$appBootstrapHash() => r'8afd1f515606f71be4768a187983a2a018104d8e'; abstract class _$AppBootstrap extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/friends/data/friend_call_repository.dart b/lib/features/friends/data/friend_call_repository.dart new file mode 100644 index 00000000..6d920774 --- /dev/null +++ b/lib/features/friends/data/friend_call_repository.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/rooms/data/livekit_join.dart'; +import 'package:resonate/features/friends/model/friend_call_model.dart'; +import 'package:resonate/features/friends/data/friends_repository.dart' + show mapAppwriteFriendsException; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/friend_call_status.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/friend_call_repository.g.dart'; + +@Riverpod(keepAlive: true) +FriendCallRepository friendCallRepository(Ref ref) => FriendCallRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + functions: ref.watch(appwriteFunctionsProvider), + apiService: ApiService(functions: ref.watch(appwriteFunctionsProvider)), +); + +class FriendCallRepository { + FriendCallRepository({ + required TablesDB tables, + required Realtime realtime, + required Functions functions, + required ApiService apiService, + }) : _tables = tables, + _realtime = realtime, + _functions = functions, + _api = apiService; + + final TablesDB _tables; + final Realtime _realtime; + final Functions _functions; + final ApiService _api; + + Future createCall({ + required String callerName, + required String recieverName, + required String callerUsername, + required String recieverUsername, + required String callerUid, + required String recieverUid, + required String callerProfileImageUrl, + required String recieverProfileImageUrl, + required String livekitRoomId, + }) async { + try { + final callModel = FriendCallModel( + callerName: callerName, + recieverName: recieverName, + callerUsername: callerUsername, + recieverUsername: recieverUsername, + callerUid: callerUid, + recieverUid: recieverUid, + callerProfileImageUrl: callerProfileImageUrl, + recieverProfileImageUrl: recieverProfileImageUrl, + livekitRoomId: livekitRoomId, + callStatus: FriendCallStatus.waiting, + docId: ID.unique(), + ); + await _tables.createRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: callModel.docId, + data: callModel.toJson(), + ); + return callModel; + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + // Triggers the cloud function that delivers the incoming-call FCM push. + Future sendCallNotification({ + required FriendCallModel call, + required String recieverFCMToken, + }) async { + final notificationData = { + "recieverFCMToken": recieverFCMToken, + "data": { + "caller_name": call.callerName, + "caller_username": call.callerUsername, + "caller_profile_image_url": call.callerProfileImageUrl, + "caller_uid": call.callerUid, + + "call_id": call.docId, + "type": "incoming_call", + "extra": jsonEncode(call.toJson()), + "livekit_room_id": call.livekitRoomId, + }, + }; + await _functions.createExecution( + functionId: startFriendCallFunctionID, + body: jsonEncode(notificationData), + ); + } + + Future getCall(String callId) async { + try { + final callDoc = await _tables.getRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: callId, + ); + return FriendCallModel.fromJson(callDoc.data); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Future setCallStatus( + FriendCallModel call, + FriendCallStatus status, + ) async { + try { + final updated = call.copyWith(callStatus: status); + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: updated.docId, + data: updated.toJson(), + ); + return updated; + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Stream callStream(String callDocId) { + final channel = + 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.$callDocId'; + final subscription = _realtime.subscribe([channel]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty) controller.add(event); + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } + + Future<({String liveKitUri, String roomToken})> callJoinInfo({ + required String roomId, + required String userId, + }) async { + final response = await _api.joinRoom(roomId, userId); + return liveKitJoinFromResponse(response); + } +} diff --git a/lib/features/friends/data/friends_repository.dart b/lib/features/friends/data/friends_repository.dart new file mode 100644 index 00000000..0ede1583 --- /dev/null +++ b/lib/features/friends/data/friends_repository.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/core/providers/firebase_providers.dart'; +import 'package:resonate/features/auth/model/auth_user.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/model/friends_state.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/friend_request_status.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/friends_repository.g.dart'; + +@Riverpod(keepAlive: true) +FriendsRepository friendsRepository(Ref ref) => FriendsRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + messaging: ref.watch(firebaseMessagingProvider), +); + +class FriendsRepository { + FriendsRepository({ + required TablesDB tables, + required Realtime realtime, + required FirebaseMessaging messaging, + }) : _tables = tables, + _realtime = realtime, + _messaging = messaging; + + final TablesDB _tables; + final Realtime _realtime; + final FirebaseMessaging _messaging; + + Future<({List friends, List requests})> + loadFriends(String uid) async { + final userDoc = await _tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + queries: [ + Query.select(["*", "friends.*"]), + ], + ); + + final friends = []; + final requests = []; + for (final friend in (userDoc.data['friends'] ?? []) as List) { + try { + final model = FriendsModel.fromJson(friend); + if (model.requestStatus == FriendRequestStatus.accepted) { + friends.add(model); + } else { + requests.add(model); + } + } catch (_) { + // Skip rows that have missing/malformed fields. + } + } + return (friends: friends, requests: requests); + } + + Future sendFriendRequest({ + required AuthUser sender, + required String recieverId, + required String recieverProfileImageUrl, + required String recieverUsername, + required String recieverName, + required double recieverRating, + }) async { + try { + final docId = ID.unique(); + final senderFCMToken = await _messaging.getToken(); + + final friendModel = FriendsModel( + senderId: sender.uid, + recieverId: recieverId, + senderProfileImgUrl: sender.profileImageUrl!, + recieverProfileImgUrl: recieverProfileImageUrl, + senderUsername: sender.userName!, + recieverUsername: recieverUsername, + senderName: sender.displayName, + recieverName: recieverName, + requestStatus: FriendRequestStatus.sent, + requestSentByUserId: sender.uid, + docId: docId, + senderFCMToken: senderFCMToken, + users: [sender.uid, recieverId], + senderRating: sender.ratingCount == 0 + ? 0 + : sender.ratingTotal / sender.ratingCount, + recieverRating: recieverRating, + ); + await _tables.createRow( + databaseId: userDatabaseID, + tableId: friendsTableID, + rowId: docId, + data: friendModel.toJson(), + ); + return friendModel; + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Future acceptFriendRequest(FriendsModel friendModel) async { + try { + final recieverFCMToken = await _messaging.getToken(); + + final updated = friendModel.copyWith( + requestStatus: FriendRequestStatus.accepted, + recieverFCMToken: recieverFCMToken, + users: [friendModel.senderId, friendModel.recieverId], + ); + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: friendsTableID, + rowId: friendModel.docId, + data: updated.toJson(), + ); + return updated; + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + // Used for both declining a request and removing an accepted friend. + Future deleteFriendRow(String docId) async { + try { + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: friendsTableID, + rowId: docId, + ); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + // Emits every friends-table change involving uid. + Stream friendsStream(String uid) { + final channel = 'databases.$userDatabaseID.tables.$friendsTableID.rows'; + final subscription = _realtime.subscribe([channel]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty && + (event.payload['senderId'] == uid || + event.payload['recieverId'] == uid)) { + controller.add(event); + } + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } +} + +FriendsFailure mapAppwriteFriendsException(AppwriteException e) { + return switch (e.code) { + 404 => const FriendsFailure.notFound(), + 401 || 403 => const FriendsFailure.permissionDenied(), + _ => FriendsFailure.unknown(e.message ?? e.toString()), + }; +} diff --git a/lib/features/friends/data/generated/friend_call_repository.g.dart b/lib/features/friends/data/generated/friend_call_repository.g.dart new file mode 100644 index 00000000..ba7fed66 --- /dev/null +++ b/lib/features/friends/data/generated/friend_call_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../friend_call_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(friendCallRepository) +final friendCallRepositoryProvider = FriendCallRepositoryProvider._(); + +final class FriendCallRepositoryProvider + extends + $FunctionalProvider< + FriendCallRepository, + FriendCallRepository, + FriendCallRepository + > + with $Provider { + FriendCallRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'friendCallRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$friendCallRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + FriendCallRepository create(Ref ref) { + return friendCallRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FriendCallRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$friendCallRepositoryHash() => + r'8603cf46c8b18d50b85cb26874d78614434166d3'; diff --git a/lib/features/friends/data/generated/friends_repository.g.dart b/lib/features/friends/data/generated/friends_repository.g.dart new file mode 100644 index 00000000..3bd0f2cc --- /dev/null +++ b/lib/features/friends/data/generated/friends_repository.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../friends_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(friendsRepository) +final friendsRepositoryProvider = FriendsRepositoryProvider._(); + +final class FriendsRepositoryProvider + extends + $FunctionalProvider< + FriendsRepository, + FriendsRepository, + FriendsRepository + > + with $Provider { + FriendsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'friendsRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$friendsRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + FriendsRepository create(Ref ref) { + return friendsRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FriendsRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$friendsRepositoryHash() => r'71326fc33aa846a49d5ecc2cdf55ab2ea87ce036'; diff --git a/lib/features/friends/data/generated/pair_chat_repository.g.dart b/lib/features/friends/data/generated/pair_chat_repository.g.dart new file mode 100644 index 00000000..f38ebbe4 --- /dev/null +++ b/lib/features/friends/data/generated/pair_chat_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../pair_chat_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(pairChatRepository) +final pairChatRepositoryProvider = PairChatRepositoryProvider._(); + +final class PairChatRepositoryProvider + extends + $FunctionalProvider< + PairChatRepository, + PairChatRepository, + PairChatRepository + > + with $Provider { + PairChatRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pairChatRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pairChatRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PairChatRepository create(Ref ref) { + return pairChatRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PairChatRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pairChatRepositoryHash() => + r'66fc68a061765b8ea2eebbb1ff849dd60216fb45'; diff --git a/lib/features/friends/data/pair_chat_repository.dart b/lib/features/friends/data/pair_chat_repository.dart new file mode 100644 index 00000000..c180af03 --- /dev/null +++ b/lib/features/friends/data/pair_chat_repository.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/friends/data/friends_repository.dart' + show mapAppwriteFriendsException; +import 'package:resonate/features/rooms/data/livekit_join.dart'; +import 'package:resonate/models/resonate_user.dart'; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/pair_chat_repository.g.dart'; + +@Riverpod(keepAlive: true) +PairChatRepository pairChatRepository(Ref ref) => PairChatRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + apiService: ApiService(functions: ref.watch(appwriteFunctionsProvider)), +); + +class PairChatRepository { + PairChatRepository({ + required TablesDB tables, + required Realtime realtime, + required ApiService apiService, + }) : _tables = tables, + _realtime = realtime, + _api = apiService; + + final TablesDB _tables; + final Realtime _realtime; + final ApiService _api; + + static String activePairsChannel() => + 'databases.$masterDatabaseId.tables.$activePairsTableId.rows'; + + Future createPairRequest({ + required Map data, + }) async { + try { + final requestDoc = await _tables.createRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: ID.unique(), + data: data, + ); + return requestDoc.$id; + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Future convertRequestToRandom(String requestDocId) async { + try { + await _tables.updateRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: requestDocId, + data: {'isRandom': true}, + ); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Future deletePairRequest(String requestDocId) async { + try { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: requestDocId, + ); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Future> listOnlineUsers({required String excludeUid}) async { + final result = await _tables.listRows( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + queries: [ + Query.notEqual('uid', excludeUid), + Query.notEqual('isAnonymous', true), + Query.limit(100), + ], + ); + + final users = []; + for (final doc in result.rows) { + try { + users.add(ResonateUser.fromJson({...doc.data, 'docId': doc.$id})); + } catch (_) { + // Skip rows that have missing/malformed fields. + } + } + return users; + } + + Future createActivePair({ + required String uid1, + required String uid2, + String? userName1, + String? userName2, + String? requestDocId1, + String? requestDocId2, + }) async { + try { + await _tables.createRow( + databaseId: masterDatabaseId, + tableId: activePairsTableId, + rowId: ID.unique(), + data: { + 'uid1': uid1, + 'uid2': uid2, + 'userName1': userName1, + 'userName2': userName2, + 'userDocId1': requestDocId1, + 'userDocId2': requestDocId2, + }, + ); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + // Deleting an already-deleted pair is fine — the other side ended first. + Future deleteActivePair(String activePairDocId) async { + try { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: activePairsTableId, + rowId: activePairDocId, + ); + } on AppwriteException catch (e) { + if (e.code == 404 || e.type == 'document_not_found') return; + throw mapAppwriteFriendsException(e); + } + } + + Future getUserProfileImageUrl(String uid) async { + try { + final userDoc = await _tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + ); + return userDoc.data['profileImageUrl'] as String?; + } catch (_) { + // Missing user rows shouldn't kill a match. + return null; + } + } + + Future updateUserRating({ + required String uid, + required double ratingTotal, + required int ratingCount, + }) async { + try { + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: uid, + data: { + 'ratingTotal': ratingTotal, + 'ratingCount': ratingCount, + }, + ); + } on AppwriteException catch (e) { + throw mapAppwriteFriendsException(e); + } + } + + Stream activePairsStream() { + final subscription = _realtime.subscribe([activePairsChannel()]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty) controller.add(event); + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } + + Stream pairRequestsStream() { + final channel = 'databases.$masterDatabaseId.tables.$pairRequestTableId.rows'; + final subscription = _realtime.subscribe([channel]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty) controller.add(event); + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } + + Future<({String liveKitUri, String roomToken})> pairJoinInfo({ + required String roomId, + required String userId, + }) async { + final response = await _api.joinRoom(roomId, userId); + return liveKitJoinFromResponse(response); + } +} diff --git a/lib/features/friends/friends_routes.dart b/lib/features/friends/friends_routes.dart new file mode 100644 index 00000000..bb72c8a2 --- /dev/null +++ b/lib/features/friends/friends_routes.dart @@ -0,0 +1,31 @@ +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/friends/view/pages/friend_call_page.dart'; +import 'package:resonate/features/friends/view/pages/pair_chat_page.dart'; +import 'package:resonate/features/friends/view/pages/pair_chat_users_page.dart'; +import 'package:resonate/features/friends/view/pages/pairing_page.dart'; +import 'package:resonate/features/friends/view/pages/ringing_page.dart'; +import 'package:resonate/routes/route_paths.dart'; + +// Routes contributed by the friends feature to the global GoRouter. +final List friendsRoutes = [ + GoRoute( + path: RoutePaths.pairing, + builder: (context, state) => const PairingPage(), + ), + GoRoute( + path: RoutePaths.pairChatUsers, + builder: (context, state) => const PairChatUsersPage(), + ), + GoRoute( + path: RoutePaths.pairChat, + builder: (context, state) => const PairChatPage(), + ), + GoRoute( + path: RoutePaths.ringingScreen, + builder: (context, state) => const RingingPage(), + ), + GoRoute( + path: RoutePaths.friendCallScreen, + builder: (context, state) => const FriendCallPage(), + ), +]; diff --git a/lib/models/friend_call_model.dart b/lib/features/friends/model/friend_call_model.dart similarity index 100% rename from lib/models/friend_call_model.dart rename to lib/features/friends/model/friend_call_model.dart diff --git a/lib/features/friends/model/friend_call_state.dart b/lib/features/friends/model/friend_call_state.dart new file mode 100644 index 00000000..97b7539c --- /dev/null +++ b/lib/features/friends/model/friend_call_state.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/friends/model/friend_call_model.dart'; + +part 'generated/friend_call_state.freezed.dart'; + +@freezed +abstract class FriendCallState with _$FriendCallState { + const factory FriendCallState({ + FriendCallModel? activeCall, + @Default(false) bool isMicOn, + @Default(true) bool isLoudSpeakerOn, + }) = _FriendCallState; +} diff --git a/lib/models/friends_model.dart b/lib/features/friends/model/friends_model.dart similarity index 92% rename from lib/models/friends_model.dart rename to lib/features/friends/model/friends_model.dart index 9ea3968b..3908a6a5 100644 --- a/lib/models/friends_model.dart +++ b/lib/features/friends/model/friends_model.dart @@ -40,7 +40,7 @@ abstract class FriendsModel with _$FriendsModel { userRating: recieverRating!, // Note: email, dateOfBirth are not available in FollowerUserModel - // so they will be null in the converted ResonateUser + // so they will be null in the converted ResonateUser (Legacy Structure) email: null, dateOfBirth: null, ); @@ -56,7 +56,7 @@ abstract class FriendsModel with _$FriendsModel { userRating: senderRating!, // Note: email, dateOfBirth are not available in FollowerUserModel - // so they will be null in the converted ResonateUser + // so they will be null in the converted ResonateUser (Legacy Structure) email: null, dateOfBirth: null, ); diff --git a/lib/features/friends/model/friends_state.dart b/lib/features/friends/model/friends_state.dart new file mode 100644 index 00000000..8c82417b --- /dev/null +++ b/lib/features/friends/model/friends_state.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; + +part 'generated/friends_state.freezed.dart'; + +@freezed +abstract class FriendsState with _$FriendsState { + const FriendsState._(); + + const factory FriendsState({ + @Default([]) List friends, + @Default([]) List friendRequests, + }) = _FriendsState; + + FriendsModel? relationWith(String uid) { + for (final friend in [...friends, ...friendRequests]) { + if (friend.senderId == uid || friend.recieverId == uid) return friend; + } + return null; + } +} + +@freezed +sealed class FriendsFailure with _$FriendsFailure { + const factory FriendsFailure.notFound() = FriendsFailureNotFound; + const factory FriendsFailure.network() = FriendsFailureNetwork; + const factory FriendsFailure.permissionDenied() = FriendsFailurePermissionDenied; + const factory FriendsFailure.unknown(String message) = FriendsFailureUnknown; +} diff --git a/lib/models/generated/friend_call_model.freezed.dart b/lib/features/friends/model/generated/friend_call_model.freezed.dart similarity index 100% rename from lib/models/generated/friend_call_model.freezed.dart rename to lib/features/friends/model/generated/friend_call_model.freezed.dart diff --git a/lib/models/generated/friend_call_model.g.dart b/lib/features/friends/model/generated/friend_call_model.g.dart similarity index 100% rename from lib/models/generated/friend_call_model.g.dart rename to lib/features/friends/model/generated/friend_call_model.g.dart diff --git a/lib/features/friends/model/generated/friend_call_state.freezed.dart b/lib/features/friends/model/generated/friend_call_state.freezed.dart new file mode 100644 index 00000000..304aa8c9 --- /dev/null +++ b/lib/features/friends/model/generated/friend_call_state.freezed.dart @@ -0,0 +1,301 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../friend_call_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$FriendCallState { + + FriendCallModel? get activeCall; bool get isMicOn; bool get isLoudSpeakerOn; +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FriendCallStateCopyWith get copyWith => _$FriendCallStateCopyWithImpl(this as FriendCallState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendCallState&&(identical(other.activeCall, activeCall) || other.activeCall == activeCall)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isLoudSpeakerOn, isLoudSpeakerOn) || other.isLoudSpeakerOn == isLoudSpeakerOn)); +} + + +@override +int get hashCode => Object.hash(runtimeType,activeCall,isMicOn,isLoudSpeakerOn); + +@override +String toString() { + return 'FriendCallState(activeCall: $activeCall, isMicOn: $isMicOn, isLoudSpeakerOn: $isLoudSpeakerOn)'; +} + + +} + +/// @nodoc +abstract mixin class $FriendCallStateCopyWith<$Res> { + factory $FriendCallStateCopyWith(FriendCallState value, $Res Function(FriendCallState) _then) = _$FriendCallStateCopyWithImpl; +@useResult +$Res call({ + FriendCallModel? activeCall, bool isMicOn, bool isLoudSpeakerOn +}); + + +$FriendCallModelCopyWith<$Res>? get activeCall; + +} +/// @nodoc +class _$FriendCallStateCopyWithImpl<$Res> + implements $FriendCallStateCopyWith<$Res> { + _$FriendCallStateCopyWithImpl(this._self, this._then); + + final FriendCallState _self; + final $Res Function(FriendCallState) _then; + +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? activeCall = freezed,Object? isMicOn = null,Object? isLoudSpeakerOn = null,}) { + return _then(_self.copyWith( +activeCall: freezed == activeCall ? _self.activeCall : activeCall // ignore: cast_nullable_to_non_nullable +as FriendCallModel?,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isLoudSpeakerOn: null == isLoudSpeakerOn ? _self.isLoudSpeakerOn : isLoudSpeakerOn // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FriendCallModelCopyWith<$Res>? get activeCall { + if (_self.activeCall == null) { + return null; + } + + return $FriendCallModelCopyWith<$Res>(_self.activeCall!, (value) { + return _then(_self.copyWith(activeCall: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [FriendCallState]. +extension FriendCallStatePatterns on FriendCallState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FriendCallState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FriendCallState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FriendCallState value) $default,){ +final _that = this; +switch (_that) { +case _FriendCallState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FriendCallState value)? $default,){ +final _that = this; +switch (_that) { +case _FriendCallState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( FriendCallModel? activeCall, bool isMicOn, bool isLoudSpeakerOn)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FriendCallState() when $default != null: +return $default(_that.activeCall,_that.isMicOn,_that.isLoudSpeakerOn);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( FriendCallModel? activeCall, bool isMicOn, bool isLoudSpeakerOn) $default,) {final _that = this; +switch (_that) { +case _FriendCallState(): +return $default(_that.activeCall,_that.isMicOn,_that.isLoudSpeakerOn);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( FriendCallModel? activeCall, bool isMicOn, bool isLoudSpeakerOn)? $default,) {final _that = this; +switch (_that) { +case _FriendCallState() when $default != null: +return $default(_that.activeCall,_that.isMicOn,_that.isLoudSpeakerOn);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FriendCallState implements FriendCallState { + const _FriendCallState({this.activeCall, this.isMicOn = false, this.isLoudSpeakerOn = true}); + + +@override final FriendCallModel? activeCall; +@override@JsonKey() final bool isMicOn; +@override@JsonKey() final bool isLoudSpeakerOn; + +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FriendCallStateCopyWith<_FriendCallState> get copyWith => __$FriendCallStateCopyWithImpl<_FriendCallState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FriendCallState&&(identical(other.activeCall, activeCall) || other.activeCall == activeCall)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isLoudSpeakerOn, isLoudSpeakerOn) || other.isLoudSpeakerOn == isLoudSpeakerOn)); +} + + +@override +int get hashCode => Object.hash(runtimeType,activeCall,isMicOn,isLoudSpeakerOn); + +@override +String toString() { + return 'FriendCallState(activeCall: $activeCall, isMicOn: $isMicOn, isLoudSpeakerOn: $isLoudSpeakerOn)'; +} + + +} + +/// @nodoc +abstract mixin class _$FriendCallStateCopyWith<$Res> implements $FriendCallStateCopyWith<$Res> { + factory _$FriendCallStateCopyWith(_FriendCallState value, $Res Function(_FriendCallState) _then) = __$FriendCallStateCopyWithImpl; +@override @useResult +$Res call({ + FriendCallModel? activeCall, bool isMicOn, bool isLoudSpeakerOn +}); + + +@override $FriendCallModelCopyWith<$Res>? get activeCall; + +} +/// @nodoc +class __$FriendCallStateCopyWithImpl<$Res> + implements _$FriendCallStateCopyWith<$Res> { + __$FriendCallStateCopyWithImpl(this._self, this._then); + + final _FriendCallState _self; + final $Res Function(_FriendCallState) _then; + +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? activeCall = freezed,Object? isMicOn = null,Object? isLoudSpeakerOn = null,}) { + return _then(_FriendCallState( +activeCall: freezed == activeCall ? _self.activeCall : activeCall // ignore: cast_nullable_to_non_nullable +as FriendCallModel?,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isLoudSpeakerOn: null == isLoudSpeakerOn ? _self.isLoudSpeakerOn : isLoudSpeakerOn // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of FriendCallState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$FriendCallModelCopyWith<$Res>? get activeCall { + if (_self.activeCall == null) { + return null; + } + + return $FriendCallModelCopyWith<$Res>(_self.activeCall!, (value) { + return _then(_self.copyWith(activeCall: value)); + }); +} +} + +// dart format on diff --git a/lib/models/generated/friends_model.freezed.dart b/lib/features/friends/model/generated/friends_model.freezed.dart similarity index 100% rename from lib/models/generated/friends_model.freezed.dart rename to lib/features/friends/model/generated/friends_model.freezed.dart diff --git a/lib/models/generated/friends_model.g.dart b/lib/features/friends/model/generated/friends_model.g.dart similarity index 100% rename from lib/models/generated/friends_model.g.dart rename to lib/features/friends/model/generated/friends_model.g.dart diff --git a/lib/features/friends/model/generated/friends_state.freezed.dart b/lib/features/friends/model/generated/friends_state.freezed.dart new file mode 100644 index 00000000..ecb97571 --- /dev/null +++ b/lib/features/friends/model/generated/friends_state.freezed.dart @@ -0,0 +1,620 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../friends_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$FriendsState { + + List get friends; List get friendRequests; +/// Create a copy of FriendsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FriendsStateCopyWith get copyWith => _$FriendsStateCopyWithImpl(this as FriendsState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsState&&const DeepCollectionEquality().equals(other.friends, friends)&&const DeepCollectionEquality().equals(other.friendRequests, friendRequests)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(friends),const DeepCollectionEquality().hash(friendRequests)); + +@override +String toString() { + return 'FriendsState(friends: $friends, friendRequests: $friendRequests)'; +} + + +} + +/// @nodoc +abstract mixin class $FriendsStateCopyWith<$Res> { + factory $FriendsStateCopyWith(FriendsState value, $Res Function(FriendsState) _then) = _$FriendsStateCopyWithImpl; +@useResult +$Res call({ + List friends, List friendRequests +}); + + + + +} +/// @nodoc +class _$FriendsStateCopyWithImpl<$Res> + implements $FriendsStateCopyWith<$Res> { + _$FriendsStateCopyWithImpl(this._self, this._then); + + final FriendsState _self; + final $Res Function(FriendsState) _then; + +/// Create a copy of FriendsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? friends = null,Object? friendRequests = null,}) { + return _then(_self.copyWith( +friends: null == friends ? _self.friends : friends // ignore: cast_nullable_to_non_nullable +as List,friendRequests: null == friendRequests ? _self.friendRequests : friendRequests // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FriendsState]. +extension FriendsStatePatterns on FriendsState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FriendsState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FriendsState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FriendsState value) $default,){ +final _that = this; +switch (_that) { +case _FriendsState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FriendsState value)? $default,){ +final _that = this; +switch (_that) { +case _FriendsState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List friends, List friendRequests)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FriendsState() when $default != null: +return $default(_that.friends,_that.friendRequests);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List friends, List friendRequests) $default,) {final _that = this; +switch (_that) { +case _FriendsState(): +return $default(_that.friends,_that.friendRequests);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List friends, List friendRequests)? $default,) {final _that = this; +switch (_that) { +case _FriendsState() when $default != null: +return $default(_that.friends,_that.friendRequests);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FriendsState extends FriendsState { + const _FriendsState({final List friends = const [], final List friendRequests = const []}): _friends = friends,_friendRequests = friendRequests,super._(); + + + final List _friends; +@override@JsonKey() List get friends { + if (_friends is EqualUnmodifiableListView) return _friends; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_friends); +} + + final List _friendRequests; +@override@JsonKey() List get friendRequests { + if (_friendRequests is EqualUnmodifiableListView) return _friendRequests; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_friendRequests); +} + + +/// Create a copy of FriendsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FriendsStateCopyWith<_FriendsState> get copyWith => __$FriendsStateCopyWithImpl<_FriendsState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FriendsState&&const DeepCollectionEquality().equals(other._friends, _friends)&&const DeepCollectionEquality().equals(other._friendRequests, _friendRequests)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_friends),const DeepCollectionEquality().hash(_friendRequests)); + +@override +String toString() { + return 'FriendsState(friends: $friends, friendRequests: $friendRequests)'; +} + + +} + +/// @nodoc +abstract mixin class _$FriendsStateCopyWith<$Res> implements $FriendsStateCopyWith<$Res> { + factory _$FriendsStateCopyWith(_FriendsState value, $Res Function(_FriendsState) _then) = __$FriendsStateCopyWithImpl; +@override @useResult +$Res call({ + List friends, List friendRequests +}); + + + + +} +/// @nodoc +class __$FriendsStateCopyWithImpl<$Res> + implements _$FriendsStateCopyWith<$Res> { + __$FriendsStateCopyWithImpl(this._self, this._then); + + final _FriendsState _self; + final $Res Function(_FriendsState) _then; + +/// Create a copy of FriendsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? friends = null,Object? friendRequests = null,}) { + return _then(_FriendsState( +friends: null == friends ? _self._friends : friends // ignore: cast_nullable_to_non_nullable +as List,friendRequests: null == friendRequests ? _self._friendRequests : friendRequests // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$FriendsFailure { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'FriendsFailure()'; +} + + +} + +/// @nodoc +class $FriendsFailureCopyWith<$Res> { +$FriendsFailureCopyWith(FriendsFailure _, $Res Function(FriendsFailure) __); +} + + +/// Adds pattern-matching-related methods to [FriendsFailure]. +extension FriendsFailurePatterns on FriendsFailure { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( FriendsFailureNotFound value)? notFound,TResult Function( FriendsFailureNetwork value)? network,TResult Function( FriendsFailurePermissionDenied value)? permissionDenied,TResult Function( FriendsFailureUnknown value)? unknown,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case FriendsFailureNotFound() when notFound != null: +return notFound(_that);case FriendsFailureNetwork() when network != null: +return network(_that);case FriendsFailurePermissionDenied() when permissionDenied != null: +return permissionDenied(_that);case FriendsFailureUnknown() when unknown != null: +return unknown(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( FriendsFailureNotFound value) notFound,required TResult Function( FriendsFailureNetwork value) network,required TResult Function( FriendsFailurePermissionDenied value) permissionDenied,required TResult Function( FriendsFailureUnknown value) unknown,}){ +final _that = this; +switch (_that) { +case FriendsFailureNotFound(): +return notFound(_that);case FriendsFailureNetwork(): +return network(_that);case FriendsFailurePermissionDenied(): +return permissionDenied(_that);case FriendsFailureUnknown(): +return unknown(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( FriendsFailureNotFound value)? notFound,TResult? Function( FriendsFailureNetwork value)? network,TResult? Function( FriendsFailurePermissionDenied value)? permissionDenied,TResult? Function( FriendsFailureUnknown value)? unknown,}){ +final _that = this; +switch (_that) { +case FriendsFailureNotFound() when notFound != null: +return notFound(_that);case FriendsFailureNetwork() when network != null: +return network(_that);case FriendsFailurePermissionDenied() when permissionDenied != null: +return permissionDenied(_that);case FriendsFailureUnknown() when unknown != null: +return unknown(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? notFound,TResult Function()? network,TResult Function()? permissionDenied,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; +switch (_that) { +case FriendsFailureNotFound() when notFound != null: +return notFound();case FriendsFailureNetwork() when network != null: +return network();case FriendsFailurePermissionDenied() when permissionDenied != null: +return permissionDenied();case FriendsFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() notFound,required TResult Function() network,required TResult Function() permissionDenied,required TResult Function( String message) unknown,}) {final _that = this; +switch (_that) { +case FriendsFailureNotFound(): +return notFound();case FriendsFailureNetwork(): +return network();case FriendsFailurePermissionDenied(): +return permissionDenied();case FriendsFailureUnknown(): +return unknown(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? notFound,TResult? Function()? network,TResult? Function()? permissionDenied,TResult? Function( String message)? unknown,}) {final _that = this; +switch (_that) { +case FriendsFailureNotFound() when notFound != null: +return notFound();case FriendsFailureNetwork() when network != null: +return network();case FriendsFailurePermissionDenied() when permissionDenied != null: +return permissionDenied();case FriendsFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class FriendsFailureNotFound implements FriendsFailure { + const FriendsFailureNotFound(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsFailureNotFound); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'FriendsFailure.notFound()'; +} + + +} + + + + +/// @nodoc + + +class FriendsFailureNetwork implements FriendsFailure { + const FriendsFailureNetwork(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsFailureNetwork); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'FriendsFailure.network()'; +} + + +} + + + + +/// @nodoc + + +class FriendsFailurePermissionDenied implements FriendsFailure { + const FriendsFailurePermissionDenied(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsFailurePermissionDenied); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'FriendsFailure.permissionDenied()'; +} + + +} + + + + +/// @nodoc + + +class FriendsFailureUnknown implements FriendsFailure { + const FriendsFailureUnknown(this.message); + + + final String message; + +/// Create a copy of FriendsFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FriendsFailureUnknownCopyWith get copyWith => _$FriendsFailureUnknownCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FriendsFailureUnknown&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'FriendsFailure.unknown(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $FriendsFailureUnknownCopyWith<$Res> implements $FriendsFailureCopyWith<$Res> { + factory $FriendsFailureUnknownCopyWith(FriendsFailureUnknown value, $Res Function(FriendsFailureUnknown) _then) = _$FriendsFailureUnknownCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$FriendsFailureUnknownCopyWithImpl<$Res> + implements $FriendsFailureUnknownCopyWith<$Res> { + _$FriendsFailureUnknownCopyWithImpl(this._self, this._then); + + final FriendsFailureUnknown _self; + final $Res Function(FriendsFailureUnknown) _then; + +/// Create a copy of FriendsFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(FriendsFailureUnknown( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/friends/model/generated/pair_chat_state.freezed.dart b/lib/features/friends/model/generated/pair_chat_state.freezed.dart new file mode 100644 index 00000000..0ccb6efa --- /dev/null +++ b/lib/features/friends/model/generated/pair_chat_state.freezed.dart @@ -0,0 +1,312 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../pair_chat_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PairChatState { + + bool get isAnonymous; String get languageIso; bool get isMicOn; bool get isLoudSpeakerOn; String? get requestDocId; String? get activePairDocId; String? get pairUsername; String? get pairProfileImageUrl; double get pairRating; List get onlineUsers; bool get isUserListLoading;// showing the rating sheet and returning to the tab view. + bool get ended; +/// Create a copy of PairChatState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PairChatStateCopyWith get copyWith => _$PairChatStateCopyWithImpl(this as PairChatState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PairChatState&&(identical(other.isAnonymous, isAnonymous) || other.isAnonymous == isAnonymous)&&(identical(other.languageIso, languageIso) || other.languageIso == languageIso)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isLoudSpeakerOn, isLoudSpeakerOn) || other.isLoudSpeakerOn == isLoudSpeakerOn)&&(identical(other.requestDocId, requestDocId) || other.requestDocId == requestDocId)&&(identical(other.activePairDocId, activePairDocId) || other.activePairDocId == activePairDocId)&&(identical(other.pairUsername, pairUsername) || other.pairUsername == pairUsername)&&(identical(other.pairProfileImageUrl, pairProfileImageUrl) || other.pairProfileImageUrl == pairProfileImageUrl)&&(identical(other.pairRating, pairRating) || other.pairRating == pairRating)&&const DeepCollectionEquality().equals(other.onlineUsers, onlineUsers)&&(identical(other.isUserListLoading, isUserListLoading) || other.isUserListLoading == isUserListLoading)&&(identical(other.ended, ended) || other.ended == ended)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isAnonymous,languageIso,isMicOn,isLoudSpeakerOn,requestDocId,activePairDocId,pairUsername,pairProfileImageUrl,pairRating,const DeepCollectionEquality().hash(onlineUsers),isUserListLoading,ended); + +@override +String toString() { + return 'PairChatState(isAnonymous: $isAnonymous, languageIso: $languageIso, isMicOn: $isMicOn, isLoudSpeakerOn: $isLoudSpeakerOn, requestDocId: $requestDocId, activePairDocId: $activePairDocId, pairUsername: $pairUsername, pairProfileImageUrl: $pairProfileImageUrl, pairRating: $pairRating, onlineUsers: $onlineUsers, isUserListLoading: $isUserListLoading, ended: $ended)'; +} + + +} + +/// @nodoc +abstract mixin class $PairChatStateCopyWith<$Res> { + factory $PairChatStateCopyWith(PairChatState value, $Res Function(PairChatState) _then) = _$PairChatStateCopyWithImpl; +@useResult +$Res call({ + bool isAnonymous, String languageIso, bool isMicOn, bool isLoudSpeakerOn, String? requestDocId, String? activePairDocId, String? pairUsername, String? pairProfileImageUrl, double pairRating, List onlineUsers, bool isUserListLoading, bool ended +}); + + + + +} +/// @nodoc +class _$PairChatStateCopyWithImpl<$Res> + implements $PairChatStateCopyWith<$Res> { + _$PairChatStateCopyWithImpl(this._self, this._then); + + final PairChatState _self; + final $Res Function(PairChatState) _then; + +/// Create a copy of PairChatState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isAnonymous = null,Object? languageIso = null,Object? isMicOn = null,Object? isLoudSpeakerOn = null,Object? requestDocId = freezed,Object? activePairDocId = freezed,Object? pairUsername = freezed,Object? pairProfileImageUrl = freezed,Object? pairRating = null,Object? onlineUsers = null,Object? isUserListLoading = null,Object? ended = null,}) { + return _then(_self.copyWith( +isAnonymous: null == isAnonymous ? _self.isAnonymous : isAnonymous // ignore: cast_nullable_to_non_nullable +as bool,languageIso: null == languageIso ? _self.languageIso : languageIso // ignore: cast_nullable_to_non_nullable +as String,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isLoudSpeakerOn: null == isLoudSpeakerOn ? _self.isLoudSpeakerOn : isLoudSpeakerOn // ignore: cast_nullable_to_non_nullable +as bool,requestDocId: freezed == requestDocId ? _self.requestDocId : requestDocId // ignore: cast_nullable_to_non_nullable +as String?,activePairDocId: freezed == activePairDocId ? _self.activePairDocId : activePairDocId // ignore: cast_nullable_to_non_nullable +as String?,pairUsername: freezed == pairUsername ? _self.pairUsername : pairUsername // ignore: cast_nullable_to_non_nullable +as String?,pairProfileImageUrl: freezed == pairProfileImageUrl ? _self.pairProfileImageUrl : pairProfileImageUrl // ignore: cast_nullable_to_non_nullable +as String?,pairRating: null == pairRating ? _self.pairRating : pairRating // ignore: cast_nullable_to_non_nullable +as double,onlineUsers: null == onlineUsers ? _self.onlineUsers : onlineUsers // ignore: cast_nullable_to_non_nullable +as List,isUserListLoading: null == isUserListLoading ? _self.isUserListLoading : isUserListLoading // ignore: cast_nullable_to_non_nullable +as bool,ended: null == ended ? _self.ended : ended // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PairChatState]. +extension PairChatStatePatterns on PairChatState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PairChatState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PairChatState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PairChatState value) $default,){ +final _that = this; +switch (_that) { +case _PairChatState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PairChatState value)? $default,){ +final _that = this; +switch (_that) { +case _PairChatState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isAnonymous, String languageIso, bool isMicOn, bool isLoudSpeakerOn, String? requestDocId, String? activePairDocId, String? pairUsername, String? pairProfileImageUrl, double pairRating, List onlineUsers, bool isUserListLoading, bool ended)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PairChatState() when $default != null: +return $default(_that.isAnonymous,_that.languageIso,_that.isMicOn,_that.isLoudSpeakerOn,_that.requestDocId,_that.activePairDocId,_that.pairUsername,_that.pairProfileImageUrl,_that.pairRating,_that.onlineUsers,_that.isUserListLoading,_that.ended);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isAnonymous, String languageIso, bool isMicOn, bool isLoudSpeakerOn, String? requestDocId, String? activePairDocId, String? pairUsername, String? pairProfileImageUrl, double pairRating, List onlineUsers, bool isUserListLoading, bool ended) $default,) {final _that = this; +switch (_that) { +case _PairChatState(): +return $default(_that.isAnonymous,_that.languageIso,_that.isMicOn,_that.isLoudSpeakerOn,_that.requestDocId,_that.activePairDocId,_that.pairUsername,_that.pairProfileImageUrl,_that.pairRating,_that.onlineUsers,_that.isUserListLoading,_that.ended);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isAnonymous, String languageIso, bool isMicOn, bool isLoudSpeakerOn, String? requestDocId, String? activePairDocId, String? pairUsername, String? pairProfileImageUrl, double pairRating, List onlineUsers, bool isUserListLoading, bool ended)? $default,) {final _that = this; +switch (_that) { +case _PairChatState() when $default != null: +return $default(_that.isAnonymous,_that.languageIso,_that.isMicOn,_that.isLoudSpeakerOn,_that.requestDocId,_that.activePairDocId,_that.pairUsername,_that.pairProfileImageUrl,_that.pairRating,_that.onlineUsers,_that.isUserListLoading,_that.ended);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PairChatState implements PairChatState { + const _PairChatState({this.isAnonymous = true, this.languageIso = 'en', this.isMicOn = false, this.isLoudSpeakerOn = true, this.requestDocId, this.activePairDocId, this.pairUsername, this.pairProfileImageUrl, this.pairRating = 2.5, final List onlineUsers = const [], this.isUserListLoading = false, this.ended = false}): _onlineUsers = onlineUsers; + + +@override@JsonKey() final bool isAnonymous; +@override@JsonKey() final String languageIso; +@override@JsonKey() final bool isMicOn; +@override@JsonKey() final bool isLoudSpeakerOn; +@override final String? requestDocId; +@override final String? activePairDocId; +@override final String? pairUsername; +@override final String? pairProfileImageUrl; +@override@JsonKey() final double pairRating; + final List _onlineUsers; +@override@JsonKey() List get onlineUsers { + if (_onlineUsers is EqualUnmodifiableListView) return _onlineUsers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_onlineUsers); +} + +@override@JsonKey() final bool isUserListLoading; +// showing the rating sheet and returning to the tab view. +@override@JsonKey() final bool ended; + +/// Create a copy of PairChatState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PairChatStateCopyWith<_PairChatState> get copyWith => __$PairChatStateCopyWithImpl<_PairChatState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PairChatState&&(identical(other.isAnonymous, isAnonymous) || other.isAnonymous == isAnonymous)&&(identical(other.languageIso, languageIso) || other.languageIso == languageIso)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)&&(identical(other.isLoudSpeakerOn, isLoudSpeakerOn) || other.isLoudSpeakerOn == isLoudSpeakerOn)&&(identical(other.requestDocId, requestDocId) || other.requestDocId == requestDocId)&&(identical(other.activePairDocId, activePairDocId) || other.activePairDocId == activePairDocId)&&(identical(other.pairUsername, pairUsername) || other.pairUsername == pairUsername)&&(identical(other.pairProfileImageUrl, pairProfileImageUrl) || other.pairProfileImageUrl == pairProfileImageUrl)&&(identical(other.pairRating, pairRating) || other.pairRating == pairRating)&&const DeepCollectionEquality().equals(other._onlineUsers, _onlineUsers)&&(identical(other.isUserListLoading, isUserListLoading) || other.isUserListLoading == isUserListLoading)&&(identical(other.ended, ended) || other.ended == ended)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isAnonymous,languageIso,isMicOn,isLoudSpeakerOn,requestDocId,activePairDocId,pairUsername,pairProfileImageUrl,pairRating,const DeepCollectionEquality().hash(_onlineUsers),isUserListLoading,ended); + +@override +String toString() { + return 'PairChatState(isAnonymous: $isAnonymous, languageIso: $languageIso, isMicOn: $isMicOn, isLoudSpeakerOn: $isLoudSpeakerOn, requestDocId: $requestDocId, activePairDocId: $activePairDocId, pairUsername: $pairUsername, pairProfileImageUrl: $pairProfileImageUrl, pairRating: $pairRating, onlineUsers: $onlineUsers, isUserListLoading: $isUserListLoading, ended: $ended)'; +} + + +} + +/// @nodoc +abstract mixin class _$PairChatStateCopyWith<$Res> implements $PairChatStateCopyWith<$Res> { + factory _$PairChatStateCopyWith(_PairChatState value, $Res Function(_PairChatState) _then) = __$PairChatStateCopyWithImpl; +@override @useResult +$Res call({ + bool isAnonymous, String languageIso, bool isMicOn, bool isLoudSpeakerOn, String? requestDocId, String? activePairDocId, String? pairUsername, String? pairProfileImageUrl, double pairRating, List onlineUsers, bool isUserListLoading, bool ended +}); + + + + +} +/// @nodoc +class __$PairChatStateCopyWithImpl<$Res> + implements _$PairChatStateCopyWith<$Res> { + __$PairChatStateCopyWithImpl(this._self, this._then); + + final _PairChatState _self; + final $Res Function(_PairChatState) _then; + +/// Create a copy of PairChatState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isAnonymous = null,Object? languageIso = null,Object? isMicOn = null,Object? isLoudSpeakerOn = null,Object? requestDocId = freezed,Object? activePairDocId = freezed,Object? pairUsername = freezed,Object? pairProfileImageUrl = freezed,Object? pairRating = null,Object? onlineUsers = null,Object? isUserListLoading = null,Object? ended = null,}) { + return _then(_PairChatState( +isAnonymous: null == isAnonymous ? _self.isAnonymous : isAnonymous // ignore: cast_nullable_to_non_nullable +as bool,languageIso: null == languageIso ? _self.languageIso : languageIso // ignore: cast_nullable_to_non_nullable +as String,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool,isLoudSpeakerOn: null == isLoudSpeakerOn ? _self.isLoudSpeakerOn : isLoudSpeakerOn // ignore: cast_nullable_to_non_nullable +as bool,requestDocId: freezed == requestDocId ? _self.requestDocId : requestDocId // ignore: cast_nullable_to_non_nullable +as String?,activePairDocId: freezed == activePairDocId ? _self.activePairDocId : activePairDocId // ignore: cast_nullable_to_non_nullable +as String?,pairUsername: freezed == pairUsername ? _self.pairUsername : pairUsername // ignore: cast_nullable_to_non_nullable +as String?,pairProfileImageUrl: freezed == pairProfileImageUrl ? _self.pairProfileImageUrl : pairProfileImageUrl // ignore: cast_nullable_to_non_nullable +as String?,pairRating: null == pairRating ? _self.pairRating : pairRating // ignore: cast_nullable_to_non_nullable +as double,onlineUsers: null == onlineUsers ? _self._onlineUsers : onlineUsers // ignore: cast_nullable_to_non_nullable +as List,isUserListLoading: null == isUserListLoading ? _self.isUserListLoading : isUserListLoading // ignore: cast_nullable_to_non_nullable +as bool,ended: null == ended ? _self.ended : ended // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/features/friends/model/pair_chat_state.dart b/lib/features/friends/model/pair_chat_state.dart new file mode 100644 index 00000000..c4234a81 --- /dev/null +++ b/lib/features/friends/model/pair_chat_state.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/models/resonate_user.dart'; + +part 'generated/pair_chat_state.freezed.dart'; + +@freezed +abstract class PairChatState with _$PairChatState { + const factory PairChatState({ + @Default(true) bool isAnonymous, + @Default('en') String languageIso, + @Default(false) bool isMicOn, + @Default(true) bool isLoudSpeakerOn, + String? requestDocId, + String? activePairDocId, + String? pairUsername, + String? pairProfileImageUrl, + @Default(2.5) double pairRating, + @Default([]) List onlineUsers, + @Default(false) bool isUserListLoading, + // showing the rating sheet and returning to the tab view. + @Default(false) bool ended, + }) = _PairChatState; +} diff --git a/lib/features/friends/view/pages/friend_call_page.dart b/lib/features/friends/view/pages/friend_call_page.dart new file mode 100644 index 00000000..5802a4b7 --- /dev/null +++ b/lib/features/friends/view/pages/friend_call_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/friends/view/widgets/call_control_panel.dart'; +import 'package:resonate/features/friends/view/widgets/call_user_info_row.dart'; +import 'package:resonate/features/friends/viewmodel/friend_call_notifier.dart'; +import 'package:resonate/features/rooms/view/widgets/audio_selector_dialog.dart'; +import 'package:resonate/features/rooms/view/widgets/room_header.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class FriendCallPage extends ConsumerWidget { + const FriendCallPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final callState = ref.watch(friendCallProvider); + final notifier = ref.read(friendCallProvider.notifier); + final call = callState.activeCall; + if (call == null) return const Scaffold(body: SizedBox.shrink()); + + return PopScope( + canPop: false, + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_10, + horizontal: UiSizes.width_20, + ), + child: Column( + children: [ + RoomHeader( + roomName: AppLocalizations.of(context)!.title, + roomDescription: AppLocalizations.of( + context, + )!.roomDescription, + ), + SizedBox(height: UiSizes.height_24_6), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CallUserInfoRow( + imageUrl: call.callerProfileImageUrl, + userName: call.callerName, + ), + SizedBox(height: UiSizes.height_20), + CallUserInfoRow( + imageUrl: call.recieverProfileImageUrl, + userName: call.recieverName, + ), + ], + ), + SizedBox(height: UiSizes.height_24_6), + ], + ), + ), + const Spacer(), + CallControlPanel( + buttons: [ + CallControlButton( + icon: callState.isMicOn ? Icons.mic : Icons.mic_off, + label: AppLocalizations.of(context)!.mute, + onPressed: notifier.toggleMic, + backgroundColor: callState.isMicOn + ? CallControlPanel.inactiveButtonColor(context) + : Theme.of(context).colorScheme.primary, + heroTag: "mic", + ), + CallControlButton( + icon: Icons.volume_up, + label: AppLocalizations.of(context)!.speakerLabel, + onPressed: notifier.toggleLoudSpeaker, + backgroundColor: callState.isLoudSpeakerOn + ? Theme.of(context).colorScheme.primary + : CallControlPanel.inactiveButtonColor(context), + heroTag: "speaker", + ), + CallControlButton( + icon: Icons.settings_voice, + label: AppLocalizations.of(context)!.audioOptions, + onPressed: () => showAudioDeviceSelector(context), + backgroundColor: + CallControlPanel.inactiveButtonColor(context), + heroTag: "audio-settings", + ), + CallControlButton( + icon: Icons.cancel_outlined, + label: AppLocalizations.of(context)!.end, + onPressed: () => notifier.endCall(), + backgroundColor: Theme.of(context).colorScheme.error, + heroTag: "end-chat", + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/friends/view/pages/friend_requests_page.dart b/lib/features/friends/view/pages/friend_requests_page.dart new file mode 100644 index 00000000..69f4511c --- /dev/null +++ b/lib/features/friends/view/pages/friend_requests_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/friends/view/widgets/friend_list_tile.dart'; +import 'package:resonate/features/friends/view/widgets/friends_empty_view.dart'; +import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; + +class FriendRequestsPage extends ConsumerWidget { + const FriendRequestsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final friendsAsync = ref.watch(friendsProvider); + + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.friendRequests)), + body: friendsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const FriendsEmptyView(isRequestsScreen: true), + data: (state) { + // Only requests addressed to us + final incomingRequests = state.friendRequests + .where( + (friend) => + friend.requestSentByUserId != requireCurrentAuthUser.uid, + ) + .toList(); + if (incomingRequests.isEmpty) { + return const FriendsEmptyView(isRequestsScreen: true); + } + return ListView.builder( + itemCount: incomingRequests.length, + shrinkWrap: true, + itemBuilder: (context, index) { + return FriendListTile( + friendModel: incomingRequests[index], + isRequest: true, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/features/friends/view/pages/friends_page.dart b/lib/features/friends/view/pages/friends_page.dart new file mode 100644 index 00000000..abd2b86f --- /dev/null +++ b/lib/features/friends/view/pages/friends_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/friends/view/widgets/friend_list_tile.dart'; +import 'package:resonate/features/friends/view/widgets/friends_empty_view.dart'; +import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; + +class FriendsPage extends ConsumerWidget { + const FriendsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final friendsAsync = ref.watch(friendsProvider); + + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.friends)), + body: friendsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const FriendsEmptyView(isRequestsScreen: false), + data: (state) { + if (state.friends.isEmpty) { + return const FriendsEmptyView(isRequestsScreen: false); + } + return ListView.builder( + itemCount: state.friends.length, + shrinkWrap: true, + itemBuilder: (context, index) { + return FriendListTile( + friendModel: state.friends[index], + isRequest: false, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/features/friends/view/pages/pair_chat_page.dart b/lib/features/friends/view/pages/pair_chat_page.dart new file mode 100644 index 00000000..7b3faf9d --- /dev/null +++ b/lib/features/friends/view/pages/pair_chat_page.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get/get.dart' show Get, Inst; +import 'package:go_router/go_router.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/friends/view/widgets/call_control_panel.dart'; +import 'package:resonate/features/friends/view/widgets/call_user_info_row.dart'; +import 'package:resonate/features/friends/view/widgets/rating_sheet.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/features/rooms/view/widgets/room_app_bar.dart'; +import 'package:resonate/features/rooms/view/widgets/room_header.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class PairChatPage extends ConsumerStatefulWidget { + const PairChatPage({super.key}); + + @override + ConsumerState createState() => _PairChatPageState(); +} + +class _PairChatPageState extends ConsumerState { + late final PairChatNotifier _notifier; + + @override + void initState() { + super.initState(); + _notifier = ref.read(pairChatProvider.notifier); + // When the partner ended the pair while we were still joining + if (ref.read(pairChatProvider).ended) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onChatEnded()); + } + } + + @override + void dispose() { + // Leaving the page ends the chat so it can't keep running headless. + if (!ref.read(pairChatProvider).ended) _notifier.endChat(); + super.dispose(); + } + + Future _onChatEnded() async { + if (!mounted) return; + final router = GoRouter.of(context); + await showModalBottomSheet( + context: context, + builder: (_) => const RatingSheet(), + ); + router.go(RoutePaths.tabview); + } + + @override + Widget build(BuildContext context) { + final chatState = ref.watch(pairChatProvider); + // ThemeController is still GetX; bridge until the theme migrates. + final placeholderUrl = + Get.find().userProfileImagePlaceholderUrl; + + ref.listen(pairChatProvider.select((s) => s.ended), (prev, ended) { + if (ended && prev != true) _onChatEnded(); + }); + + // The old GetX LiveKitController ended the chat when the room dropped + ref.listen(liveKitProvider.select((s) => s.isConnected), (prev, connected) { + if (prev == true && !connected) { + ref.read(pairChatProvider.notifier).endChat(); + } + }); + + return PopScope( + canPop: false, + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + const RoomAppBar(), + Padding( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_10, + horizontal: UiSizes.width_20, + ), + child: Column( + children: [ + RoomHeader( + roomName: AppLocalizations.of(context)!.title, + roomDescription: AppLocalizations.of( + context, + )!.roomDescription, + ), + SizedBox(height: UiSizes.height_24_6), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CallUserInfoRow( + imageUrl: chatState.isAnonymous + ? placeholderUrl + : requireCurrentAuthUser.profileImageUrl ?? '', + userName: chatState.isAnonymous + ? AppLocalizations.of(context)!.user1 + : requireCurrentAuthUser.userName ?? '', + ), + SizedBox(height: UiSizes.height_20), + CallUserInfoRow( + imageUrl: chatState.isAnonymous + ? placeholderUrl + : chatState.pairProfileImageUrl ?? placeholderUrl, + userName: chatState.isAnonymous + ? AppLocalizations.of(context)!.user2 + : chatState.pairUsername ?? '', + ), + ], + ), + SizedBox(height: UiSizes.height_24_6), + ], + ), + ), + const Spacer(), + CallControlPanel( + buttons: [ + CallControlButton( + icon: chatState.isMicOn ? Icons.mic : Icons.mic_off, + label: AppLocalizations.of(context)!.mute, + onPressed: _notifier.toggleMic, + backgroundColor: chatState.isMicOn + ? CallControlPanel.inactiveButtonColor(context) + : Theme.of(context).colorScheme.primary, + heroTag: "mic", + ), + CallControlButton( + icon: Icons.volume_up, + label: AppLocalizations.of(context)!.speakerLabel, + onPressed: _notifier.toggleLoudSpeaker, + backgroundColor: chatState.isLoudSpeakerOn + ? Theme.of(context).colorScheme.primary + : CallControlPanel.inactiveButtonColor(context), + heroTag: "speaker", + ), + CallControlButton( + icon: Icons.cancel_outlined, + label: AppLocalizations.of(context)!.end, + onPressed: () => _notifier.endChat(), + backgroundColor: Theme.of(context).colorScheme.error, + heroTag: "end-chat", + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/friends/view/pages/pair_chat_users_page.dart b/lib/features/friends/view/pages/pair_chat_users_page.dart new file mode 100644 index 00000000..04fd8bc7 --- /dev/null +++ b/lib/features/friends/view/pages/pair_chat_users_page.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; + +class PairChatUsersPage extends ConsumerStatefulWidget { + const PairChatUsersPage({super.key}); + + @override + ConsumerState createState() => _PairChatUsersPageState(); +} + +class _PairChatUsersPageState extends ConsumerState { + late final PairChatNotifier _notifier; + + @override + void initState() { + super.initState(); + _notifier = ref.read(pairChatProvider.notifier); + _notifier.loadUsers(); + } + + @override + void dispose() { + _notifier.cancelRequest(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final chatState = ref.watch(pairChatProvider); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.onlineUsers), + actions: [ + IconButton( + onPressed: () async { + final router = GoRouter.of(context); + await _notifier.convertToRandom(); + router.push(RoutePaths.pairing); + }, + icon: Icon(Icons.casino_outlined), + ), + ], + actionsPadding: EdgeInsets.only(right: 16.0), + ), + body: chatState.isUserListLoading + ? Center(child: CircularProgressIndicator()) + : chatState.onlineUsers.isEmpty + ? Center(child: Text(AppLocalizations.of(context)!.noOnlineUsers)) + : ListView.builder( + itemCount: chatState.onlineUsers.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final user = chatState.onlineUsers[index]; + return ListTile( + onTap: () async { + await _notifier.pairWithSelectedUser(user); + }, + title: Text(user.userName ?? ''), + subtitle: Text(user.name ?? ''), + leading: CircleAvatar( + backgroundImage: NetworkImage(user.profileImageUrl ?? ''), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star, + color: Theme.of(context).colorScheme.primary, + ), + Text((user.userRating ?? 0).toStringAsFixed(1)), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/friends/view/pages/pairing_page.dart b/lib/features/friends/view/pages/pairing_page.dart new file mode 100644 index 00000000..a7588351 --- /dev/null +++ b/lib/features/friends/view/pages/pairing_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get/get.dart' show Get, Inst; +import 'package:go_router/go_router.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class PairingPage extends ConsumerWidget { + const PairingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + final onPrimaryColor = theme.colorScheme.onPrimary; + final isAnonymous = + ref.watch(pairChatProvider.select((s) => s.isAnonymous)); + // ThemeController is still GetX; bridge until the theme migrates. + final profileImageUrl = isAnonymous + ? Get.find().userProfileImagePlaceholderUrl + : requireCurrentAuthUser.profileImageUrl ?? ''; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.findingRandomPartner, + style: TextStyle( + color: primaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 6.5, + ), + ), + SizedBox(height: UiSizes.height_5), + Text( + AppLocalizations.of(context)!.hangOnGoodThingsTakeTime, + style: TextStyle( + fontSize: UiSizes.size_14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + _buildLoadingIndicator(context, primaryColor, profileImageUrl), + const Spacer(), + _buildFooter(context, ref, primaryColor, onPrimaryColor), + ], + ), + ), + ), + ); + } + + Widget _buildLoadingIndicator( + BuildContext context, + Color primaryColor, + String profileImageUrl, + ) { + return Stack( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_20, + vertical: UiSizes.height_20, + ), + child: LoadingIndicator( + indicatorType: Indicator.ballScaleMultiple, + colors: [ + primaryColor.withValues(alpha: 0.2), + primaryColor, + primaryColor.withValues(alpha: 0.6), + ], + strokeWidth: 2, + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.center, + child: CircleAvatar( + radius: MediaQuery.of(context).size.height * 0.05, + backgroundImage: NetworkImage(profileImageUrl), + ), + ), + ), + ], + ); + } + + Widget _buildFooter( + BuildContext context, + WidgetRef ref, + Color primaryColor, + Color onPrimaryColor, + ) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.quickFact, + style: TextStyle( + color: primaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 6.5, + ), + ), + Text( + AppLocalizations.of(context)!.resonateOpenSourceProject, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: UiSizes.size_14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + SizedBox(height: UiSizes.height_15), + ElevatedButton( + onPressed: () async { + final router = GoRouter.of(context); + await ref.read(pairChatProvider.notifier).cancelRequest(); + router.go(RoutePaths.tabview); + }, + style: ElevatedButton.styleFrom(backgroundColor: primaryColor), + child: Text( + AppLocalizations.of(context)!.cancel, + style: TextStyle( + color: onPrimaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 8, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/friends/view/pages/ringing_page.dart b/lib/features/friends/view/pages/ringing_page.dart new file mode 100644 index 00000000..a2db6818 --- /dev/null +++ b/lib/features/friends/view/pages/ringing_page.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/friends/viewmodel/friend_call_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class RingingPage extends ConsumerWidget { + const RingingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + final onPrimaryColor = theme.colorScheme.onPrimary; + final call = ref.watch(friendCallProvider).activeCall; + if (call == null) return const Scaffold(body: SizedBox.shrink()); + + return Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), + child: Column( + children: [ + Text( + "Calling ${call.recieverName}...", + style: TextStyle( + color: primaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 6.5, + ), + ), + SizedBox(height: UiSizes.height_5), + Text( + "Ringing...", + style: TextStyle( + fontSize: UiSizes.size_14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + _buildLoadingIndicator( + context, + primaryColor, + call.recieverProfileImageUrl, + ), + const Spacer(), + _buildFooter(context, ref, primaryColor, onPrimaryColor), + ], + ), + ), + ), + ); + } + + Widget _buildLoadingIndicator( + BuildContext context, + Color primaryColor, + String profileImageUrl, + ) { + return Stack( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_20, + vertical: UiSizes.height_20, + ), + child: LoadingIndicator( + indicatorType: Indicator.ballScaleMultiple, + colors: [ + primaryColor.withValues(alpha: 0.2), + primaryColor, + primaryColor.withValues(alpha: 0.6), + ], + strokeWidth: 2, + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.center, + child: CircleAvatar( + radius: MediaQuery.of(context).size.height * 0.05, + backgroundImage: NetworkImage(profileImageUrl), + ), + ), + ), + ], + ); + } + + Widget _buildFooter( + BuildContext context, + WidgetRef ref, + Color primaryColor, + Color onPrimaryColor, + ) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.quickFact, + style: TextStyle( + color: primaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 6.5, + ), + ), + Text( + AppLocalizations.of(context)!.resonateOpenSourceProject, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: UiSizes.size_14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + SizedBox(height: UiSizes.height_15), + ElevatedButton( + onPressed: () => ref.read(friendCallProvider.notifier).endCall(), + style: ElevatedButton.styleFrom(backgroundColor: primaryColor), + child: Text( + AppLocalizations.of(context)!.cancel, + style: TextStyle( + color: onPrimaryColor, + fontSize: MediaQuery.of(context).devicePixelRatio * 8, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/friends/view/widgets/call_control_panel.dart b/lib/features/friends/view/widgets/call_control_panel.dart new file mode 100644 index 00000000..7eefeb2f --- /dev/null +++ b/lib/features/friends/view/widgets/call_control_panel.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +// Bottom control bar shared by the friend call and pair chat pages. +class CallControlPanel extends StatelessWidget { + const CallControlPanel({super.key, required this.buttons}); + + final List buttons; + + // Background for buttons that are toggled off / neutral: they sit on the + // primary panel in light mode and on the dark container in dark mode. + static Color inactiveButtonColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return scheme.brightness == Brightness.light + ? scheme.onPrimary.withValues(alpha: 0.5) + : scheme.onSurface.withValues(alpha: 0.5); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), + color: scheme.brightness == Brightness.light + ? scheme.primary + : scheme.surfaceContainerHighest, + height: UiSizes.height_131, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: buttons, + ), + ); + } +} + +class CallControlButton extends StatelessWidget { + const CallControlButton({ + super.key, + required this.icon, + required this.label, + required this.onPressed, + required this.backgroundColor, + required this.heroTag, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + final Color backgroundColor; + final String heroTag; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: UiSizes.height_56, + width: UiSizes.width_56, + child: FloatingActionButton( + elevation: 0, + heroTag: heroTag, + onPressed: onPressed, + backgroundColor: backgroundColor, + child: Icon(icon, size: UiSizes.size_24), + ), + ), + SizedBox(height: UiSizes.height_4), + Text(label, style: TextStyle(fontSize: UiSizes.height_14)), + ], + ); + } +} diff --git a/lib/features/friends/view/widgets/call_user_info_row.dart b/lib/features/friends/view/widgets/call_user_info_row.dart new file mode 100644 index 00000000..27755c21 --- /dev/null +++ b/lib/features/friends/view/widgets/call_user_info_row.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +// Avatar + name row shared by the friend call and pair chat pages. +class CallUserInfoRow extends StatelessWidget { + const CallUserInfoRow({ + super.key, + required this.imageUrl, + required this.userName, + }); + + final String imageUrl; + final String userName; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + backgroundImage: NetworkImage(imageUrl), + radius: UiSizes.width_66, + ), + SizedBox(width: UiSizes.width_16), + Container( + alignment: Alignment.center, + width: UiSizes.width_100, + child: Text( + userName, + style: TextStyle(fontSize: UiSizes.size_16), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/lib/features/friends/view/widgets/friend_list_tile.dart b/lib/features/friends/view/widgets/friend_list_tile.dart new file mode 100644 index 00000000..f87ef428 --- /dev/null +++ b/lib/features/friends/view/widgets/friend_list_tile.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/viewmodel/friend_call_notifier.dart'; +import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; +import 'package:resonate/features/profile/view/pages/profile_page.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class FriendListTile extends ConsumerStatefulWidget { + final FriendsModel friendModel; + final bool isRequest; + + const FriendListTile({ + required this.friendModel, + required this.isRequest, + super.key, + }); + + @override + ConsumerState createState() => _FriendListTileState(); +} + +class _FriendListTileState extends ConsumerState { + bool _isProcessing = false; + + FriendsModel get friendModel => widget.friendModel; + + Future _runAction(Future Function() action) async { + setState(() => _isProcessing = true); + try { + await action(); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + @override + Widget build(BuildContext context) { + final bool userIsSender = + friendModel.senderId == requireCurrentAuthUser.uid; + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfilePage( + creator: userIsSender + ? friendModel.recieverToResonateUserForRequestsPage() + : friendModel.senderToResonateUserForRequestsPage(), + isCreatorProfile: true, + ), + ), + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.secondary, + ), + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ListTile( + contentPadding: const EdgeInsets.all(0), + leading: CircleAvatar( + backgroundImage: NetworkImage( + userIsSender + ? friendModel.recieverProfileImgUrl + : friendModel.senderProfileImgUrl, + ), + radius: 25, + ), + trailing: _isProcessing + ? LoadingIndicator( + indicatorType: Indicator.ballRotate, + colors: [Theme.of(context).colorScheme.primary], + ) + : widget.isRequest + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _runAction(() async { + final l10n = AppLocalizations.of(context)!; + await ref + .read(friendsProvider.notifier) + .acceptFriendRequest(friendModel); + customSnackbar( + l10n.friendRequestAccepted, + l10n.friendRequestAcceptedTo(friendModel.senderName), + LogType.success, + ); + }), + icon: Icon(Icons.check), + color: Theme.of(context).colorScheme.primary, + ), + IconButton( + onPressed: () => _runAction(() async { + final l10n = AppLocalizations.of(context)!; + await ref + .read(friendsProvider.notifier) + .declineFriendRequest(friendModel); + customSnackbar( + l10n.friendRequestDeclined, + l10n.friendRequestDeclinedTo(friendModel.senderName), + LogType.info, + ); + }), + icon: Icon(Icons.close), + color: Theme.of(context).colorScheme.error, + ), + ], + ) + : IconButton( + onPressed: () => _runAction(() async { + final l10n = AppLocalizations.of(context)!; + try { + await ref + .read(friendCallProvider.notifier) + .startCall(friendModel); + } catch (e) { + customSnackbar(l10n.error, e.toString(), LogType.error); + } + }), + icon: Icon(Icons.call), + ), + title: Text( + userIsSender ? friendModel.recieverName : friendModel.senderName, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontSize: 17, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userIsSender + ? friendModel.recieverUsername + : friendModel.senderUsername, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 12, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star, color: Theme.of(context).colorScheme.primary), + Text( + userIsSender + ? (friendModel.recieverRating ?? 0).toStringAsFixed(1) + : (friendModel.senderRating ?? 0).toStringAsFixed(1), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/screens/friends_empty_screen.dart b/lib/features/friends/view/widgets/friends_empty_view.dart similarity index 93% rename from lib/views/screens/friends_empty_screen.dart rename to lib/features/friends/view/widgets/friends_empty_view.dart index 0a56556c..bf54730f 100644 --- a/lib/views/screens/friends_empty_screen.dart +++ b/lib/features/friends/view/widgets/friends_empty_view.dart @@ -8,10 +8,10 @@ import 'package:resonate/utils/constants.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:share_plus/share_plus.dart'; -class FriendsEmptyState extends StatelessWidget { +class FriendsEmptyView extends StatelessWidget { final bool isRequestsScreen; - const FriendsEmptyState({super.key, required this.isRequestsScreen}); + const FriendsEmptyView({super.key, required this.isRequestsScreen}); @override Widget build(BuildContext context) { @@ -64,6 +64,8 @@ class FriendsEmptyState extends StatelessWidget { width: double.infinity, child: ElevatedButton.icon( onPressed: () { + // TabViewController is still GetX; bridge until the + // tab shell migrates. Get.find().setIndex(1); appRouter.go(RoutePaths.tabview); }, diff --git a/lib/views/widgets/pair_chat_dialog.dart b/lib/features/friends/view/widgets/pair_chat_dialog.dart similarity index 52% rename from lib/views/widgets/pair_chat_dialog.dart rename to lib/features/friends/view/widgets/pair_chat_dialog.dart index d4dc8749..d9aec8d0 100644 --- a/lib/views/widgets/pair_chat_dialog.dart +++ b/lib/features/friends/view/widgets/pair_chat_dialog.dart @@ -1,18 +1,53 @@ import 'dart:developer'; + import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:language_picker/language_picker_dropdown.dart'; import 'package:language_picker/languages.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/core/container.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; -Future buildPairChatDialog(BuildContext context) { - final PairChatController controller = Get.find(); +Future showPairChatDialog(BuildContext context) { + return showDialog( + context: context, + useRootNavigator: true, + builder: (_) => const PairChatDialog(), + ); +} + +class PairChatDialog extends ConsumerWidget { + const PairChatDialog({super.key}); + + Future _startFlow( + BuildContext context, { + required Future Function() request, + required String destination, + }) async { + final router = GoRouter.of(context); + final l10n = AppLocalizations.of(context)!; + Navigator.of(context, rootNavigator: true).pop(); + try { + await request(); + router.push(destination); + } catch (e) { + log('Pair chat request failed: $e'); + customSnackbar(l10n.error, e.toString(), LogType.error); + } + } - return Get.dialog( - Dialog( + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAnonymous = + ref.watch(pairChatProvider.select((s) => s.isAnonymous)); + final notifier = ref.read(pairChatProvider.notifier); + + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), backgroundColor: Theme.of(context).colorScheme.surface, elevation: 12, @@ -51,72 +86,69 @@ Future buildPairChatDialog(BuildContext context) { const SizedBox(height: 16), // Anonymous and Authenticated Buttons - Obx( - () => Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () => controller.isAnonymous.value = true, - style: ElevatedButton.styleFrom( - backgroundColor: controller.isAnonymous.value - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - elevation: controller.isAnonymous.value ? 6 : 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 14), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => notifier.setAnonymous(true), + style: ElevatedButton.styleFrom( + backgroundColor: isAnonymous + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + elevation: isAnonymous ? 6 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - child: Text( - AppLocalizations.of(context)!.anonymous, - style: TextStyle( - color: controller.isAnonymous.value - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: UiSizes.size_12, - fontWeight: FontWeight.w600, - overflow: TextOverflow.fade, - ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text( + AppLocalizations.of(context)!.anonymous, + style: TextStyle( + color: isAnonymous + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: UiSizes.size_12, + fontWeight: FontWeight.w600, + overflow: TextOverflow.fade, ), ), ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton( - onPressed: () => controller.isAnonymous.value = false, - style: ElevatedButton.styleFrom( - backgroundColor: !controller.isAnonymous.value - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.surfaceContainerHigh, - elevation: !controller.isAnonymous.value ? 6 : 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 5, - ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: () => notifier.setAnonymous(false), + style: ElevatedButton.styleFrom( + backgroundColor: !isAnonymous + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + elevation: !isAnonymous ? 6 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 5, ), - child: Text( - requireCurrentAuthUser.displayName, - // "asjdwwwwwassdawdhausduuawhdaub", - style: TextStyle( - color: !controller.isAnonymous.value - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: UiSizes.size_12, - fontWeight: FontWeight.w600, - overflow: TextOverflow.ellipsis, - ), + ), + child: Text( + requireCurrentAuthUser.displayName, + style: TextStyle( + color: !isAnonymous + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: UiSizes.size_12, + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, ), ), ), - ], - ), + ), + ], ), const SizedBox(height: 24), @@ -142,14 +174,17 @@ Future buildPairChatDialog(BuildContext context) { ), onValuePicked: (Language language) { log(language.isoCode); - controller.languageIso = language.isoCode; + notifier.setLanguage(language.isoCode); }, ), const SizedBox(height: 28), - // Resonate Button styled like Anonymous button ElevatedButton( - onPressed: controller.quickMatch, + onPressed: () => _startFlow( + context, + request: notifier.quickMatch, + destination: RoutePaths.pairing, + ), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, elevation: 6, @@ -177,7 +212,11 @@ Future buildPairChatDialog(BuildContext context) { ), ElevatedButton( - onPressed: controller.choosePartner, + onPressed: () => _startFlow( + context, + request: notifier.choosePartner, + destination: RoutePaths.pairChatUsers, + ), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, elevation: 6, @@ -201,6 +240,6 @@ Future buildPairChatDialog(BuildContext context) { ], ), ), - ), - ); + ); + } } diff --git a/lib/features/friends/view/widgets/rating_sheet.dart b/lib/features/friends/view/widgets/rating_sheet.dart new file mode 100644 index 00000000..ec82de86 --- /dev/null +++ b/lib/features/friends/view/widgets/rating_sheet.dart @@ -0,0 +1,61 @@ +import 'package:animated_rating_stars/animated_rating_stars.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class RatingSheet extends ConsumerWidget { + const RatingSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pairRating = + ref.watch(pairChatProvider.select((s) => s.pairRating)); + + return Container( + width: double.infinity, + height: UiSizes.height_246, + + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + color: Theme.of(context).colorScheme.surface, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + 'Rate your experience', + style: Theme.of(context).textTheme.headlineMedium, + ), + Text('Rating: ${pairRating.toStringAsFixed(1)}/5.0'), + AnimatedRatingStars( + onChanged: (double rating) { + ref.read(pairChatProvider.notifier).setPairRating(rating); + }, + customFilledIcon: Icons.star, + customHalfFilledIcon: Icons.star_half, + customEmptyIcon: Icons.star_border_outlined, + displayRatingValue: true, + initialRating: 2.5, + minRating: 0.0, + maxRating: 5.0, + interactiveTooltips: true, + filledIcon: Icons.star, + halfFilledIcon: Icons.star_half, + emptyIcon: Icons.star_border_outlined, + ), + ElevatedButton( + onPressed: () async { + final navigator = Navigator.of(context); + await ref.read(pairChatProvider.notifier).submitRating(); + navigator.pop(); + }, + child: const Text('Submit'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/friends/viewmodel/friend_call_notifier.dart b/lib/features/friends/viewmodel/friend_call_notifier.dart new file mode 100644 index 00000000..d36f1f81 --- /dev/null +++ b/lib/features/friends/viewmodel/friend_call_notifier.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/auth/data/callkit_service.dart'; +import 'package:resonate/features/friends/data/friend_call_repository.dart'; +import 'package:resonate/features/friends/model/friend_call_state.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/model/friends_state.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/app_router.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/friend_call_status.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/friend_call_notifier.g.dart'; + +// Call transitions arrive from CallKit callbacks and Realtime events +@Riverpod(keepAlive: true) +class FriendCallNotifier extends _$FriendCallNotifier { + StreamSubscription? _callSub; + + @override + FriendCallState build() { + ref.onDispose(_cancelSub); + return const FriendCallState(); + } + + // Starts a call to the other side and rings them over FCM. + Future startCall(FriendsModel friend) async { + final me = requireCurrentAuthUser; + final amSender = friend.senderId == me.uid; + final recieverFCMToken = + amSender ? friend.recieverFCMToken : friend.senderFCMToken; + if (recieverFCMToken == null) { + throw const FriendsFailure.unknown('Friend has no notification token'); + } + + final repo = ref.read(friendCallRepositoryProvider); + final call = await repo.createCall( + callerName: amSender ? friend.senderName : friend.recieverName, + recieverName: amSender ? friend.recieverName : friend.senderName, + callerUsername: amSender ? friend.senderUsername : friend.recieverUsername, + recieverUsername: amSender ? friend.recieverUsername : friend.senderUsername, + callerUid: amSender ? friend.senderId : friend.recieverId, + recieverUid: amSender ? friend.recieverId : friend.senderId, + callerProfileImageUrl: + amSender ? friend.senderProfileImgUrl : friend.recieverProfileImgUrl, + recieverProfileImageUrl: + amSender ? friend.recieverProfileImgUrl : friend.senderProfileImgUrl, + livekitRoomId: friend.docId, + ); + await repo.sendCallNotification( + call: call, + recieverFCMToken: recieverFCMToken, + ); + + state = FriendCallState(activeCall: call); + _listenToCall(call.docId); + + appRouter.push(RoutePaths.ringingScreen); + } + + // CallKit accept callback receiver side. + Future onAnswerCall(Map extra) async { + final repo = ref.read(friendCallRepositoryProvider); + var call = await repo.getCall(extra['call_id'] as String); + if (call.callStatus == FriendCallStatus.ended) { + // Caller hung up before we answered, clear the lingering CallKit UI. + await ref.read(callKitServiceProvider).endAllCalls(); + return; + } + + call = await repo.setCallStatus(call, FriendCallStatus.connected); + state = state.copyWith( + activeCall: call, + isMicOn: false, + isLoudSpeakerOn: true, + ); + _listenToCall(call.docId); // A leftover subscription could watch a previous call. + + await _joinCall(roomId: call.livekitRoomId, userId: call.recieverUid); + } + + Future onDeclinedCall(Map extra) async { + final repo = ref.read(friendCallRepositoryProvider); + final call = await repo.getCall(extra['call_id'] as String); + final updated = await repo.setCallStatus(call, FriendCallStatus.declined); + state = state.copyWith(activeCall: updated); + } + + Future endCall() async { + final call = state.activeCall; + if (call == null) return; + + final updated = await ref + .read(friendCallRepositoryProvider) + .setCallStatus(call, FriendCallStatus.ended); + state = state.copyWith(activeCall: updated); + + await _teardownCall(); + appRouter.go(RoutePaths.tabview); + } + + Future toggleMic() async { + final next = !state.isMicOn; + state = state.copyWith(isMicOn: next); + try { + await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(next); + } catch (e) { + log('Mic toggle failed: $e'); + } + } + + Future toggleLoudSpeaker() async { + final next = !state.isLoudSpeakerOn; + state = state.copyWith(isLoudSpeakerOn: next); + try { + await ref.read(liveKitProvider.notifier).setSpeakerphoneOn(next); + } catch (e) { + log('Speaker toggle failed: $e'); + } + } + + Future _joinCall({required String roomId, required String userId}) async { + try { + final joinInfo = await ref + .read(friendCallRepositoryProvider) + .callJoinInfo(roomId: roomId, userId: userId); + final connected = await ref.read(liveKitProvider.notifier).connect( + liveKitUri: joinInfo.liveKitUri, + roomToken: joinInfo.roomToken, + ); + if (state.activeCall?.callStatus != FriendCallStatus.connected) { + await ref.read(liveKitProvider.notifier).disconnect(); + return; + } + if (!connected) throw Exception('LiveKit connection failed'); + + appRouter.push(RoutePaths.friendCallScreen); + } catch (e) { + log('Joining call failed: $e'); + _notifyConnectionFailed(); + await _teardownCall(); + appRouter.go(RoutePaths.tabview); + } + } + + void _listenToCall(String callDocId) { + _callSub?.cancel(); + _callSub = ref + .read(friendCallRepositoryProvider) + .callStream(callDocId) + .listen((event) async { + if (!event.events.first.endsWith('.update')) return; + final call = state.activeCall; + if (call == null) return; + + final status = event.payload['callStatus']; + if (status == FriendCallStatus.connected.name && + call.callStatus != FriendCallStatus.connected) { + state = state.copyWith( + activeCall: call.copyWith(callStatus: FriendCallStatus.connected), + ); + await _joinCall(roomId: call.livekitRoomId, userId: call.callerUid); + } else if (status == FriendCallStatus.ended.name && + call.callStatus != FriendCallStatus.ended) { + state = state.copyWith( + activeCall: call.copyWith(callStatus: FriendCallStatus.ended), + ); + await _teardownCall(); + appRouter.go(RoutePaths.tabview); + } else if (status == FriendCallStatus.declined.name && + call.callStatus != FriendCallStatus.declined) { + state = state.copyWith( + activeCall: call.copyWith(callStatus: FriendCallStatus.declined), + ); + _notifyDeclined(call.recieverName); + await _teardownCall(); + appRouter.go(RoutePaths.tabview); + } + }); + } + + void _notifyDeclined(String recieverName) { + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) return; + customSnackbar( + AppLocalizations.of(ctx)!.callDeclined, + AppLocalizations.of(ctx)!.callDeclinedTo(recieverName), + LogType.info, + ); + } + + void _notifyConnectionFailed() { + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) return; + customSnackbar( + AppLocalizations.of(ctx)!.connectionFailed, + AppLocalizations.of(ctx)!.unableToJoinRoom, + LogType.error, + ); + } + + Future _teardownCall() async { + await _cancelSub(); + await ref.read(liveKitProvider.notifier).disconnect(); + try { + await ref.read(callKitServiceProvider).endAllCalls(); + } catch (e) { + log('CallKit endAllCalls failed: $e'); + } + } + + Future _cancelSub() async { + final sub = _callSub; + _callSub = null; + await sub?.cancel(); + } +} diff --git a/lib/features/friends/viewmodel/friends_notifier.dart b/lib/features/friends/viewmodel/friends_notifier.dart new file mode 100644 index 00000000..0409eb39 --- /dev/null +++ b/lib/features/friends/viewmodel/friends_notifier.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/friends/data/friends_repository.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/model/friends_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/friends_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class FriendsNotifier extends _$FriendsNotifier { + StreamSubscription? _friendsSub; + + @override + Future build() async { + ref.onDispose(_cancelSub); + final uid = ref.watch(authProvider).value?.userOrNull?.uid; + if (uid == null) return const FriendsState(); + + final repo = ref.watch(friendsRepositoryProvider); + final lists = await repo.loadFriends(uid); + + await _cancelSub(); + _friendsSub = repo.friendsStream(uid).listen((_) => _refreshQuietly()); + + return FriendsState(friends: lists.friends, friendRequests: lists.requests); + } + + Future sendFriendRequest({ + required String recieverId, + required String recieverProfileImageUrl, + required String recieverUsername, + required String recieverName, + required double recieverRating, + }) async { + final model = await ref.read(friendsRepositoryProvider).sendFriendRequest( + sender: requireCurrentAuthUser, + recieverId: recieverId, + recieverProfileImageUrl: recieverProfileImageUrl, + recieverUsername: recieverUsername, + recieverName: recieverName, + recieverRating: recieverRating, + ); + + final current = state.value; + if (current == null || !ref.mounted) return; + state = AsyncData( + current.copyWith(friendRequests: [...current.friendRequests, model]), + ); + } + + Future acceptFriendRequest(FriendsModel friendModel) async { + final updated = await ref + .read(friendsRepositoryProvider) + .acceptFriendRequest(friendModel); + + final current = state.value; + if (current == null || !ref.mounted) return; + state = AsyncData( + current.copyWith( + friends: [...current.friends, updated], + friendRequests: current.friendRequests + .where((request) => request.docId != friendModel.docId) + .toList(), + ), + ); + } + + Future declineFriendRequest(FriendsModel friendModel) async { + await ref.read(friendsRepositoryProvider).deleteFriendRow(friendModel.docId); + + final current = state.value; + if (current == null || !ref.mounted) return; + state = AsyncData( + current.copyWith( + friendRequests: current.friendRequests + .where((request) => request.docId != friendModel.docId) + .toList(), + ), + ); + } + + Future removeFriend(FriendsModel friendModel) async { + await ref.read(friendsRepositoryProvider).deleteFriendRow(friendModel.docId); + + final current = state.value; + if (current == null || !ref.mounted) return; + state = AsyncData( + current.copyWith( + friends: current.friends + .where((friend) => friend.docId != friendModel.docId) + .toList(), + friendRequests: current.friendRequests + .where((request) => request.docId != friendModel.docId) + .toList(), + ), + ); + } + + // Realtime sync + Future _refreshQuietly() async { + try { + final uid = requireCurrentAuthUser.uid; + final lists = await ref.read(friendsRepositoryProvider).loadFriends(uid); + if (!ref.mounted) return; + state = AsyncData( + FriendsState(friends: lists.friends, friendRequests: lists.requests), + ); + } catch (e) { + log('Friends realtime refresh failed: $e'); + } + } + + // Nulls the field BEFORE the async cancel so concurrent callers (rebuild + // racing an un-awaited dispose) never see a half-cancelled subscription. + Future _cancelSub() async { + final sub = _friendsSub; + _friendsSub = null; + await sub?.cancel(); + } +} diff --git a/lib/features/friends/viewmodel/generated/friend_call_notifier.g.dart b/lib/features/friends/viewmodel/generated/friend_call_notifier.g.dart new file mode 100644 index 00000000..05140e63 --- /dev/null +++ b/lib/features/friends/viewmodel/generated/friend_call_notifier.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../friend_call_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(FriendCallNotifier) +final friendCallProvider = FriendCallNotifierProvider._(); + +final class FriendCallNotifierProvider + extends $NotifierProvider { + FriendCallNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'friendCallProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$friendCallNotifierHash(); + + @$internal + @override + FriendCallNotifier create() => FriendCallNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FriendCallState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$friendCallNotifierHash() => + r'f22fa343c849b2961e04c4e9957c4b594c08be78'; + +abstract class _$FriendCallNotifier extends $Notifier { + FriendCallState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + FriendCallState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/friends/viewmodel/generated/friends_notifier.g.dart b/lib/features/friends/viewmodel/generated/friends_notifier.g.dart new file mode 100644 index 00000000..07386c2b --- /dev/null +++ b/lib/features/friends/viewmodel/generated/friends_notifier.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../friends_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(FriendsNotifier) +final friendsProvider = FriendsNotifierProvider._(); + +final class FriendsNotifierProvider + extends $AsyncNotifierProvider { + FriendsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'friendsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$friendsNotifierHash(); + + @$internal + @override + FriendsNotifier create() => FriendsNotifier(); +} + +String _$friendsNotifierHash() => r'd733b9710d32f97d4ef58eff07f3a6b8d8c1ddd0'; + +abstract class _$FriendsNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, FriendsState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, FriendsState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/friends/viewmodel/generated/pair_chat_notifier.g.dart b/lib/features/friends/viewmodel/generated/pair_chat_notifier.g.dart new file mode 100644 index 00000000..1ad5f125 --- /dev/null +++ b/lib/features/friends/viewmodel/generated/pair_chat_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../pair_chat_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(PairChatNotifier) +final pairChatProvider = PairChatNotifierProvider._(); + +final class PairChatNotifierProvider + extends $NotifierProvider { + PairChatNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pairChatProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pairChatNotifierHash(); + + @$internal + @override + PairChatNotifier create() => PairChatNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PairChatState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pairChatNotifierHash() => r'c214e9bdc605def32a455840ada851ff4b1cda93'; + +abstract class _$PairChatNotifier extends $Notifier { + PairChatState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + PairChatState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/friends/viewmodel/pair_chat_notifier.dart b/lib/features/friends/viewmodel/pair_chat_notifier.dart new file mode 100644 index 00000000..5cd76aba --- /dev/null +++ b/lib/features/friends/viewmodel/pair_chat_notifier.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/friends/data/pair_chat_repository.dart'; +import 'package:resonate/features/friends/model/pair_chat_state.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/models/resonate_user.dart'; +import 'package:resonate/routes/app_router.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/pair_chat_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class PairChatNotifier extends _$PairChatNotifier { + StreamSubscription? _activePairSub; + StreamSubscription? _newUsersSub; + + @override + PairChatState build() { + ref.onDispose(_cancelSubs); + return const PairChatState(); + } + + Future reset() async { + state = const PairChatState(); + await _cancelSubs(); + } + + void setAnonymous(bool isAnonymous) { + state = state.copyWith(isAnonymous: isAnonymous); + } + + void setLanguage(String languageIso) { + state = state.copyWith(languageIso: languageIso); + } + + void setPairRating(double rating) { + state = state.copyWith(pairRating: rating); + } + + Future quickMatch() async { + final me = requireCurrentAuthUser; + _listenForActivePair(); + + final requestDocId = + await ref.read(pairChatRepositoryProvider).createPairRequest( + data: { + 'languageIso': state.languageIso, + 'isAnonymous': state.isAnonymous, + 'uid': me.uid, + 'isRandom': true, + if (!state.isAnonymous) 'userName': me.userName, + }, + ); + state = state.copyWith(requestDocId: requestDocId); + } + + Future choosePartner() async { + final me = requireCurrentAuthUser; + state = state.copyWith(isAnonymous: false); + _listenForNewUsers(); + _listenForActivePair(); + + final requestDocId = + await ref.read(pairChatRepositoryProvider).createPairRequest( + data: { + 'languageIso': state.languageIso, + 'isAnonymous': false, + 'uid': me.uid, + 'isRandom': false, + 'profileImageUrl': me.profileImageUrl, + 'userName': me.userName, + 'name': me.displayName, + 'userRating': + me.ratingCount == 0 ? 0 : me.ratingTotal / me.ratingCount, + }, + ); + state = state.copyWith(requestDocId: requestDocId); + } + + Future convertToRandom() async { + await _newUsersSub?.cancel(); + _newUsersSub = null; + _listenForActivePair(); + await ref + .read(pairChatRepositoryProvider) + .convertRequestToRandom(state.requestDocId!); + } + + Future loadUsers() async { + state = state.copyWith(isUserListLoading: true, onlineUsers: const []); + try { + final users = await ref + .read(pairChatRepositoryProvider) + .listOnlineUsers(excludeUid: requireCurrentAuthUser.uid); + if (!ref.mounted) return; + state = state.copyWith(onlineUsers: users, isUserListLoading: false); + } catch (e) { + log('Loading pair chat users failed: $e'); + if (!ref.mounted) return; + state = state.copyWith(isUserListLoading: false); + } + } + + Future pairWithSelectedUser(ResonateUser user) async { + final me = requireCurrentAuthUser; + await ref.read(pairChatRepositoryProvider).createActivePair( + uid1: me.uid, + uid2: user.uid!, + userName1: me.userName, + userName2: user.userName, + requestDocId1: state.requestDocId, + requestDocId2: user.docId, + ); + // Both sides join through their own active-pair Realtime listener. + } + + // Claims the request synchronously so a dispose-time call can never delete + // a newer flow's request. + Future cancelRequest() async { + final requestDocId = state.requestDocId; + state = state.copyWith(requestDocId: null); + await _cancelSubs(); + if (requestDocId != null) { + try { + await ref + .read(pairChatRepositoryProvider) + .deletePairRequest(requestDocId); + } catch (e) { + log('Cancel pair request failed: $e'); + } + } + } + + Future toggleMic() async { + final next = !state.isMicOn; + state = state.copyWith(isMicOn: next); + try { + await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(next); + } catch (e) { + log('Mic toggle failed: $e'); + } + } + + Future toggleLoudSpeaker() async { + final next = !state.isLoudSpeakerOn; + state = state.copyWith(isLoudSpeakerOn: next); + try { + await ref.read(liveKitProvider.notifier).setSpeakerphoneOn(next); + } catch (e) { + log('Speaker toggle failed: $e'); + } + } + + Future endChat() async { + if (state.ended) return; + state = state.copyWith(ended: true); + + await _cancelSubs(); + final activePairDocId = state.activePairDocId; + if (activePairDocId != null) { + try { + await ref + .read(pairChatRepositoryProvider) + .deleteActivePair(activePairDocId); + } catch (e) { + log('Deleting active pair failed: $e'); + } + } + await ref.read(liveKitProvider.notifier).disconnect(); + } + + Future submitRating() async { + final me = requireCurrentAuthUser; + await ref.read(pairChatRepositoryProvider).updateUserRating( + uid: me.uid, + ratingTotal: me.ratingTotal + state.pairRating, + ratingCount: me.ratingCount + 1, + ); + await ref.read(authProvider.notifier).refresh(); + } + + void _listenForActivePair() { + if (_activePairSub != null) return; + final uid = requireCurrentAuthUser.uid; + final channel = PairChatRepository.activePairsChannel(); + + _activePairSub = ref + .read(pairChatRepositoryProvider) + .activePairsStream() + .listen((event) async { + final uid1 = event.payload['uid1'] as String?; + final uid2 = event.payload['uid2'] as String?; + if (uid1 != uid && uid2 != uid) return; + + final docId = event.payload['\$id'].toString(); + final action = event.events.first.substring( + channel.length + 1 + docId.length + 1, + ); + switch (action) { + case 'create': + await _onPaired(event.payload, amUser1: uid1 == uid); + break; + case 'delete': + await endChat(); + break; + } + }); + } + + Future _onPaired( + Map payload, { + required bool amUser1, + }) async { + final repo = ref.read(pairChatRepositoryProvider); + final partnerUid = + (amUser1 ? payload['uid2'] : payload['uid1']) as String; + final pairUsername = + (amUser1 ? payload['userName2'] : payload['userName1']) as String?; + final pairProfileImageUrl = await repo.getUserProfileImageUrl(partnerUid); + // endChat() always cancels the subs synchronously, so a null sub after + // any await means the partner already ended this pair — bail out before + // joining (otherwise the user lands on a dead PairChatPage). + if (_activePairSub == null) return; + + final activePairDocId = payload['\$id'] as String; + state = state.copyWith( + activePairDocId: activePairDocId, + pairUsername: pairUsername, + pairProfileImageUrl: pairProfileImageUrl, + isMicOn: false, + isLoudSpeakerOn: true, + ended: false, + ); + + try { + final joinInfo = await repo.pairJoinInfo( + roomId: activePairDocId, + userId: requireCurrentAuthUser.uid, + ); + if (_activePairSub == null || state.ended) return; + + final connected = await ref.read(liveKitProvider.notifier).connect( + liveKitUri: joinInfo.liveKitUri, + roomToken: joinInfo.roomToken, + ); + if (_activePairSub == null || state.ended) { + await ref.read(liveKitProvider.notifier).disconnect(); + return; + } + if (!connected) throw Exception('LiveKit connection failed'); + + appRouter.push(RoutePaths.pairChat); + } catch (e) { + log('Joining pair chat failed: $e'); + _notifyConnectionFailed(); + await endChat(); + appRouter.go(RoutePaths.tabview); + } + } + + void _notifyConnectionFailed() { + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) return; + customSnackbar( + AppLocalizations.of(ctx)!.connectionFailed, + AppLocalizations.of(ctx)!.unableToJoinRoom, + LogType.error, + ); + } + + void _listenForNewUsers() { + if (_newUsersSub != null) return; + final uid = requireCurrentAuthUser.uid; + + _newUsersSub = ref + .read(pairChatRepositoryProvider) + .pairRequestsStream() + .listen((event) { + final eventName = event.events.first; + if (eventName.endsWith('.create')) { + // Skip our own request and anonymous ones + if (event.payload['uid'] == uid || + event.payload['isAnonymous'] == true) { + return; + } + try { + final eventSplit = eventName.split('.'); + final docId = eventSplit[eventSplit.length - 2]; + final newUser = + ResonateUser.fromJson({...event.payload, 'docId': docId}); + state = state.copyWith(onlineUsers: [...state.onlineUsers, newUser]); + } catch (e) { + log('Skipping malformed pair request payload: $e'); + } + } else if (eventName.endsWith('.delete')) { + final removedUid = event.payload['uid']; + state = state.copyWith( + onlineUsers: state.onlineUsers + .where((user) => user.uid != removedUid) + .toList(), + ); + } + }); + } + + // Nulls the fields BEFORE the async cancels so the _listenFor* guards and + // _onPaired's bail-out checks stay correct even when a cancel is racing an + // un-awaited reset()/cancelRequest(). + Future _cancelSubs() async { + final activePairSub = _activePairSub; + final newUsersSub = _newUsersSub; + _activePairSub = null; + _newUsersSub = null; + await activePairSub?.cancel(); + await newUsersSub?.cancel(); + } +} diff --git a/lib/features/profile/view/pages/profile_page.dart b/lib/features/profile/view/pages/profile_page.dart index 4d678a04..994c04a0 100644 --- a/lib/features/profile/view/pages/profile_page.dart +++ b/lib/features/profile/view/pages/profile_page.dart @@ -6,14 +6,16 @@ import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:loading_indicator/loading_indicator.dart'; import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/controllers/friends_controller.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/view/pages/friend_requests_page.dart'; +import 'package:resonate/features/friends/view/pages/friends_page.dart'; +import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; import 'package:resonate/features/profile/model/profile_view_data.dart'; import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/friends_model.dart'; import 'package:resonate/models/resonate_user.dart'; import 'package:resonate/models/story.dart'; import 'package:resonate/routes/route_paths.dart'; @@ -23,8 +25,6 @@ import 'package:resonate/utils/enums/friend_request_status.dart'; import 'package:resonate/utils/enums/log_type.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/views/screens/followers_screen.dart'; -import 'package:resonate/views/screens/friend_requests_screen.dart'; -import 'package:resonate/views/screens/friends_screen.dart'; import 'package:resonate/views/screens/story_screen.dart'; import 'package:resonate/views/widgets/loading_dialog.dart'; import 'package:resonate/views/widgets/snackbar.dart'; @@ -46,7 +46,6 @@ class ProfilePage extends ConsumerStatefulWidget { class _ProfilePageState extends ConsumerState { final themeController = Get.find(); final exploreStoryController = Get.find(); - final friendsController = Get.find(); bool get _isCreator => widget.isCreatorProfile == true; String get _creatorId => widget.creator!.uid!; @@ -68,7 +67,7 @@ class _ProfilePageState extends ConsumerState { onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => FriendRequestsScreen(), + builder: (_) => const FriendRequestsPage(), ), ); }, @@ -77,7 +76,9 @@ class _ProfilePageState extends ConsumerState { IconButton( onPressed: () { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => FriendsScreen()), + MaterialPageRoute( + builder: (_) => const FriendsPage(), + ), ); }, icon: const Icon(Icons.groups), @@ -85,9 +86,9 @@ class _ProfilePageState extends ConsumerState { ] : null, ), - body: Obx(() { + body: Builder(builder: (context) { final loading = (profileAsync?.isLoading ?? false) || - friendsController.isLoadingFriends.value; + ref.watch(friendsProvider).isLoading; if (loading || authUser == null) { return Center( child: SizedBox( @@ -357,94 +358,86 @@ class _ProfilePageState extends ConsumerState { Widget _buildFriendButton(BuildContext context, ColorScheme colorScheme) { final l10n = AppLocalizations.of(context)!; - return Obx(() { - final FriendsModel? friendModel = friendsController.friendsList - .firstWhereOrNull( - (friend) => - friend.senderId == widget.creator!.uid || - friend.recieverId == widget.creator!.uid, - ) ?? - friendsController.friendRequestsList.firstWhereOrNull( - (friend) => - friend.senderId == widget.creator!.uid || - friend.recieverId == widget.creator!.uid, + final friendsState = ref.watch(friendsProvider).value; + final FriendsModel? friendModel = + friendsState?.relationWith(widget.creator!.uid!); + final friendsNotifier = ref.read(friendsProvider.notifier); + + return ElevatedButton( + onPressed: () async { + if (friendModel == null) { + await friendsNotifier.sendFriendRequest( + recieverId: widget.creator!.uid!, + recieverProfileImageUrl: widget.creator!.profileImageUrl!, + recieverUsername: widget.creator!.userName!, + recieverName: widget.creator!.name!, + recieverRating: widget.creator!.userRating!, ); - return ElevatedButton( - onPressed: () async { - if (friendModel == null) { - await friendsController.sendFriendRequest( - widget.creator!.uid!, - widget.creator!.profileImageUrl!, - widget.creator!.userName!, - widget.creator!.name!, - widget.creator!.userRating!, - ); + customSnackbar( + l10n.friendRequestSent, + l10n.friendRequestSentTo(widget.creator!.name!), + LogType.success, + ); + } else { + if (friendModel.requestStatus == FriendRequestStatus.sent && + friendModel.senderId == widget.creator!.uid) { + await friendsNotifier.acceptFriendRequest(friendModel); customSnackbar( - l10n.friendRequestSent, - l10n.friendRequestSentTo(widget.creator!.name!), + l10n.friendRequestAccepted, + l10n.friendRequestAcceptedTo(widget.creator!.name!), LogType.success, ); } else { - if (friendModel.requestStatus == FriendRequestStatus.sent && - friendModel.senderId == widget.creator!.uid) { - await friendsController.acceptFriendRequest(friendModel); - customSnackbar( - l10n.friendRequestAccepted, - l10n.friendRequestAcceptedTo(widget.creator!.name!), - LogType.success, - ); - } else { - try { - await friendsController.removeFriend(friendModel); - } catch (e) { - log(e.toString()); - } - customSnackbar( - l10n.friendRequestCancelled, - l10n.friendRequestCancelledTo(widget.creator!.name!), - LogType.info, - ); + try { + await friendsNotifier.removeFriend(friendModel); + } catch (e) { + log(e.toString()); } + customSnackbar( + l10n.friendRequestCancelled, + l10n.friendRequestCancelledTo(widget.creator!.name!), + LogType.info, + ); } - }, - style: ElevatedButton.styleFrom( - backgroundColor: friendModel != null - ? (friendModel.requestStatus == FriendRequestStatus.sent - ? colorScheme.primary - : colorScheme.secondary) - : colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - side: BorderSide(color: colorScheme.primary), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - friendModel != null - ? (friendModel.requestStatus == FriendRequestStatus.sent - ? Icons.check - : Icons.people) - : Icons.add, - color: colorScheme.onPrimary, - ), - const SizedBox(width: 8), - Text( - friendModel != null - ? (friendModel.requestStatus == FriendRequestStatus.sent - ? friendModel.senderId == widget.creator!.uid - ? l10n.accept - : l10n.requested - : l10n.friends) - : l10n.addFriend, - style: TextStyle(color: colorScheme.onPrimary), - ), - ], + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? colorScheme.primary + : colorScheme.secondary) + : colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + side: BorderSide(color: colorScheme.primary), ), - ); - }); + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? Icons.check + : Icons.people) + : Icons.add, + color: colorScheme.onPrimary, + ), + const SizedBox(width: 8), + Text( + friendModel != null + ? (friendModel.requestStatus == FriendRequestStatus.sent + ? friendModel.senderId == widget.creator!.uid + ? l10n.accept + : l10n.requested + : l10n.friends) + : l10n.addFriend, + style: TextStyle(color: colorScheme.onPrimary), + ), + ], + ), + ); } Widget _buildStoriesSection( diff --git a/lib/features/rooms/data/livekit_join.dart b/lib/features/rooms/data/livekit_join.dart new file mode 100644 index 00000000..59c2b72c --- /dev/null +++ b/lib/features/rooms/data/livekit_join.dart @@ -0,0 +1,15 @@ +import 'package:resonate/utils/constants.dart'; + +// Converts the response from the createPairChat API into the parameters needed to join a LiveKit room. +// Temporary, when the full livekit cluster is migrated, it'll be added to core +({String liveKitUri, String roomToken}) liveKitJoinFromResponse( + Map response, +) { + final socketUrl = response['livekit_socket_url'] as String; + return ( + liveKitUri: socketUrl == 'wss://host.docker.internal:7880' + ? localhostLivekitEndpoint + : socketUrl, + roomToken: response['access_token'] as String, + ); +} diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/rooms_repository.dart index 5a045e7d..ba7000a3 100644 --- a/lib/features/rooms/data/rooms_repository.dart +++ b/lib/features/rooms/data/rooms_repository.dart @@ -4,6 +4,7 @@ import 'package:appwrite/appwrite.dart'; import 'package:appwrite/models.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/rooms/data/livekit_join.dart'; import 'package:resonate/features/rooms/model/appwrite_room.dart'; import 'package:resonate/features/rooms/model/participant.dart'; import 'package:resonate/features/rooms/model/room_failure.dart'; @@ -120,14 +121,16 @@ class RoomsRepository { try { final response = await _api.createRoom(name, description, adminUid, tags); final roomId = response['livekit_room']['name'] as String; - final roomToken = response['access_token'] as String; - final socketUrl = response['livekit_socket_url'] as String; - final liveKitUri = socketUrl == 'wss://host.docker.internal:7880' - ? localhostLivekitEndpoint - : socketUrl; + final join = liveKitJoinFromResponse(response); - await _secureStorage.write(key: 'createdRoomAdminToken', value: roomToken); - await _secureStorage.write(key: 'createdRoomLivekitUrl', value: liveKitUri); + await _secureStorage.write( + key: 'createdRoomAdminToken', + value: join.roomToken, + ); + await _secureStorage.write( + key: 'createdRoomLivekitUrl', + value: join.liveKitUri, + ); final myDocId = await _addParticipant( roomId: roomId, @@ -138,8 +141,8 @@ class RoomsRepository { return ( roomId: roomId, myDocId: myDocId, - liveKitUri: liveKitUri, - roomToken: roomToken, + liveKitUri: join.liveKitUri, + roomToken: join.roomToken, ); } on AppwriteException catch (e) { throw _mapException(e); @@ -153,11 +156,7 @@ class RoomsRepository { }) async { try { final response = await _api.joinRoom(roomId, userId); - final roomToken = response['access_token'] as String; - final socketUrl = response['livekit_socket_url'] as String; - final liveKitUri = socketUrl == 'wss://host.docker.internal:7880' - ? localhostLivekitEndpoint - : socketUrl; + final join = liveKitJoinFromResponse(response); final myDocId = await _addParticipant( roomId: roomId, @@ -165,7 +164,11 @@ class RoomsRepository { isAdmin: isAdmin, ); - return (myDocId: myDocId, liveKitUri: liveKitUri, roomToken: roomToken); + return ( + myDocId: myDocId, + liveKitUri: join.liveKitUri, + roomToken: join.roomToken, + ); } on AppwriteException catch (e) { throw _mapException(e); } diff --git a/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart b/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart index 33d91bdc..87f84cb1 100644 --- a/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart +++ b/lib/features/rooms/viewmodel/generated/livekit_notifier.g.dart @@ -41,7 +41,7 @@ final class LiveKitNotifierProvider } } -String _$liveKitNotifierHash() => r'1c9f3d5ae76f6d3f489907a4ae669ef405f5247e'; +String _$liveKitNotifierHash() => r'0c8b830088adc5d0ff525844c58276eac300f897'; abstract class _$LiveKitNotifier extends $Notifier { LiveKitState build(); diff --git a/lib/features/rooms/viewmodel/livekit_notifier.dart b/lib/features/rooms/viewmodel/livekit_notifier.dart index 81657bdf..f095ae1a 100644 --- a/lib/features/rooms/viewmodel/livekit_notifier.dart +++ b/lib/features/rooms/viewmodel/livekit_notifier.dart @@ -57,6 +57,9 @@ class LiveKitNotifier extends _$LiveKitNotifier { Future setMicrophoneEnabled(bool enabled) => _session?.setMicrophoneEnabled(enabled) ?? Future.value(); + Future setSpeakerphoneOn(bool enabled) => + Hardware.instance.setSpeakerphoneOn(enabled); + Future setRecording(bool recording) async { await _session?.setRecording(recording); state = state.copyWith(isRecording: recording); diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index 7be61bff..f9fe9f47 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -6,6 +6,7 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/auth_routes.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/friends/friends_routes.dart'; import 'package:resonate/features/profile/profile_routes.dart'; import 'package:resonate/features/rooms/rooms_routes.dart'; import 'package:resonate/routes/route_paths.dart'; @@ -19,14 +20,9 @@ import 'package:resonate/views/screens/home_screen.dart'; import 'package:resonate/views/screens/live_chapter_screen.dart'; import 'package:resonate/views/screens/notifications_screen.dart'; import 'package:resonate/views/screens/verify_chapter_details_screen.dart'; -import 'package:resonate/views/screens/pair_chat_screen.dart'; -import 'package:resonate/views/screens/pair_chat_users_screen.dart'; -import 'package:resonate/views/screens/pairing_screen.dart'; -import 'package:resonate/views/screens/ringing_screen.dart'; import 'package:resonate/views/screens/settings_screen.dart'; import 'package:resonate/views/screens/tabview_screen.dart'; import 'package:resonate/views/screens/user_account_screen.dart'; -import 'package:resonate/controllers/friend_call_screen.dart'; // Global navigator key final GlobalKey rootNavigatorKey = @@ -88,26 +84,7 @@ final routerProvider = Provider((ref) { ), // Pair chat / friend calls - GoRoute( - path: RoutePaths.pairing, - builder: (_, _) => PairingScreen(), - ), - GoRoute( - path: RoutePaths.pairChatUsers, - builder: (_, _) => PairChatUsersScreen(), - ), - GoRoute( - path: RoutePaths.pairChat, - builder: (_, _) => PairChatScreen(), - ), - GoRoute( - path: RoutePaths.ringingScreen, - builder: (_, _) => RingingScreen(), - ), - GoRoute( - path: RoutePaths.friendCallScreen, - builder: (_, _) => FriendCallScreen(), - ), + ...friendsRoutes, // Stories GoRoute( diff --git a/lib/services/room_service.dart b/lib/services/room_service.dart index 0e0a2941..af0912cc 100644 --- a/lib/services/room_service.dart +++ b/lib/services/room_service.dart @@ -67,18 +67,4 @@ class RoomService { String? livekitToken = await storage.read(key: "createdRoomAdminToken"); await apiService.deleteLiveChapterRoom(roomId, livekitToken!); } - - static Future joinLivekitPairChat({ - required roomId, - required String userId, - }) async { - var response = await apiService.joinRoom(roomId, userId); - String livekitToken = response["access_token"]; - String livekitSocketUrl = - response["livekit_socket_url"] == "wss://host.docker.internal:7880" - ? localhostLivekitEndpoint - : response["livekit_socket_url"]; - - await joinLiveKitRoom(livekitSocketUrl, livekitToken); - } } diff --git a/lib/views/screens/chapter_play_screen.dart b/lib/views/screens/chapter_play_screen.dart index 02868ebe..fe5fe4b1 100644 --- a/lib/views/screens/chapter_play_screen.dart +++ b/lib/views/screens/chapter_play_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:flutter_lyric/lyrics_reader.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; import 'package:get/get.dart'; import 'package:resonate/controllers/chapter_player_controller.dart'; import 'package:resonate/models/chapter.dart'; @@ -17,28 +17,31 @@ class ChapterPlayScreen extends StatefulWidget { } class _ChapterPlayScreenState extends State { - late UINetease lyricUI; + late LyricStyle lyricStyle; final ChapterPlayerController controller = Get.find(); @override void didChangeDependencies() { super.didChangeDependencies(); bool themeIsDark = Theme.of(context).brightness == Brightness.dark; - lyricUI = UINetease( - highlightColor: themeIsDark ? Colors.white : Colors.black, - playingMainTextStyle: TextStyle( + // Mirrors the old UINetease setup: the playing line is bold and + // theme-contrasted, other lines stay grey. + lyricStyle = LyricStyles.default1.copyWith( + activeStyle: TextStyle( fontSize: UiSizes.size_20, fontWeight: FontWeight.bold, - color: themeIsDark - ? const Color.fromARGB(255, 223, 222, 222) - : Colors.grey[600], + color: themeIsDark ? Colors.white : Colors.black, ), - otherMainTextStyle: TextStyle( + textStyle: TextStyle( fontSize: UiSizes.size_18, color: themeIsDark ? const Color.fromARGB(255, 223, 222, 222) : Colors.grey[600], ), + selectedColor: themeIsDark ? Colors.white : Colors.black, + activeHighlightColor: themeIsDark ? Colors.white : Colors.black, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + fadeRange: FadeRange(top: 0, bottom: 0), ); } @@ -48,11 +51,13 @@ class _ChapterPlayScreenState extends State { controller.initialize( AudioPlayer()..setSourceUrl(widget.chapter.audioFileUrl), - LyricsModelBuilder.create() - .bindLyricToMain(widget.chapter.lyrics) - .getModel(), + widget.chapter.lyrics, Duration(milliseconds: widget.chapter.playDuration), ); + // Tapping a lyric line seeks to it (replaces the old select-line flow). + controller.lyricController.setOnTapLineCallback((start) { + controller.audioPlayer?.seek(start); + }); } @override @@ -111,56 +116,19 @@ class _ChapterPlayScreenState extends State { : const Color.fromARGB(193, 232, 230, 230), borderRadius: BorderRadius.circular(10), ), - child: Obx( - () => LyricsReader( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - model: controller.lyricModel, - position: controller.lyricProgress.value, - lyricUi: lyricUI, - playing: controller.isPlaying.value, - size: const Size(double.infinity, 200), - emptyBuilder: () => Center( - child: Text( - AppLocalizations.of(context)!.noLyrics, - style: UINetease().getOtherMainTextStyle(), + child: widget.chapter.lyrics.trim().isEmpty + ? Center( + child: Text( + AppLocalizations.of(context)!.noLyrics, + style: lyricStyle.textStyle, + ), + ) + : LyricView( + controller: controller.lyricController, + style: lyricStyle, + width: double.infinity, + height: 200, ), - ), - selectLineBuilder: (progress, confirm) { - return Row( - children: [ - IconButton( - onPressed: () { - confirm.call(); - - controller.audioPlayer?.seek( - Duration(milliseconds: progress), - ); - }, - icon: Icon( - Icons.play_arrow, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary, - ), - height: 1, - width: double.infinity, - ), - ), - ], - ); - }, - ), - ), ), ), diff --git a/lib/views/screens/friend_requests_screen.dart b/lib/views/screens/friend_requests_screen.dart deleted file mode 100644 index 092c0c88..00000000 --- a/lib/views/screens/friend_requests_screen.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/friends_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/views/widgets/friend_request_list_tile.dart'; -import 'package:resonate/views/screens/friends_empty_screen.dart'; - -class FriendRequestsScreen extends StatelessWidget { - const FriendRequestsScreen({super.key}); - - @override - Widget build(BuildContext context) { - final friendsController = Get.find(); - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.friendRequests)), - body: Obx(() { - final availableFriendRequests = friendsController.friendRequestsList - .where( - (friend) => - friend.requestSentByUserId != - friendsController.authStateController.uid, - ) - .toList(); - if (availableFriendRequests.isEmpty) { - return const FriendsEmptyState(isRequestsScreen: true); - } - return ListView.builder( - itemCount: availableFriendRequests.length, - shrinkWrap: true, - itemBuilder: (context, index) { - final friendModel = availableFriendRequests[index]; - return FriendsListTile(friendModel: friendModel, isRequest: true); - }, - ); - }), - ); - } -} diff --git a/lib/views/screens/friends_screen.dart b/lib/views/screens/friends_screen.dart deleted file mode 100644 index 1b8dd15c..00000000 --- a/lib/views/screens/friends_screen.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/friends_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/views/widgets/friend_request_list_tile.dart'; -import 'package:resonate/views/screens/friends_empty_screen.dart'; - -class FriendsScreen extends StatelessWidget { - const FriendsScreen({super.key}); - - @override - Widget build(BuildContext context) { - final friendsController = Get.find(); - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.friends)), - body: Obx(() { - final availableFriends = friendsController.friendsList; - if (availableFriends.isEmpty) { - return const FriendsEmptyState(isRequestsScreen: false); - } - return ListView.builder( - itemCount: availableFriends.length, - shrinkWrap: true, - itemBuilder: (context, index) { - final friendModel = availableFriends[index]; - return FriendsListTile(friendModel: friendModel, isRequest: false); - }, - ); - }), - ); - } -} diff --git a/lib/views/screens/pair_chat_screen.dart b/lib/views/screens/pair_chat_screen.dart deleted file mode 100644 index 068e2674..00000000 --- a/lib/views/screens/pair_chat_screen.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/features/rooms/view/widgets/room_app_bar.dart'; -import 'package:resonate/features/rooms/view/widgets/room_header.dart'; - -import 'package:resonate/l10n/app_localizations.dart'; -import '../../controllers/pair_chat_controller.dart'; - -class PairChatScreen extends StatelessWidget { - final PairChatController controller = Get.find(); - final ThemeController themeController = Get.find(); - - PairChatScreen({super.key}); - - @override - Widget build(BuildContext context) { - final currentBrightness = Theme.of(context).brightness; - - return PopScope( - canPop: false, - child: Scaffold( - body: SafeArea( - child: Column( - children: [ - const RoomAppBar(), - Padding( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_10, - horizontal: UiSizes.width_20, - ), - child: Column( - children: [ - RoomHeader( - roomName: AppLocalizations.of(context)!.title, - roomDescription: AppLocalizations.of( - context, - )!.roomDescription, - ), - SizedBox(height: UiSizes.height_24_6), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildUserInfoRow( - controller.isAnonymous.value - ? themeController.userProfileImagePlaceholderUrl - : requireCurrentAuthUser.profileImageUrl ?? '', - controller.isAnonymous.value - ? AppLocalizations.of(context)!.user1 - : requireCurrentAuthUser.userName ?? '', - ), - SizedBox(height: UiSizes.height_20), - _buildUserInfoRow( - controller.isAnonymous.value - ? themeController.userProfileImagePlaceholderUrl - : controller.pairProfileImageUrl!, - controller.isAnonymous.value - ? AppLocalizations.of(context)!.user2 - : controller.pairUsername!, - ), - ], - ), - SizedBox(height: UiSizes.height_24_6), - ], - ), - ), - const Spacer(), - _buildBottomControlPanel(currentBrightness, context), - ], - ), - ), - ), - ); - } - - Widget _buildUserInfoRow(String imageUrl, String userName) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - backgroundImage: NetworkImage(imageUrl), - radius: UiSizes.width_66, - ), - SizedBox(width: UiSizes.width_16), - Container( - alignment: Alignment.center, - width: UiSizes.width_100, - child: Text( - userName, - style: TextStyle(fontSize: UiSizes.size_16), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - - Widget _buildBottomControlPanel( - Brightness currentBrightness, - BuildContext context, - ) { - return Container( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), - color: currentBrightness == Brightness.light - ? Theme.of(context).colorScheme.primary - : Colors.black, - height: UiSizes.height_131, - child: Obx(() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildControlButton( - icon: controller.isMicOn.value ? Icons.mic : Icons.mic_off, - label: AppLocalizations.of(context)!.mute, - onPressed: controller.toggleMic, - backgroundColor: controller.isMicOn.value - ? _getControlButtonBackgroundColor(currentBrightness) - : Theme.of(context).colorScheme.primary, - heroTag: "mic", - ), - _buildControlButton( - icon: Icons.volume_up, - label: AppLocalizations.of(context)!.speakerLabel, - onPressed: controller.toggleLoudSpeaker, - backgroundColor: controller.isLoudSpeakerOn.value - ? Theme.of(context).colorScheme.primary - : _getControlButtonBackgroundColor(currentBrightness), - heroTag: "speaker", - ), - _buildControlButton( - icon: Icons.cancel_outlined, - label: AppLocalizations.of(context)!.end, - onPressed: () async { - await controller.endChat(); - }, - backgroundColor: Colors.redAccent, - heroTag: "end-chat", - ), - ], - ); - }), - ); - } - - Color _getControlButtonBackgroundColor(Brightness brightness) { - return brightness == Brightness.light - ? Colors.white.withValues(alpha: 0.5) - : Colors.white54; - } - - Widget _buildControlButton({ - required IconData icon, - required String label, - required VoidCallback onPressed, - required Color backgroundColor, - required String heroTag, - }) { - return Column( - children: [ - SizedBox( - height: UiSizes.height_56, - width: UiSizes.width_56, - child: FloatingActionButton( - elevation: 0, - heroTag: heroTag, - onPressed: onPressed, - backgroundColor: backgroundColor, - child: Icon(icon, size: UiSizes.size_24), - ), - ), - SizedBox(height: UiSizes.height_4), - Text(label, style: TextStyle(fontSize: UiSizes.height_14)), - ], - ); - } -} diff --git a/lib/views/screens/pair_chat_users_screen.dart b/lib/views/screens/pair_chat_users_screen.dart deleted file mode 100644 index fe126a89..00000000 --- a/lib/views/screens/pair_chat_users_screen.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class PairChatUsersScreen extends StatefulWidget { - const PairChatUsersScreen({super.key}); - - @override - State createState() => _PairChatUsersScreenState(); -} - -class _PairChatUsersScreenState extends State { - final PairChatController pairChatController = Get.find(); - - @override - void initState() { - super.initState(); - pairChatController.loadUsers(); - } - - @override - void dispose() { - pairChatController.cancelRequest(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.onlineUsers), - actions: [ - IconButton( - onPressed: () async { - await pairChatController.convertToRandom(); - }, - icon: Icon(Icons.casino_outlined), - ), - ], - actionsPadding: EdgeInsets.only(right: 16.0), - ), - body: Obx( - () => pairChatController.isUserListLoading.value - ? Center(child: CircularProgressIndicator()) - : pairChatController.usersList.isEmpty - ? Center(child: Text(AppLocalizations.of(context)!.noOnlineUsers)) - : ListView.builder( - itemCount: pairChatController.usersList.length, - shrinkWrap: true, - itemBuilder: (context, index) { - return ListTile( - onTap: () async { - await pairChatController.pairWithSelectedUser( - pairChatController.usersList[index], - ); - }, - title: Text(pairChatController.usersList[index].userName!), - subtitle: Text(pairChatController.usersList[index].name!), - - leading: CircleAvatar( - backgroundImage: NetworkImage( - pairChatController.usersList[index].profileImageUrl!, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.star, color: Colors.amber), - Text( - pairChatController.usersList[index].userRating! - .toStringAsFixed(1), - ), - ], - ), - // ...other fields - ); - }, - ), - ), - ); - } -} diff --git a/lib/views/screens/pairing_screen.dart b/lib/views/screens/pairing_screen.dart deleted file mode 100644 index b549ba2f..00000000 --- a/lib/views/screens/pairing_screen.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class PairingScreen extends StatelessWidget { - final PairChatController controller = Get.find(); - final ThemeController themeController = Get.find(); - - PairingScreen({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final primaryColor = theme.colorScheme.primary; - final onPrimaryColor = theme.colorScheme.onPrimary; - final profileImageUrl = controller.isAnonymous.value - ? themeController.userProfileImagePlaceholderUrl - : requireCurrentAuthUser.profileImageUrl ?? ''; - - return Scaffold( - body: SafeArea( - child: Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), - child: Column( - children: [ - _buildTitle(primaryColor, context), - SizedBox(height: UiSizes.height_5), - _buildSubtitle(context), - const Spacer(), - _buildLoadingIndicator(context, primaryColor, profileImageUrl), - const Spacer(), - _buildFooter(primaryColor, onPrimaryColor, context), - ], - ), - ), - ), - ); - } - - Widget _buildTitle(Color primaryColor, BuildContext context) { - return Text( - AppLocalizations.of(context)!.findingRandomPartner, - style: TextStyle(color: primaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 6.5), - ); - } - - Widget _buildSubtitle(BuildContext context) { - return Text( - AppLocalizations.of(context)!.hangOnGoodThingsTakeTime, - style: TextStyle(fontSize: UiSizes.size_14, color: Colors.grey.shade500), - ); - } - - Widget _buildLoadingIndicator( - BuildContext context, - Color primaryColor, - String profileImageUrl, - ) { - return Stack( - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: UiSizes.width_20, - vertical: UiSizes.height_20, - ), - child: LoadingIndicator( - indicatorType: Indicator.ballScaleMultiple, - colors: [ - primaryColor.withValues(alpha: 0.2), - primaryColor, - primaryColor.withValues(alpha: 0.6), - ], - strokeWidth: 2, - ), - ), - Positioned.fill( - child: Align( - alignment: Alignment.center, - child: CircleAvatar( - radius: Get.size.height * 0.05, - backgroundImage: NetworkImage(profileImageUrl), - ), - ), - ), - ], - ); - } - - Widget _buildFooter( - Color primaryColor, - Color onPrimaryColor, - BuildContext context, - ) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), - child: Column( - children: [ - _buildQuickFact(primaryColor, context), - SizedBox(height: UiSizes.height_15), - _buildCancelButton(primaryColor, onPrimaryColor, context), - ], - ), - ); - } - - Widget _buildQuickFact(Color primaryColor, BuildContext context) { - return Column( - children: [ - Text( - AppLocalizations.of(context)!.quickFact, - style: TextStyle(color: primaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 6.5), - ), - Text( - AppLocalizations.of(context)!.resonateOpenSourceProject, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: UiSizes.size_14, - color: Colors.grey.shade500, - ), - ), - ], - ); - } - - Widget _buildCancelButton( - Color primaryColor, - Color onPrimaryColor, - BuildContext context, - ) { - return ElevatedButton( - onPressed: () async { - await controller.cancelRequest(); - }, - style: ElevatedButton.styleFrom(backgroundColor: primaryColor), - child: Text( - AppLocalizations.of(context)!.cancel, - style: TextStyle(color: onPrimaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 8), - ), - ); - } -} diff --git a/lib/views/screens/ringing_screen.dart b/lib/views/screens/ringing_screen.dart deleted file mode 100644 index dd7aa009..00000000 --- a/lib/views/screens/ringing_screen.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/friend_calling_controller.dart'; -import 'package:resonate/themes/theme_controller.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class RingingScreen extends StatelessWidget { - final FriendCallingController controller = - Get.find(); - final ThemeController themeController = Get.find(); - - RingingScreen({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final primaryColor = theme.colorScheme.primary; - final onPrimaryColor = theme.colorScheme.onPrimary; - final profileImageUrl = - controller.friendCallModel.value!.recieverProfileImageUrl; - - return Scaffold( - body: SafeArea( - child: Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_20), - child: Column( - children: [ - _buildTitle(primaryColor, context), - SizedBox(height: UiSizes.height_5), - _buildSubtitle(context), - const Spacer(), - _buildLoadingIndicator(context, primaryColor, profileImageUrl), - const Spacer(), - _buildFooter(primaryColor, onPrimaryColor, context), - ], - ), - ), - ), - ); - } - - Widget _buildTitle(Color primaryColor, BuildContext context) { - return Text( - "Calling ${controller.friendCallModel.value!.recieverName}...", - style: TextStyle(color: primaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 6.5), - ); - } - - Widget _buildSubtitle(BuildContext context) { - return Text( - "Ringing...", - style: TextStyle(fontSize: UiSizes.size_14, color: Colors.grey.shade500), - ); - } - - Widget _buildLoadingIndicator( - BuildContext context, - Color primaryColor, - String profileImageUrl, - ) { - return Stack( - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: UiSizes.width_20, - vertical: UiSizes.height_20, - ), - child: LoadingIndicator( - indicatorType: Indicator.ballScaleMultiple, - colors: [ - primaryColor.withValues(alpha: 0.2), - primaryColor, - primaryColor.withValues(alpha: 0.6), - ], - strokeWidth: 2, - ), - ), - Positioned.fill( - child: Align( - alignment: Alignment.center, - child: CircleAvatar( - radius: Get.size.height * 0.05, - backgroundImage: NetworkImage(profileImageUrl), - ), - ), - ), - ], - ); - } - - Widget _buildFooter( - Color primaryColor, - Color onPrimaryColor, - BuildContext context, - ) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), - child: Column( - children: [ - _buildQuickFact(primaryColor, context), - SizedBox(height: UiSizes.height_15), - _buildCancelButton(primaryColor, onPrimaryColor, context), - ], - ), - ); - } - - Widget _buildQuickFact(Color primaryColor, BuildContext context) { - return Column( - children: [ - Text( - AppLocalizations.of(context)!.quickFact, - style: TextStyle(color: primaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 6.5), - ), - Text( - AppLocalizations.of(context)!.resonateOpenSourceProject, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: UiSizes.size_14, - color: Colors.grey.shade500, - ), - ), - ], - ); - } - - Widget _buildCancelButton( - Color primaryColor, - Color onPrimaryColor, - BuildContext context, - ) { - return ElevatedButton( - onPressed: () async { - await controller.onEndedCall({ - "call_id": controller.friendCallModel.value!.docId, - }); - Get.back(); - }, - style: ElevatedButton.styleFrom(backgroundColor: primaryColor), - child: Text( - AppLocalizations.of(context)!.cancel, - style: TextStyle(color: onPrimaryColor, fontSize: MediaQuery.of(context).devicePixelRatio * 8), - ), - ); - } -} diff --git a/lib/views/screens/tabview_screen.dart b/lib/views/screens/tabview_screen.dart index 1a8eb6cd..40859ad7 100644 --- a/lib/views/screens/tabview_screen.dart +++ b/lib/views/screens/tabview_screen.dart @@ -6,10 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; +import 'package:resonate/features/friends/view/widgets/pair_chat_dialog.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; import 'package:resonate/features/rooms/view/pages/create_room_page.dart'; import 'package:resonate/features/rooms/view/pages/room_page.dart'; import 'package:resonate/l10n/app_localizations.dart'; @@ -18,7 +19,6 @@ import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/utils/utils.dart'; import 'package:resonate/views/screens/explore_screen.dart'; import 'package:resonate/views/screens/home_screen.dart'; -import 'package:resonate/views/widgets/pair_chat_dialog.dart'; import 'package:resonate/views/widgets/profile_avatar.dart'; class TabViewScreen extends ConsumerStatefulWidget { @@ -140,9 +140,9 @@ class _TabViewScreenState extends ConsumerState { ), label: AppLocalizations.of(context)!.pairChat, labelStyle: TextStyle(fontSize: UiSizes.size_14), - onTap: () { - Get.put(PairChatController()); - buildPairChatDialog(context); + onTap: () async { + await ref.read(pairChatProvider.notifier).reset(); + if (context.mounted) showPairChatDialog(context); }, ), ], diff --git a/lib/views/widgets/chapter_player.dart b/lib/views/widgets/chapter_player.dart index 3c7fd93d..a4a5894f 100644 --- a/lib/views/widgets/chapter_player.dart +++ b/lib/views/widgets/chapter_player.dart @@ -97,11 +97,11 @@ class ChapterPlayer extends StatelessWidget { controller.sliderProgress.value = value; }, onChangeEnd: (double value) { - controller.lyricProgress.value = value.toInt(); - - controller.audioPlayer?.seek( - Duration(milliseconds: value.toInt()), + final position = Duration( + milliseconds: value.toInt(), ); + controller.lyricController.setProgress(position); + controller.audioPlayer?.seek(position); }, min: 0, max: diff --git a/lib/views/widgets/friend_request_list_tile.dart b/lib/views/widgets/friend_request_list_tile.dart deleted file mode 100644 index 45721c85..00000000 --- a/lib/views/widgets/friend_request_list_tile.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/friend_calling_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/controllers/friends_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/friends_model.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/features/profile/view/pages/profile_page.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class FriendsListTile extends StatelessWidget { - final FriendsModel friendModel; - final bool isRequest; - - const FriendsListTile({ - required this.friendModel, - required this.isRequest, - super.key, - }); - - @override - Widget build(BuildContext context) { - final friendsController = Get.find(); - final friendCallingController = Get.put(FriendCallingController()); - final RxBool isProcessing = false.obs; - final bool userIsSender = friendModel.senderId == requireCurrentAuthUser.uid; - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProfilePage( - creator: userIsSender - ? friendModel.recieverToResonateUserForRequestsPage() - : friendModel.senderToResonateUserForRequestsPage(), - isCreatorProfile: true, - ), - ), - ); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.secondary, - ), - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ListTile( - contentPadding: const EdgeInsets.all(0), - leading: CircleAvatar( - backgroundImage: NetworkImage( - userIsSender - ? friendModel.recieverProfileImgUrl - : friendModel.senderProfileImgUrl, - ), - radius: 25, - ), - trailing: Obx( - () => isProcessing.value - ? LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ) - : isRequest - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () async { - isProcessing.value = true; - await friendsController.acceptFriendRequest( - friendModel, - ); - isProcessing.value = false; - customSnackbar( - AppLocalizations.of(context)!.friendRequestAccepted, - AppLocalizations.of( - context, - )!.friendRequestAcceptedTo(friendModel.senderName), - LogType.success, - ); - }, - icon: Icon(Icons.check), - color: Colors.green, - ), - IconButton( - onPressed: () async { - isProcessing.value = true; - await friendsController.declineFriendRequest( - friendModel, - ); - isProcessing.value = false; - customSnackbar( - AppLocalizations.of(context)!.friendRequestDeclined, - AppLocalizations.of( - context, - )!.friendRequestDeclinedTo(friendModel.senderName), - LogType.info, - ); - }, - icon: Icon(Icons.close), - color: Colors.red, - ), - ], - ) - : IconButton( - onPressed: () async { - if (userIsSender) { - await friendCallingController.startCall( - friendModel.senderName, - friendModel.recieverName, - friendModel.senderUsername, - friendModel.recieverUsername, - friendModel.senderId, - friendModel.recieverId, - friendModel.senderProfileImgUrl, - friendModel.recieverProfileImgUrl, - friendModel.docId, - friendModel.recieverFCMToken!, - ); - } else { - await friendCallingController.startCall( - friendModel.recieverName, - friendModel.senderName, - friendModel.recieverUsername, - friendModel.senderUsername, - friendModel.recieverId, - friendModel.senderId, - friendModel.recieverProfileImgUrl, - friendModel.senderProfileImgUrl, - friendModel.docId, - friendModel.senderFCMToken!, - ); - } - }, - icon: Icon(Icons.call), - ), - ), - title: Text( - userIsSender ? friendModel.recieverName : friendModel.senderName, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - userIsSender - ? friendModel.recieverUsername - : friendModel.senderUsername, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.star, color: Colors.amber), - Text( - userIsSender - ? friendModel.recieverRating!.toStringAsFixed(1) - : friendModel.senderRating!.toStringAsFixed(1), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/widgets/rating_sheet.dart b/lib/views/widgets/rating_sheet.dart deleted file mode 100644 index 063e0c71..00000000 --- a/lib/views/widgets/rating_sheet.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:animated_rating_stars/animated_rating_stars.dart'; -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/pair_chat_controller.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class RatingSheetWidget extends StatelessWidget { - RatingSheetWidget({super.key}); - final TablesDB tablesDB = AppwriteService.getTables(); - final PairChatController controller = Get.find(); - AuthUser get authController => requireCurrentAuthUser; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - height: UiSizes.height_246, - - decoration: BoxDecoration( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - color: Theme.of(context).colorScheme.surface, - ), - // Placeholder for the rating sheet content - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - 'Rate your experience', - style: Theme.of(context).textTheme.headlineMedium, - ), - Obx( - () => Text( - 'Rating: ${controller.pairRating.value.toStringAsFixed(1)}/5.0', - //style: Theme.of(context).textTheme.bodyMedium, - ), - ), - AnimatedRatingStars( - onChanged: (double rating) { - controller.pairRating.value = rating; - }, - customFilledIcon: Icons.star, - customHalfFilledIcon: Icons.star_half, - customEmptyIcon: Icons.star_border_outlined, - displayRatingValue: true, - initialRating: 2.5, - minRating: 0.0, - maxRating: 5.0, - interactiveTooltips: true, - filledIcon: Icons.star, - halfFilledIcon: Icons.star_half, - emptyIcon: Icons.star_border_outlined, - ), - ElevatedButton( - onPressed: () async { - await tablesDB.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authController.uid, - data: { - "ratingTotal": - authController.ratingTotal + - controller.pairRating.value, - "ratingCount": authController.ratingCount + 1, - }, - ); - await rootContainer.read(authProvider.notifier).refresh(); - Get.back(); - }, - child: const Text('Submit'), - ), - ], - ), - ), - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 6696f1de..0e97c46a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -790,12 +790,11 @@ packages: flutter_lyric: dependency: "direct main" description: - path: "." - ref: master - resolved-ref: "2cf076d0ae4a2d6c00d6a521e35b97782ab24887" - url: "https://github.com/Aarush-Acharya/flutter_lyric.git" - source: git - version: "2.0.4+6" + name: flutter_lyric + sha256: b48b07d47136406e95180f3b3d97c596682164817df967933c3d4a3bfe46a9bd + url: "https://pub.dev" + source: hosted + version: "3.0.6" flutter_native_splash: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 87cdd8aa..d865966b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,10 +81,11 @@ dependencies: git: url: https://github.com/lionelmennig/textfield_tags.git ref: fixes/allow-controller-re-registration - flutter_lyric: - git: - url: https://github.com/Aarush-Acharya/flutter_lyric.git - ref: master + flutter_lyric: ^3.0.6 + # flutter_lyric: + # git: + # url: https://github.com/Aarush-Acharya/flutter_lyric.git + # ref: master url_launcher: ^6.3.2 uuid: ^4.5.2 audio_metadata_reader: ^1.4.2 diff --git a/test/controllers/chapter_player_controller_test.dart b/test/controllers/chapter_player_controller_test.dart index 71cc8710..a9a6685b 100644 --- a/test/controllers/chapter_player_controller_test.dart +++ b/test/controllers/chapter_player_controller_test.dart @@ -1,6 +1,5 @@ import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_lyric/lyrics_reader_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:resonate/controllers/chapter_player_controller.dart'; @@ -9,9 +8,9 @@ void main() { ChapterPlayerController chapterPlayerController = ChapterPlayerController(); test('check initial values', () { expect(chapterPlayerController.currentPage.value, 0.0); - expect(chapterPlayerController.lyricProgress.value, 0.0); expect(chapterPlayerController.sliderProgress.value, 0.0); expect(chapterPlayerController.isPlaying.value, false); + expect(chapterPlayerController.lyricController.lyricNotifier.value, null); }); testWidgets('check initialize', (WidgetTester tester) async { @@ -19,11 +18,14 @@ void main() { await tester.pumpAndSettle(); chapterPlayerController.initialize( AudioPlayer(), - LyricsReaderModel(), + '[00:01.00] Hello world', Duration(minutes: 3), ); - expect(chapterPlayerController.lyricModel, isA()); + expect( + chapterPlayerController.lyricController.lyricNotifier.value, + isNotNull, + ); expect(chapterPlayerController.audioPlayer, isA()); expect(chapterPlayerController.audioPlayer?.releaseMode, ReleaseMode.stop); expect(chapterPlayerController.chapterDuration.inMinutes, 3); @@ -34,7 +36,7 @@ void main() { await tester.pumpAndSettle(); chapterPlayerController.initialize( AudioPlayer(), - LyricsReaderModel(), + '[00:01.00] Hello world', Duration(minutes: 3), ); diff --git a/test/controllers/friend_calling_controller_test.dart b/test/controllers/friend_calling_controller_test.dart deleted file mode 100644 index d2395a59..00000000 --- a/test/controllers/friend_calling_controller_test.dart +++ /dev/null @@ -1,362 +0,0 @@ -import 'dart:async'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/friend_calling_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/models/friend_call_model.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/friend_call_status.dart'; - -import '../helpers/test_root_container.dart'; -import 'friend_calling_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB]) -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), -]) -final FriendCallModel mockFriendCallModel = FriendCallModel( - callerName: 'Test User 1', - recieverName: "Test User 2", - callerUsername: "Test User 1", - recieverUsername: "Test User 2", - callerUid: "id1", - recieverUid: "id2", - callerProfileImageUrl: "https://example.com/profile1.jpg", - recieverProfileImageUrl: "https://example.com/profile2.jpg", - livekitRoomId: "room1", - callStatus: FriendCallStatus.waiting, - docId: "doc1", -); -final Row mockFriendCallRow = Row( - $id: 'doc1', - $tableId: friendCallsTableId, - $databaseId: masterDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: {...mockFriendCallModel.toJson(), '\$id': 'doc1'}, - $sequence: 0, -); -final Row mockFriendCallEndedRow = Row( - $id: 'doc1', - $tableId: friendCallsTableId, - $databaseId: masterDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - ...mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.ended) - .toJson(), - '\$id': 'doc1', - }, - $sequence: 0, -); -final Row mockFriendCallDeclinedRow = Row( - $id: 'doc1', - $tableId: friendCallsTableId, - $databaseId: masterDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - ...mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.declined) - .toJson(), - '\$id': 'doc1', - }, - $sequence: 0, -); -final Row mockFriendCallAcceptedRow = Row( - $id: 'doc1', - $tableId: friendCallsTableId, - $databaseId: masterDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - ...mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.connected) - .toJson(), - '\$id': 'doc1', - }, - $sequence: 0, -); -StreamController mockRealtimeMessageStreamController = - StreamController.broadcast(); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late MockTablesDB tables; - late MockRealtime realtime; - late FriendCallingController friendCallingController; - setUp(() async { - // FriendCallingController.startCall / onDeclinedCall navigate via - // `appRouter`, which reads the routerProvider off the root container. - // Install a test container so those reads have valid providers. - await installTestRootContainer( - authState: AuthState.authenticated(fakeAuthUser()), - ); - - tables = MockTablesDB(); - realtime = MockRealtime(); - - friendCallingController = FriendCallingController( - tables: tables, - functions: MockFunctions(), - realtime: realtime, - ); - - when( - tables.getRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: 'doc1', - ), - ).thenAnswer((_) => Future.value(mockFriendCallRow)); - when( - tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: 'doc1', - data: mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.declined) - .toJson(), - ), - ).thenAnswer((_) => Future.value(mockFriendCallDeclinedRow)); - when( - tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: 'doc1', - data: mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.connected) - .toJson(), - ), - ).thenAnswer((_) => Future.value(mockFriendCallAcceptedRow)); - when( - tables.updateRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: 'doc1', - data: mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.ended) - .toJson(), - ), - ).thenAnswer((_) => Future.value(mockFriendCallEndedRow)); - when( - tables.createRow( - databaseId: masterDatabaseId, - tableId: friendCallsTableId, - rowId: anyNamed('rowId'), - data: mockFriendCallModel.toJson(), - ), - ).thenAnswer((_) => Future.value(mockFriendCallRow)); - - when(realtime.subscribe(any)).thenAnswer( - (_) => RealtimeSubscription( - close: () async {}, - channels: [ - 'databases.$masterDatabaseId.collections.$friendCallsTableId.documents.doc1', - ], - controller: mockRealtimeMessageStreamController, - ), - ); - - Get.testMode = true; - }); - - test('test startCall', () async { - await friendCallingController.startCall( - mockFriendCallModel.callerName, - mockFriendCallModel.recieverName, - mockFriendCallModel.callerUsername, - mockFriendCallModel.recieverUsername, - mockFriendCallModel.callerUid, - mockFriendCallModel.recieverUid, - mockFriendCallModel.callerProfileImageUrl, - mockFriendCallModel.recieverProfileImageUrl, - mockFriendCallModel.livekitRoomId, - "testToken2", - ); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.waiting, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - expect( - friendCallingController.friendCallModel.value!.recieverName, - 'Test User 2', - ); - expect(friendCallingController.friendCallModel.value!.callerUid, 'id1'); - expect(friendCallingController.friendCallModel.value!.recieverUid, 'id2'); - }); - test('test listenToCallChanges', () async { - await friendCallingController.startCall( - mockFriendCallModel.callerName, - mockFriendCallModel.recieverName, - mockFriendCallModel.callerUsername, - mockFriendCallModel.recieverUsername, - mockFriendCallModel.callerUid, - mockFriendCallModel.recieverUid, - mockFriendCallModel.callerProfileImageUrl, - mockFriendCallModel.recieverProfileImageUrl, - mockFriendCallModel.livekitRoomId, - "testToken2", - ); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.waiting, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - mockRealtimeMessageStreamController.add( - RealtimeMessage( - events: [ - 'databases.$masterDatabaseId.collections.$friendCallsTableId.documents.${friendCallingController.friendCallModel.value!.docId}.update', - ], - payload: { - "ip": "", - "\$id": friendCallingController.friendCallModel.value!.docId, - "\$createdAt": DateTime.now().toIso8601String(), - "\$updatedAt": DateTime.now().toIso8601String(), - "\$permissions": [], - "\$tableId": friendCallsTableId, - "\$databaseId": masterDatabaseId, - ...mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.connected) - .toJson(), - }, - channels: [ - 'databases.$masterDatabaseId.collections.$friendCallsTableId.documents', - ], - timestamp: DateTime.now().toIso8601String(), - ), - ); - await Future.delayed(Duration(seconds: 3)); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.connected, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - - await Future.delayed(Duration(seconds: 3)); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.connected, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - mockRealtimeMessageStreamController.add( - RealtimeMessage( - events: [ - 'databases.$masterDatabaseId.collections.$friendCallsTableId.documents.${friendCallingController.friendCallModel.value!.docId}.update', - ], - payload: { - "ip": "", - "\$id": friendCallingController.friendCallModel.value!.docId, - "\$createdAt": DateTime.now().toIso8601String(), - "\$updatedAt": DateTime.now().toIso8601String(), - "\$permissions": [], - "\$tableId": friendCallsTableId, - "\$databaseId": masterDatabaseId, - ...mockFriendCallModel - .copyWith(callStatus: FriendCallStatus.ended) - .toJson(), - }, - channels: [ - 'databases.$masterDatabaseId.collections.$friendCallsTableId.documents', - ], - timestamp: DateTime.now().toIso8601String(), - ), - ); - await Future.delayed(Duration(seconds: 3)); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.ended, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - }); - test('test onAnswerCall', () async { - await friendCallingController.onAnswerCall({'call_id': 'doc1'}); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.connected, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - expect( - friendCallingController.friendCallModel.value!.recieverName, - 'Test User 2', - ); - expect(friendCallingController.friendCallModel.value!.callerUid, 'id1'); - expect(friendCallingController.friendCallModel.value!.recieverUid, 'id2'); - }); - test('test onDeclinedCall', () async { - await friendCallingController.onDeclinedCall({'call_id': 'doc1'}); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.declined, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - expect( - friendCallingController.friendCallModel.value!.recieverName, - 'Test User 2', - ); - expect(friendCallingController.friendCallModel.value!.callerUid, 'id1'); - expect(friendCallingController.friendCallModel.value!.recieverUid, 'id2'); - }); - test('test onEndedCall', () async { - await friendCallingController.onEndedCall({'call_id': 'doc1'}); - expect( - friendCallingController.friendCallModel.value!.callStatus, - FriendCallStatus.ended, - ); - expect( - friendCallingController.friendCallModel.value!.callerName, - 'Test User 1', - ); - expect( - friendCallingController.friendCallModel.value!.recieverName, - 'Test User 2', - ); - expect(friendCallingController.friendCallModel.value!.callerUid, 'id1'); - expect(friendCallingController.friendCallModel.value!.recieverUid, 'id2'); - }); - test('test toggleMic', () async { - expect(friendCallingController.isMicOn.value, false); - friendCallingController.toggleMic(); - expect(friendCallingController.isMicOn.value, true); - }); - test('test toggleLoudSpeaker', () async { - expect(friendCallingController.isLoudSpeakerOn.value, true); - friendCallingController.toggleLoudSpeaker(); - expect(friendCallingController.isLoudSpeakerOn.value, false); - }); -} diff --git a/test/controllers/friend_calling_controller_test.mocks.dart b/test/controllers/friend_calling_controller_test.mocks.dart deleted file mode 100644 index e0bdbf7f..00000000 --- a/test/controllers/friend_calling_controller_test.mocks.dart +++ /dev/null @@ -1,619 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/friend_calling_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; - -import 'package:appwrite/appwrite.dart' as _i5; -import 'package:appwrite/enums.dart' as _i9; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/realtime.dart' as _i7; -import 'package:appwrite/src/realtime_subscription.dart' as _i4; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart' as _i8; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRealtimeSubscription_5 extends _i1.SmartFake - implements _i4.RealtimeSubscription { - _FakeRealtimeSubscription_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecutionList_6 extends _i1.SmartFake implements _i3.ExecutionList { - _FakeExecutionList_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecution_7 extends _i1.SmartFake implements _i3.Execution { - _FakeExecution_7(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [TablesDB]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i5.TablesDB { - MockTablesDB() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i6.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i6.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i6.Future<_i3.TransactionList>); - - @override - _i6.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i6.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i6.Future<_i3.Transaction>); - - @override - _i6.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i6.Future<_i3.RowList>.value( - _FakeRowList_3( - this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i6.Future<_i3.RowList>); - - @override - _i6.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), - returnValue: _i6.Future.value(), - ) - as _i6.Future); - - @override - _i6.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); - - @override - _i6.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i6.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i6.Future<_i3.Row>); -} - -/// A class which mocks [Realtime]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockRealtime extends _i1.Mock implements _i7.Realtime { - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - returnValueForMissingStub: _FakeClient_0( - this, - Invocation.getter(#client), - ), - ) - as _i2.Client); - - @override - _i4.RealtimeSubscription subscribe(List? channels) => - (super.noSuchMethod( - Invocation.method(#subscribe, [channels]), - returnValue: _FakeRealtimeSubscription_5( - this, - Invocation.method(#subscribe, [channels]), - ), - returnValueForMissingStub: _FakeRealtimeSubscription_5( - this, - Invocation.method(#subscribe, [channels]), - ), - ) - as _i4.RealtimeSubscription); -} - -/// A class which mocks [FlutterCallkitIncoming]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFlutterCallkitIncoming extends _i1.Mock - implements _i8.FlutterCallkitIncoming {} - -/// A class which mocks [Functions]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFunctions extends _i1.Mock implements _i5.Functions { - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - returnValueForMissingStub: _FakeClient_0( - this, - Invocation.getter(#client), - ), - ) - as _i2.Client); - - @override - _i6.Future<_i3.ExecutionList> listExecutions({ - required String? functionId, - List? queries, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - returnValue: _i6.Future<_i3.ExecutionList>.value( - _FakeExecutionList_6( - this, - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - ), - ), - returnValueForMissingStub: _i6.Future<_i3.ExecutionList>.value( - _FakeExecutionList_6( - this, - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - ), - ), - ) - as _i6.Future<_i3.ExecutionList>); - - @override - _i6.Future<_i3.Execution> createExecution({ - required String? functionId, - String? body, - bool? xasync, - String? path, - _i9.ExecutionMethod? method, - Map? headers, - String? scheduledAt, - }) => - (super.noSuchMethod( - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - returnValue: _i6.Future<_i3.Execution>.value( - _FakeExecution_7( - this, - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - ), - ), - returnValueForMissingStub: _i6.Future<_i3.Execution>.value( - _FakeExecution_7( - this, - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - ), - ), - ) - as _i6.Future<_i3.Execution>); - - @override - _i6.Future<_i3.Execution> getExecution({ - required String? functionId, - required String? executionId, - }) => - (super.noSuchMethod( - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - returnValue: _i6.Future<_i3.Execution>.value( - _FakeExecution_7( - this, - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - ), - ), - returnValueForMissingStub: _i6.Future<_i3.Execution>.value( - _FakeExecution_7( - this, - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - ), - ), - ) - as _i6.Future<_i3.Execution>); -} diff --git a/test/controllers/friends_controller_test.dart b/test/controllers/friends_controller_test.dart deleted file mode 100644 index 92b2fc0f..00000000 --- a/test/controllers/friends_controller_test.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/friends_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/models/friends_model.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/friend_request_status.dart'; - -import '../helpers/test_root_container.dart'; -import 'friends_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB, Functions, FirebaseMessaging]) -@GenerateNiceMocks([MockSpec()]) -final List mockFriendModelList = [ - FriendsModel( - senderId: "id2", - recieverId: 'id1', - senderProfileImgUrl: 'example.com/1', - recieverProfileImgUrl: 'example.com/2', - senderUsername: 'testu2', - recieverUsername: 'testu1', - senderName: "Test User 2", - recieverName: "Test User 1", - requestStatus: FriendRequestStatus.sent, - requestSentByUserId: 'testu2', - senderRating: 5.0, - recieverRating: 5.0, - docId: 'doc1', - users: ['id2', 'id1'], - senderFCMToken: 'testToken2', - ), - FriendsModel( - senderId: "id2", - recieverId: 'id3', - senderProfileImgUrl: 'example.com/1', - recieverProfileImgUrl: 'example.com/3', - senderUsername: 'testu2', - recieverUsername: 'testu3', - senderName: "Test User 2", - recieverName: "Test User 3", - requestStatus: FriendRequestStatus.accepted, - requestSentByUserId: 'testu2', - senderRating: 5.0, - recieverRating: 5.0, - docId: 'doc2', - users: ['id2', 'id3'], - senderFCMToken: 'testToken2', - recieverFCMToken: 'testToken3', - ), - FriendsModel( - senderId: "id5", - recieverId: 'id2', - senderProfileImgUrl: 'example.com/5', - recieverProfileImgUrl: 'example.com/2', - senderUsername: 'testu5', - recieverUsername: 'testu2', - senderName: "Test User 5", - recieverName: "Test User 2", - requestStatus: FriendRequestStatus.sent, - requestSentByUserId: 'testu5', - senderRating: 5.0, - recieverRating: 5.0, - docId: 'doc4', - users: ['id5', 'id2'], - senderFCMToken: 'testToken5', - ), -]; -final List mockFriendRows = [ - Row( - $id: 'doc1', - $tableId: friendsTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: mockFriendModelList[0].toJson(), - $sequence: 0, - ), - Row( - $id: 'doc2', - $tableId: friendsTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: mockFriendModelList[1].toJson(), - $sequence: 1, - ), - Row( - $id: 'doc4', - $tableId: friendsTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: mockFriendModelList[2].toJson(), - $sequence: 2, - ), -]; -final Row mockUserRow = Row( - $id: 'doc1', - $tableId: usersTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - 'name': "Test User 2", - 'dob': "2000-01-01", - 'username': "testu2", - 'profileImageUrl': "https://example.com/profile2.jpg", - 'email': "testuser2@example.com", - 'profileImageId': "profileImageId2", - 'ratingCount': 5, - 'ratingTotal': 25, - 'followers': [], - 'friends': [ - {"\$id": 'doc1', ...mockFriendRows[0].data}, - {"\$id": 'doc2', ...mockFriendRows[1].data}, - {"\$id": 'doc4', ...mockFriendRows[2].data}, - ], - }, - $sequence: 0, -); -final FriendsModel mockSentFriendRequest = FriendsModel( - senderId: 'id2', - recieverId: 'id4', - senderProfileImgUrl: 'https://example.com/profile2.jpg', - recieverProfileImgUrl: 'example.com/4', - senderUsername: 'testu2', - recieverUsername: 'testu4', - senderName: 'Test User 2', - recieverName: 'Test User 4', - requestStatus: FriendRequestStatus.sent, - requestSentByUserId: 'id2', - senderRating: 5.0, - recieverRating: 4.0, - docId: 'doc3', - users: ['id2', 'id4'], - senderFCMToken: 'testToken2', -); -final Row mockSentFriendRequestRow = Row( - $id: 'doc3', - $tableId: friendsTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: mockSentFriendRequest.toJson(), - $sequence: 0, -); -final FriendsModel mockAcceptedRequestModel = mockFriendModelList[2].copyWith( - recieverFCMToken: 'testToken2', - requestStatus: FriendRequestStatus.accepted, -); - -void main() { - late MockTablesDB tables; - late MockFirebaseMessaging mockFirebaseMessaging; - late FriendsController friendsController; - - setUp(() async { - Get.testMode = true; - // Install auth state that mirrors what the original AuthStateController - // mutations used to set up. - await installTestRootContainer( - authState: AuthState.authenticated( - fakeAuthUser( - uid: 'id2', - userName: 'testu2', - profileImageUrl: 'https://example.com/profile2.jpg', - displayName: 'Test User 2', - ratingTotal: 25.0, - ratingCount: 5, - ), - ), - ); - - tables = MockTablesDB(); - mockFirebaseMessaging = MockFirebaseMessaging(); - friendsController = FriendsController( - tables: tables, - firebaseMessaging: mockFirebaseMessaging, - functions: MockFunctions(), - realtime: MockRealtime(), - ); - - when( - tables.getRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: 'id2', - queries: [Query.select(["*", "friends.*"])], - ), - ).thenAnswer( - (_) => Future.delayed(const Duration(seconds: 2), () => mockUserRow), - ); - when( - tables.createRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: anyNamed('rowId'), - data: mockSentFriendRequest.toJson(), - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 2), - () => mockSentFriendRequestRow, - ), - ); - when(mockFirebaseMessaging.getToken()) - .thenAnswer((_) async => 'testToken2'); - when( - tables.deleteRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: anyNamed('rowId'), - ), - ).thenAnswer((_) => Future.delayed(const Duration(seconds: 0))); - when( - tables.updateRow( - databaseId: userDatabaseID, - tableId: friendsTableID, - rowId: anyNamed('rowId'), - data: mockAcceptedRequestModel.toJson(), - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 2), - () => mockFriendRows[2], - ), - ); - }); - - test('getFriendsList loads accepted + pending into separate lists', - () async { - expect(friendsController.isLoadingFriends.value, false); - friendsController.getFriendsList(); - expect(friendsController.isLoadingFriends.value, true); - expect(friendsController.friendsList, isEmpty); - expect(friendsController.friendRequestsList, isEmpty); - - await Future.delayed(const Duration(seconds: 2)); - - expect(friendsController.isLoadingFriends.value, false); - expect(friendsController.friendsList.length, 1); - expect(friendsController.friendsList[0].senderName, 'Test User 2'); - expect(friendsController.friendsList[0].recieverName, 'Test User 3'); - expect(friendsController.friendsList[0].senderFCMToken, 'testToken2'); - expect(friendsController.friendsList[0].recieverFCMToken, 'testToken3'); - expect(friendsController.friendRequestsList.length, 2); - expect( - friendsController.friendRequestsList[0].senderName, - 'Test User 2', - ); - expect( - friendsController.friendRequestsList[0].recieverName, - 'Test User 1', - ); - expect( - friendsController.friendRequestsList[0].senderFCMToken, - 'testToken2', - ); - expect(friendsController.friendRequestsList[0].recieverFCMToken, null); - }); - - test('sendFriendRequest creates a new outgoing request', () async { - await friendsController.sendFriendRequest( - 'id4', - 'example.com/4', - 'testu4', - 'Test User 4', - 4, - ); - - expect(friendsController.friendRequestsList.length, 1); - expect( - friendsController.friendRequestsList[0].recieverName, - 'Test User 4', - ); - expect(friendsController.friendRequestsList[0].senderName, 'Test User 2'); - expect( - friendsController.friendRequestsList[0].recieverUsername, - 'testu4', - ); - expect( - friendsController.friendRequestsList[0].senderFCMToken, - 'testToken2', - ); - }); - - test('removeFriend deletes from friendsList', () async { - await friendsController.getFriendsList(); - - expect(friendsController.friendsList.length, 1); - expect(friendsController.friendRequestsList.length, 2); - - await friendsController.removeFriend(mockFriendModelList[1]); - - expect(friendsController.friendsList.length, 0); - expect(friendsController.friendRequestsList.length, 2); - }); - - test('acceptFriendRequest moves request to friendsList', () async { - await friendsController.getFriendsList(); - - expect(friendsController.friendsList.length, 1); - expect(friendsController.friendRequestsList.length, 2); - - await friendsController.acceptFriendRequest(mockFriendModelList[2]); - - expect(friendsController.friendsList.length, 2); - expect(friendsController.friendRequestsList.length, 1); - expect(friendsController.friendsList[1].recieverName, 'Test User 2'); - expect(friendsController.friendsList[1].recieverFCMToken, 'testToken2'); - }); - - test('declineFriendRequest removes from requests only', () async { - await friendsController.getFriendsList(); - - expect(friendsController.friendsList.length, 1); - expect(friendsController.friendRequestsList.length, 2); - - await friendsController.declineFriendRequest(mockFriendModelList[2]); - - expect(friendsController.friendsList.length, 1); - expect(friendsController.friendRequestsList.length, 1); - }); -} diff --git a/test/controllers/friends_controller_test.mocks.dart b/test/controllers/friends_controller_test.mocks.dart deleted file mode 100644 index aca6dc78..00000000 --- a/test/controllers/friends_controller_test.mocks.dart +++ /dev/null @@ -1,789 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/friends_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i8; - -import 'package:appwrite/appwrite.dart' as _i7; -import 'package:appwrite/enums.dart' as _i9; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/realtime.dart' as _i11; -import 'package:appwrite/src/realtime_subscription.dart' as _i6; -import 'package:firebase_core/firebase_core.dart' as _i4; -import 'package:firebase_messaging/firebase_messaging.dart' as _i10; -import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' - as _i5; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecutionList_5 extends _i1.SmartFake implements _i3.ExecutionList { - _FakeExecutionList_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecution_6 extends _i1.SmartFake implements _i3.Execution { - _FakeExecution_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFirebaseApp_7 extends _i1.SmartFake implements _i4.FirebaseApp { - _FakeFirebaseApp_7(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeNotificationSettings_8 extends _i1.SmartFake - implements _i5.NotificationSettings { - _FakeNotificationSettings_8(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRealtimeSubscription_9 extends _i1.SmartFake - implements _i6.RealtimeSubscription { - _FakeRealtimeSubscription_9(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [TablesDB]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i7.TablesDB { - MockTablesDB() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i8.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i8.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i8.Future<_i3.TransactionList>); - - @override - _i8.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i8.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i8.Future<_i3.Transaction>); - - @override - _i8.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Transaction>); - - @override - _i8.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i8.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i8.Future<_i3.Transaction>); - - @override - _i8.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i8.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i8.Future<_i3.Transaction>); - - @override - _i8.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i8.Future<_i3.RowList>.value( - _FakeRowList_3( - this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i8.Future<_i3.RowList>); - - @override - _i8.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); - - @override - _i8.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); - - @override - _i8.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); - - @override - _i8.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); - - @override - _i8.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), - returnValue: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); - - @override - _i8.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i8.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i8.Future<_i3.Row>); -} - -/// A class which mocks [Functions]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFunctions extends _i1.Mock implements _i7.Functions { - MockFunctions() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i8.Future<_i3.ExecutionList> listExecutions({ - required String? functionId, - List? queries, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - returnValue: _i8.Future<_i3.ExecutionList>.value( - _FakeExecutionList_5( - this, - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - ), - ), - ) - as _i8.Future<_i3.ExecutionList>); - - @override - _i8.Future<_i3.Execution> createExecution({ - required String? functionId, - String? body, - bool? xasync, - String? path, - _i9.ExecutionMethod? method, - Map? headers, - String? scheduledAt, - }) => - (super.noSuchMethod( - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - returnValue: _i8.Future<_i3.Execution>.value( - _FakeExecution_6( - this, - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - ), - ), - ) - as _i8.Future<_i3.Execution>); - - @override - _i8.Future<_i3.Execution> getExecution({ - required String? functionId, - required String? executionId, - }) => - (super.noSuchMethod( - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - returnValue: _i8.Future<_i3.Execution>.value( - _FakeExecution_6( - this, - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - ), - ), - ) - as _i8.Future<_i3.Execution>); -} - -/// A class which mocks [FirebaseMessaging]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFirebaseMessaging extends _i1.Mock implements _i10.FirebaseMessaging { - MockFirebaseMessaging() { - _i1.throwOnMissingStub(this); - } - - @override - _i4.FirebaseApp get app => - (super.noSuchMethod( - Invocation.getter(#app), - returnValue: _FakeFirebaseApp_7(this, Invocation.getter(#app)), - ) - as _i4.FirebaseApp); - - @override - bool get isAutoInitEnabled => - (super.noSuchMethod( - Invocation.getter(#isAutoInitEnabled), - returnValue: false, - ) - as bool); - - @override - _i8.Stream get onTokenRefresh => - (super.noSuchMethod( - Invocation.getter(#onTokenRefresh), - returnValue: _i8.Stream.empty(), - ) - as _i8.Stream); - - @override - set app(_i4.FirebaseApp? value) => super.noSuchMethod( - Invocation.setter(#app, value), - returnValueForMissingStub: null, - ); - - @override - Map get pluginConstants => - (super.noSuchMethod( - Invocation.getter(#pluginConstants), - returnValue: {}, - ) - as Map); - - @override - _i8.Future<_i5.RemoteMessage?> getInitialMessage() => - (super.noSuchMethod( - Invocation.method(#getInitialMessage, []), - returnValue: _i8.Future<_i5.RemoteMessage?>.value(), - ) - as _i8.Future<_i5.RemoteMessage?>); - - @override - _i8.Future deleteToken() => - (super.noSuchMethod( - Invocation.method(#deleteToken, []), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future getAPNSToken() => - (super.noSuchMethod( - Invocation.method(#getAPNSToken, []), - returnValue: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future getToken({String? vapidKey}) => - (super.noSuchMethod( - Invocation.method(#getToken, [], {#vapidKey: vapidKey}), - returnValue: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future isSupported() => - (super.noSuchMethod( - Invocation.method(#isSupported, []), - returnValue: _i8.Future.value(false), - ) - as _i8.Future); - - @override - _i8.Future<_i5.NotificationSettings> getNotificationSettings() => - (super.noSuchMethod( - Invocation.method(#getNotificationSettings, []), - returnValue: _i8.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_8( - this, - Invocation.method(#getNotificationSettings, []), - ), - ), - ) - as _i8.Future<_i5.NotificationSettings>); - - @override - _i8.Future<_i5.NotificationSettings> requestPermission({ - bool? alert = true, - bool? announcement = false, - bool? badge = true, - bool? carPlay = false, - bool? criticalAlert = false, - bool? provisional = false, - bool? sound = true, - bool? providesAppNotificationSettings = false, - }) => - (super.noSuchMethod( - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: providesAppNotificationSettings, - }), - returnValue: _i8.Future<_i5.NotificationSettings>.value( - _FakeNotificationSettings_8( - this, - Invocation.method(#requestPermission, [], { - #alert: alert, - #announcement: announcement, - #badge: badge, - #carPlay: carPlay, - #criticalAlert: criticalAlert, - #provisional: provisional, - #sound: sound, - #providesAppNotificationSettings: - providesAppNotificationSettings, - }), - ), - ), - ) - as _i8.Future<_i5.NotificationSettings>); - - @override - _i8.Future setAutoInitEnabled(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setAutoInitEnabled, [enabled]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future setDeliveryMetricsExportToBigQuery(bool? enabled) => - (super.noSuchMethod( - Invocation.method(#setDeliveryMetricsExportToBigQuery, [enabled]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future setForegroundNotificationPresentationOptions({ - bool? alert = false, - bool? badge = false, - bool? sound = false, - }) => - (super.noSuchMethod( - Invocation.method( - #setForegroundNotificationPresentationOptions, - [], - {#alert: alert, #badge: badge, #sound: sound}, - ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future subscribeToTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#subscribeToTopic, [topic]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); - - @override - _i8.Future unsubscribeFromTopic(String? topic) => - (super.noSuchMethod( - Invocation.method(#unsubscribeFromTopic, [topic]), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) - as _i8.Future); -} - -/// A class which mocks [Realtime]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockRealtime extends _i1.Mock implements _i11.Realtime { - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - returnValueForMissingStub: _FakeClient_0( - this, - Invocation.getter(#client), - ), - ) - as _i2.Client); - - @override - _i6.RealtimeSubscription subscribe(List? channels) => - (super.noSuchMethod( - Invocation.method(#subscribe, [channels]), - returnValue: _FakeRealtimeSubscription_9( - this, - Invocation.method(#subscribe, [channels]), - ), - returnValueForMissingStub: _FakeRealtimeSubscription_9( - this, - Invocation.method(#subscribe, [channels]), - ), - ) - as _i6.RealtimeSubscription); -} diff --git a/test/features/friends/viewmodel/friend_call_notifier_test.dart b/test/features/friends/viewmodel/friend_call_notifier_test.dart new file mode 100644 index 00000000..7e380779 --- /dev/null +++ b/test/features/friends/viewmodel/friend_call_notifier_test.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/friends/model/friend_call_model.dart'; +import 'package:resonate/features/friends/model/friends_state.dart'; +import 'package:resonate/features/friends/viewmodel/friend_call_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/friend_call_status.dart'; +import 'package:resonate/utils/enums/friend_request_status.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late FakeCallKitService callKit; + late StreamController realtimeEvents; + + // I am the sender of the friendship doc, calling reciever-1. + final friend = fakeFriendsModel( + senderId: 'me', + recieverId: 'reciever-1', + senderName: 'Me', + recieverName: 'Friend', + requestStatus: FriendRequestStatus.accepted, + recieverFCMToken: 'their-token', + docId: 'friendship-doc', + ); + + Map callJson(FriendCallModel call) => + {...call.toJson(), '\$id': call.docId}; + + MockExecution joinExecution() { + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn( + '{"access_token":"token-1","livekit_socket_url":"wss://host.docker.internal:7880"}', + ); + return exec; + } + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + callKit = FakeCallKitService(); + realtimeEvents = StreamController.broadcast(); + + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: invocation.namedArguments[#rowId] as String, + data: invocation.namedArguments[#data] as Map, + )); + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: invocation.namedArguments[#rowId] as String, + data: invocation.namedArguments[#data] as Map, + )); + when(functions.createExecution( + functionId: anyNamed('functionId'), + body: anyNamed('body'), + )).thenAnswer((_) async => joinExecution()); + when(realtime.subscribe(any)).thenAnswer( + (invocation) => RealtimeSubscription( + close: () async {}, + channels: List.from(invocation.positionalArguments[0] as List), + controller: realtimeEvents, + ), + ); + }); + + tearDown(() => realtimeEvents.close()); + + Future buildContainer() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + callKit: callKit, + ); + + group('FriendCallNotifier', () { + test('startCall creates the call row, rings over FCM, and sets waiting', + () async { + final container = await buildContainer(); + + await container.read(friendCallProvider.notifier).startCall(friend); + + final state = container.read(friendCallProvider); + expect(state.activeCall, isNotNull); + expect(state.activeCall!.callStatus, FriendCallStatus.waiting); + expect(state.activeCall!.callerUid, 'me'); + expect(state.activeCall!.recieverUid, 'reciever-1'); + expect(state.activeCall!.livekitRoomId, 'friendship-doc'); + verify(tables.createRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + verify(functions.createExecution( + functionId: startFriendCallFunctionID, + body: anyNamed('body'), + )).called(1); + }); + + test('startCall throws when the friend has no FCM token', () async { + final container = await buildContainer(); + final tokenless = fakeFriendsModel( + senderId: 'me', + recieverId: 'reciever-1', + recieverFCMToken: null, + ); + + expect( + () => container.read(friendCallProvider.notifier).startCall(tokenless), + throwsA(isA()), + ); + }); + + test('realtime connected update joins LiveKit on the caller side', + () async { + final container = await buildContainer(); + final notifier = container.read(friendCallProvider.notifier); + await notifier.startCall(friend); + final call = container.read(friendCallProvider).activeCall!; + + realtimeEvents.add(RealtimeMessage( + events: [ + 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.${call.docId}.update', + ], + payload: callJson( + call.copyWith(callStatus: FriendCallStatus.connected), + ), + channels: [ + 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.${call.docId}', + ], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + expect( + container.read(friendCallProvider).activeCall!.callStatus, + FriendCallStatus.connected, + ); + expect(container.read(liveKitProvider).isConnected, isTrue); + }); + + test('realtime ended update tears the call down', () async { + final container = await buildContainer(); + final notifier = container.read(friendCallProvider.notifier); + await notifier.startCall(friend); + final call = container.read(friendCallProvider).activeCall!; + + realtimeEvents.add(RealtimeMessage( + events: [ + 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.${call.docId}.update', + ], + payload: callJson(call.copyWith(callStatus: FriendCallStatus.ended)), + channels: [ + 'databases.$masterDatabaseId.tables.$friendCallsTableId.rows.${call.docId}', + ], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + expect( + container.read(friendCallProvider).activeCall!.callStatus, + FriendCallStatus.ended, + ); + expect(callKit.endAllCallsCount, 1); + expect(container.read(liveKitProvider).isConnected, isFalse); + }); + + test('onAnswerCall marks the call connected and joins LiveKit', () async { + final container = await buildContainer(); + final incoming = FriendCallModel( + callerName: 'Friend', + recieverName: 'Me', + callerUsername: 'friend', + recieverUsername: 'me', + callerUid: 'caller-1', + recieverUid: 'me', + callerProfileImageUrl: 'https://example.com/c.jpg', + recieverProfileImageUrl: 'https://example.com/m.jpg', + livekitRoomId: 'friendship-doc', + callStatus: FriendCallStatus.waiting, + docId: 'call-doc', + ); + when(tables.getRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: 'call-doc', + )).thenAnswer((_) async => buildRow( + id: 'call-doc', + data: callJson(incoming), + )); + + await container + .read(friendCallProvider.notifier) + .onAnswerCall({'call_id': 'call-doc'}); + + final state = container.read(friendCallProvider); + expect(state.activeCall!.callStatus, FriendCallStatus.connected); + expect(container.read(liveKitProvider).isConnected, isTrue); + verify(tables.updateRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: 'call-doc', + data: anyNamed('data'), + )).called(1); + }); + + test('onAnswerCall does nothing when the call already ended', () async { + final container = await buildContainer(); + final endedCall = FriendCallModel( + callerName: 'Friend', + recieverName: 'Me', + callerUsername: 'friend', + recieverUsername: 'me', + callerUid: 'caller-1', + recieverUid: 'me', + callerProfileImageUrl: 'https://example.com/c.jpg', + recieverProfileImageUrl: 'https://example.com/m.jpg', + livekitRoomId: 'friendship-doc', + callStatus: FriendCallStatus.ended, + docId: 'call-doc', + ); + when(tables.getRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: 'call-doc', + )).thenAnswer((_) async => buildRow( + id: 'call-doc', + data: callJson(endedCall), + )); + + await container + .read(friendCallProvider.notifier) + .onAnswerCall({'call_id': 'call-doc'}); + + expect(container.read(friendCallProvider).activeCall, isNull); + verifyNever(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )); + }); + + test('onDeclinedCall marks the call declined', () async { + final container = await buildContainer(); + final incoming = FriendCallModel( + callerName: 'Friend', + recieverName: 'Me', + callerUsername: 'friend', + recieverUsername: 'me', + callerUid: 'caller-1', + recieverUid: 'me', + callerProfileImageUrl: 'https://example.com/c.jpg', + recieverProfileImageUrl: 'https://example.com/m.jpg', + livekitRoomId: 'friendship-doc', + callStatus: FriendCallStatus.waiting, + docId: 'call-doc', + ); + when(tables.getRow( + databaseId: masterDatabaseId, + tableId: friendCallsTableId, + rowId: 'call-doc', + )).thenAnswer((_) async => buildRow( + id: 'call-doc', + data: callJson(incoming), + )); + + await container + .read(friendCallProvider.notifier) + .onDeclinedCall({'call_id': 'call-doc'}); + + expect( + container.read(friendCallProvider).activeCall!.callStatus, + FriendCallStatus.declined, + ); + }); + + test('endCall updates the row and tears everything down', () async { + final container = await buildContainer(); + final notifier = container.read(friendCallProvider.notifier); + await notifier.startCall(friend); + + await notifier.endCall(); + + final state = container.read(friendCallProvider); + expect(state.activeCall!.callStatus, FriendCallStatus.ended); + expect(callKit.endAllCallsCount, 1); + expect(container.read(liveKitProvider).isConnected, isFalse); + }); + + test('toggleMic and toggleLoudSpeaker flip their flags', () async { + final container = await buildContainer(); + final notifier = container.read(friendCallProvider.notifier); + + expect(container.read(friendCallProvider).isMicOn, isFalse); + await notifier.toggleMic(); + expect(container.read(friendCallProvider).isMicOn, isTrue); + + expect(container.read(friendCallProvider).isLoudSpeakerOn, isTrue); + await notifier.toggleLoudSpeaker(); + expect(container.read(friendCallProvider).isLoudSpeakerOn, isFalse); + }); + }); +} diff --git a/test/features/friends/viewmodel/friends_notifier_test.dart b/test/features/friends/viewmodel/friends_notifier_test.dart new file mode 100644 index 00000000..48c6e9e1 --- /dev/null +++ b/test/features/friends/viewmodel/friends_notifier_test.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/friend_request_status.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Map _friendJson(FriendsModel model) => { + ...model.toJson(), + '\$id': model.docId, + }; + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFirebaseMessaging messaging; + late StreamController realtimeEvents; + + final accepted = fakeFriendsModel( + docId: 'doc-accepted', + senderId: 'me', + recieverId: 'friend-1', + requestStatus: FriendRequestStatus.accepted, + ); + final incoming = fakeFriendsModel( + docId: 'doc-incoming', + senderId: 'friend-2', + recieverId: 'me', + requestStatus: FriendRequestStatus.sent, + recieverFCMToken: null, + ); + final outgoing = fakeFriendsModel( + docId: 'doc-outgoing', + senderId: 'me', + recieverId: 'friend-3', + requestStatus: FriendRequestStatus.sent, + recieverFCMToken: null, + ); + + void stubUserDoc(List> friends) { + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'me', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 'me', + tableId: usersTableID, + databaseId: userDatabaseID, + data: {'friends': friends}, + )); + } + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + messaging = MockFirebaseMessaging(); + realtimeEvents = StreamController.broadcast(); + + when(messaging.getToken()).thenAnswer((_) async => 'my-fcm-token'); + when(realtime.subscribe(any)).thenAnswer( + (_) => RealtimeSubscription( + close: () async {}, + channels: ['databases.$userDatabaseID.tables.$friendsTableID.rows'], + controller: realtimeEvents, + ), + ); + }); + + tearDown(() => realtimeEvents.close()); + + Future buildContainer() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + messaging: messaging, + ); + + group('FriendsNotifier', () { + test('build partitions accepted and pending into separate lists', + () async { + stubUserDoc([ + _friendJson(accepted), + _friendJson(incoming), + _friendJson(outgoing), + ]); + final container = await buildContainer(); + + final state = await container.read(friendsProvider.future); + + expect(state.friends, hasLength(1)); + expect(state.friends.first.docId, 'doc-accepted'); + expect(state.friendRequests, hasLength(2)); + expect(state.friendRequests.map((r) => r.docId), + containsAll(['doc-incoming', 'doc-outgoing'])); + }); + + test('build skips malformed friend rows', () async { + stubUserDoc([ + _friendJson(accepted), + {'senderId': 'broken-row'}, + ]); + final container = await buildContainer(); + + final state = await container.read(friendsProvider.future); + + expect(state.friends, hasLength(1)); + expect(state.friendRequests, isEmpty); + }); + + test('sendFriendRequest creates a row and adds an outgoing request', + () async { + stubUserDoc([]); + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: invocation.namedArguments[#rowId] as String, + data: invocation.namedArguments[#data] as Map, + )); + final container = await buildContainer(); + await container.read(friendsProvider.future); + + await container.read(friendsProvider.notifier).sendFriendRequest( + recieverId: 'friend-9', + recieverProfileImageUrl: 'https://example.com/f9.jpg', + recieverUsername: 'friend9', + recieverName: 'Friend Nine', + recieverRating: 4.2, + ); + + final state = container.read(friendsProvider).value!; + expect(state.friendRequests, hasLength(1)); + final request = state.friendRequests.first; + expect(request.recieverName, 'Friend Nine'); + expect(request.senderFCMToken, 'my-fcm-token'); + expect(request.requestSentByUserId, 'me'); + expect(request.requestStatus, FriendRequestStatus.sent); + verify(tables.createRow( + databaseId: userDatabaseID, + tableId: friendsTableID, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + }); + + test('acceptFriendRequest moves the request into friends', () async { + stubUserDoc([_friendJson(accepted), _friendJson(incoming)]); + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: invocation.namedArguments[#rowId] as String, + data: invocation.namedArguments[#data] as Map, + )); + final container = await buildContainer(); + await container.read(friendsProvider.future); + + await container + .read(friendsProvider.notifier) + .acceptFriendRequest(incoming); + + final state = container.read(friendsProvider).value!; + expect(state.friends, hasLength(2)); + final acceptedNow = + state.friends.firstWhere((f) => f.docId == 'doc-incoming'); + expect(acceptedNow.requestStatus, FriendRequestStatus.accepted); + expect(acceptedNow.recieverFCMToken, 'my-fcm-token'); + expect(state.friendRequests, isEmpty); + }); + + test('declineFriendRequest deletes the row and removes the request only', + () async { + stubUserDoc([_friendJson(accepted), _friendJson(incoming)]); + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async {}); + final container = await buildContainer(); + await container.read(friendsProvider.future); + + await container + .read(friendsProvider.notifier) + .declineFriendRequest(incoming); + + final state = container.read(friendsProvider).value!; + expect(state.friends, hasLength(1)); + expect(state.friendRequests, isEmpty); + verify(tables.deleteRow( + databaseId: userDatabaseID, + tableId: friendsTableID, + rowId: 'doc-incoming', + )).called(1); + }); + + test('removeFriend deletes the row and removes the friend', () async { + stubUserDoc([_friendJson(accepted), _friendJson(incoming)]); + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async {}); + final container = await buildContainer(); + await container.read(friendsProvider.future); + + await container.read(friendsProvider.notifier).removeFriend(accepted); + + final state = container.read(friendsProvider).value!; + expect(state.friends, isEmpty); + expect(state.friendRequests, hasLength(1)); + }); + + test('realtime change involving us reloads the lists', () async { + stubUserDoc([_friendJson(accepted)]); + final container = await buildContainer(); + await container.read(friendsProvider.future); + expect(container.read(friendsProvider).value!.friendRequests, isEmpty); + + // Next reload returns an extra incoming request. + stubUserDoc([_friendJson(accepted), _friendJson(incoming)]); + realtimeEvents.add(RealtimeMessage( + events: [ + 'databases.$userDatabaseID.tables.$friendsTableID.rows.doc-incoming.create', + ], + payload: _friendJson(incoming), + channels: ['databases.$userDatabaseID.tables.$friendsTableID.rows'], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + final state = container.read(friendsProvider).value!; + expect(state.friendRequests, hasLength(1)); + }); + + test('realtime change for unrelated users is ignored', () async { + stubUserDoc([_friendJson(accepted)]); + final container = await buildContainer(); + await container.read(friendsProvider.future); + clearInteractions(tables); + + realtimeEvents.add(RealtimeMessage( + events: [ + 'databases.$userDatabaseID.tables.$friendsTableID.rows.other.create', + ], + payload: {'senderId': 'someone', 'recieverId': 'else'}, + channels: ['databases.$userDatabaseID.tables.$friendsTableID.rows'], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + verifyNever(tables.getRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + queries: anyNamed('queries'), + )); + }); + }); +} diff --git a/test/features/friends/viewmodel/pair_chat_notifier_test.dart b/test/features/friends/viewmodel/pair_chat_notifier_test.dart new file mode 100644 index 00000000..09472e1b --- /dev/null +++ b/test/features/friends/viewmodel/pair_chat_notifier_test.dart @@ -0,0 +1,412 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart' show RowList; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/friends/viewmodel/pair_chat_notifier.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/models/resonate_user.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late StreamController activePairEvents; + late StreamController pairRequestEvents; + + const activePairsChannel = + 'databases.$masterDatabaseId.tables.$activePairsTableId.rows'; + const pairRequestsChannel = + 'databases.$masterDatabaseId.tables.$pairRequestTableId.rows'; + + MockExecution joinExecution() { + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn( + '{"access_token":"token-1","livekit_socket_url":"ws://livekit:7880"}', + ); + return exec; + } + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + activePairEvents = StreamController.broadcast(); + pairRequestEvents = StreamController.broadcast(); + + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: 'request-doc-1', + data: invocation.namedArguments[#data] as Map, + )); + when(functions.createExecution( + functionId: anyNamed('functionId'), + body: anyNamed('body'), + )).thenAnswer((_) async => joinExecution()); + when(realtime.subscribe(any)).thenAnswer((invocation) { + final channels = + List.from(invocation.positionalArguments[0] as List); + return RealtimeSubscription( + close: () async {}, + channels: channels, + controller: channels.first == activePairsChannel + ? activePairEvents + : pairRequestEvents, + ); + }); + }); + + tearDown(() async { + await activePairEvents.close(); + await pairRequestEvents.close(); + }); + + Future buildContainer() => installTestRootContainer( + authState: AuthState.authenticated( + fakeAuthUser(uid: 'me', userName: 'MeUser'), + ), + tables: tables, + realtime: realtime, + functions: functions, + ); + + RealtimeMessage pairCreatedEvent({ + String docId = 'pair-doc-1', + String uid1 = 'me', + String uid2 = 'partner-1', + String? userName1 = 'MeUser', + String? userName2 = 'PartnerUser', + }) => + RealtimeMessage( + events: ['$activePairsChannel.$docId.create'], + payload: { + '\$id': docId, + 'uid1': uid1, + 'uid2': uid2, + 'userName1': userName1, + 'userName2': userName2, + }, + channels: [activePairsChannel], + timestamp: DateTime.now().toIso8601String(), + ); + + group('PairChatNotifier', () { + test('quickMatch creates a random request and stores its doc id', + () async { + final container = await buildContainer(); + + await container.read(pairChatProvider.notifier).quickMatch(); + + expect(container.read(pairChatProvider).requestDocId, 'request-doc-1'); + final data = verify(tables.createRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: anyNamed('rowId'), + data: captureAnyNamed('data'), + )).captured.single as Map; + expect(data['isRandom'], isTrue); + expect(data['isAnonymous'], isTrue); + expect(data['uid'], 'me'); + expect(data.containsKey('userName'), isFalse); + }); + + test('quickMatch includes the username when not anonymous', () async { + final container = await buildContainer(); + container.read(pairChatProvider.notifier).setAnonymous(false); + + await container.read(pairChatProvider.notifier).quickMatch(); + + final data = verify(tables.createRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: anyNamed('rowId'), + data: captureAnyNamed('data'), + )).captured.single as Map; + expect(data['userName'], 'MeUser'); + }); + + test('choosePartner creates a non-random request with profile data', + () async { + final container = await buildContainer(); + + await container.read(pairChatProvider.notifier).choosePartner(); + + expect(container.read(pairChatProvider).isAnonymous, isFalse); + final data = verify(tables.createRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: anyNamed('rowId'), + data: captureAnyNamed('data'), + )).captured.single as Map; + expect(data['isRandom'], isFalse); + expect(data['userName'], 'MeUser'); + expect(data['userRating'], 5.0); + }); + + test('a matching active pair joins LiveKit and stores partner info', + () async { + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'partner-1', + )).thenAnswer((_) async => buildRow( + id: 'partner-1', + data: {'profileImageUrl': 'https://example.com/p.jpg'}, + )); + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + + activePairEvents.add(pairCreatedEvent()); + await pumpEventQueue(); + + final state = container.read(pairChatProvider); + expect(state.activePairDocId, 'pair-doc-1'); + expect(state.pairUsername, 'PartnerUser'); + expect(state.pairProfileImageUrl, 'https://example.com/p.jpg'); + expect(container.read(liveKitProvider).isConnected, isTrue); + }); + + test('an active pair for other users is ignored', () async { + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + + activePairEvents.add( + pairCreatedEvent(uid1: 'someone', uid2: 'else'), + ); + await pumpEventQueue(); + + expect(container.read(pairChatProvider).activePairDocId, isNull); + expect(container.read(liveKitProvider).isConnected, isFalse); + }); + + test('a partner profile fetch failure still joins the chat', () async { + when(tables.getRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'partner-1', + )).thenThrow(AppwriteException('row_not_found', 404)); + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + + activePairEvents.add(pairCreatedEvent()); + await pumpEventQueue(); + + final state = container.read(pairChatProvider); + expect(state.activePairDocId, 'pair-doc-1'); + expect(state.pairProfileImageUrl, isNull); + expect(container.read(liveKitProvider).isConnected, isTrue); + }); + + test('remote deletion of the active pair ends the chat', () async { + when(tables.getRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async => buildRow(id: 'partner-1', data: {})); + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async {}); + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + activePairEvents.add(pairCreatedEvent()); + await pumpEventQueue(); + + activePairEvents.add(RealtimeMessage( + events: ['$activePairsChannel.pair-doc-1.delete'], + payload: { + '\$id': 'pair-doc-1', + 'uid1': 'me', + 'uid2': 'partner-1', + }, + channels: [activePairsChannel], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + final state = container.read(pairChatProvider); + expect(state.ended, isTrue); + expect(container.read(liveKitProvider).isConnected, isFalse); + }); + + test('loadUsers populates the online list and skips malformed rows', + () async { + when(tables.listRows( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 2, rows: [ + buildRow(id: 'req-1', data: { + 'uid': 'other-1', + 'userName': 'OtherUser', + 'name': 'Other Person', + 'profileImageUrl': 'https://example.com/o.jpg', + 'userRating': 4.5, + }), + buildRow(id: 'req-2', data: { + 'uid': 7, // malformed: uid must be a string + }), + ])); + final container = await buildContainer(); + + await container.read(pairChatProvider.notifier).loadUsers(); + + final state = container.read(pairChatProvider); + expect(state.isUserListLoading, isFalse); + expect(state.onlineUsers, hasLength(1)); + expect(state.onlineUsers.first.userName, 'OtherUser'); + expect(state.onlineUsers.first.docId, 'req-1'); + }); + + test('new pair requests stream into the online list, skipping our own', + () async { + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).choosePartner(); + + RealtimeMessage requestEvent(String uid, {bool anonymous = false}) => + RealtimeMessage( + events: ['$pairRequestsChannel.req-$uid.create'], + payload: { + 'uid': uid, + 'isAnonymous': anonymous, + 'userName': 'User-$uid', + 'name': 'Name $uid', + 'profileImageUrl': 'https://example.com/$uid.jpg', + 'userRating': 3.0, + }, + channels: [pairRequestsChannel], + timestamp: DateTime.now().toIso8601String(), + ); + + pairRequestEvents.add(requestEvent('other-1')); + pairRequestEvents.add(requestEvent('me')); // our own request + pairRequestEvents.add(requestEvent('anon-1', anonymous: true)); + await pumpEventQueue(); + + var state = container.read(pairChatProvider); + expect(state.onlineUsers.map((u) => u.uid), ['other-1']); + + pairRequestEvents.add(RealtimeMessage( + events: ['$pairRequestsChannel.req-other-1.delete'], + payload: {'uid': 'other-1'}, + channels: [pairRequestsChannel], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + state = container.read(pairChatProvider); + expect(state.onlineUsers, isEmpty); + }); + + test('pairWithSelectedUser creates the active pair row', () async { + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).choosePartner(); + clearInteractions(tables); + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: 'pair-doc-1', + data: invocation.namedArguments[#data] as Map, + )); + + await container.read(pairChatProvider.notifier).pairWithSelectedUser( + const ResonateUser( + uid: 'partner-1', + userName: 'PartnerUser', + docId: 'req-partner', + ), + ); + + final data = verify(tables.createRow( + databaseId: masterDatabaseId, + tableId: activePairsTableId, + rowId: anyNamed('rowId'), + data: captureAnyNamed('data'), + )).captured.single as Map; + expect(data['uid1'], 'me'); + expect(data['uid2'], 'partner-1'); + expect(data['userName2'], 'PartnerUser'); + }); + + test('cancelRequest deletes the pending request', () async { + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async {}); + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + + await container.read(pairChatProvider.notifier).cancelRequest(); + + expect(container.read(pairChatProvider).requestDocId, isNull); + verify(tables.deleteRow( + databaseId: masterDatabaseId, + tableId: pairRequestTableId, + rowId: 'request-doc-1', + )).called(1); + }); + + test('endChat tolerates the pair row already being deleted', () async { + when(tables.getRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async => buildRow(id: 'partner-1', data: {})); + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenThrow(AppwriteException('not found', 404, 'document_not_found')); + final container = await buildContainer(); + await container.read(pairChatProvider.notifier).quickMatch(); + activePairEvents.add(pairCreatedEvent()); + await pumpEventQueue(); + + await container.read(pairChatProvider.notifier).endChat(); + + expect(container.read(pairChatProvider).ended, isTrue); + }); + + test('submitRating updates the user rating row', () async { + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((invocation) async => buildRow( + id: 'me', + data: invocation.namedArguments[#data] as Map, + )); + final container = await buildContainer(); + container.read(pairChatProvider.notifier).setPairRating(4.0); + + await container.read(pairChatProvider.notifier).submitRating(); + + final data = verify(tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: 'me', + data: captureAnyNamed('data'), + )).captured.single as Map; + // fakeAuthUser defaults: ratingTotal 5, ratingCount 1. + expect(data['ratingTotal'], 9.0); + expect(data['ratingCount'], 2); + }); + }); +} diff --git a/test/features/rooms/data/livekit_join_test.dart b/test/features/rooms/data/livekit_join_test.dart new file mode 100644 index 00000000..bf7d4d8a --- /dev/null +++ b/test/features/rooms/data/livekit_join_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/rooms/data/livekit_join.dart'; +import 'package:resonate/utils/constants.dart'; + +void main() { + group('liveKitJoinFromResponse', () { + test('rewrites the docker-internal socket URL to the local endpoint', () { + final result = liveKitJoinFromResponse({ + 'access_token': 'token-1', + 'livekit_socket_url': 'wss://host.docker.internal:7880', + }); + + expect(result.liveKitUri, localhostLivekitEndpoint); + expect(result.roomToken, 'token-1'); + }); + + test('passes other socket URLs through unchanged', () { + final result = liveKitJoinFromResponse({ + 'access_token': 'token-2', + 'livekit_socket_url': 'wss://livekit.example.com:443', + }); + + expect(result.liveKitUri, 'wss://livekit.example.com:443'); + expect(result.roomToken, 'token-2'); + }); + }); +} diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index f6e0dd90..cf77ccd0 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -11,9 +11,12 @@ import 'package:resonate/core/providers/appwrite_providers.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/core/providers/get_storage_provider.dart'; import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/callkit_service.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; +import 'package:resonate/features/friends/model/friends_model.dart'; +import 'package:resonate/utils/enums/friend_request_status.dart'; import 'package:resonate/features/rooms/model/appwrite_room.dart'; import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; import 'package:resonate/features/rooms/model/livekit_state.dart'; @@ -133,6 +136,41 @@ Participant fakeParticipant({ hasRequestedToBeSpeaker: hasRequestedToBeSpeaker, ); +FriendsModel fakeFriendsModel({ + String senderId = 'sender-1', + String recieverId = 'reciever-1', + String senderName = 'Sender', + String recieverName = 'Reciever', + String senderUsername = 'sender', + String recieverUsername = 'reciever', + String senderProfileImgUrl = 'https://example.com/s.jpg', + String recieverProfileImgUrl = 'https://example.com/r.jpg', + String? senderFCMToken = 'sender-token', + String? recieverFCMToken = 'reciever-token', + FriendRequestStatus requestStatus = FriendRequestStatus.sent, + String? requestSentByUserId, + double? senderRating = 4.0, + double? recieverRating = 3.5, + String docId = 'friend-doc-1', +}) => + FriendsModel( + senderId: senderId, + recieverId: recieverId, + senderName: senderName, + recieverName: recieverName, + senderUsername: senderUsername, + recieverUsername: recieverUsername, + senderProfileImgUrl: senderProfileImgUrl, + recieverProfileImgUrl: recieverProfileImgUrl, + senderFCMToken: senderFCMToken, + recieverFCMToken: recieverFCMToken, + requestStatus: requestStatus, + requestSentByUserId: requestSentByUserId ?? senderId, + senderRating: senderRating, + recieverRating: recieverRating, + docId: docId, + ); + /// Stubs the `flutter_secure_storage` method channel so writes/reads succeed /// in unit tests (the plugin normally calls into native code). Call from a /// `setUp()` in any test where the code path touches secure storage. @@ -222,12 +260,40 @@ class FakeLiveKitNotifier extends LiveKitNotifier { @override Future setMicrophoneEnabled(bool enabled) async {} + @override + Future setSpeakerphoneOn(bool enabled) async {} + @override Future setRecording(bool recording) async { state = state.copyWith(isRecording: recording); } } +/// Stub [CallKitService] that never touches the CallKit plugin channels. +class FakeCallKitService extends CallKitService { + int showIncomingCallCount = 0; + int endAllCallsCount = 0; + + @override + void start({ + required Future Function(Map extra) onAccept, + required Future Function(Map extra) onDecline, + }) {} + + @override + Future stop() async {} + + @override + Future showIncomingCall(RemoteMessage message) async { + showIncomingCallCount++; + } + + @override + Future endAllCalls() async { + endAllCallsCount++; + } +} + /// Stub auth notifier — used by tests that just need an auth context but /// don't want to wire up the full Appwrite SDK to make `loadCurrentUser()` /// return what they want. @@ -321,6 +387,7 @@ Future installTestRootContainer({ FirebaseMessaging? messaging, FakeAuthRepository? authRepository, GetStorage? getStorageBox, + CallKitService? callKit, }) async { final container = ProviderContainer( overrides: [ @@ -331,6 +398,7 @@ Future installTestRootContainer({ if (getStorageBox != null) getStorageBoxProvider.overrideWithValue(getStorageBox), liveKitProvider.overrideWith(FakeLiveKitNotifier.new), + callKitServiceProvider.overrideWithValue(callKit ?? FakeCallKitService()), if (account != null) appwriteAccountProvider.overrideWithValue(account), if (tables != null) appwriteTablesProvider.overrideWithValue(tables), if (client != null) appwriteClientProvider.overrideWithValue(client), From e40b06ea9a2617f9a65a86f5ad5883e771687c44 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:37:17 +0530 Subject: [PATCH 06/12] fix: fixed broken workflow --- android/app/src/main/AndroidManifest.xml | 2 +- .../chapter_player_controller.dart | 12 +-- lib/controllers/explore_story_controller.dart | 13 ++- lib/views/screens/chapter_play_screen.dart | 97 +++++++------------ lib/views/screens/create_story_screen.dart | 23 +++-- lib/views/widgets/chapter_player.dart | 4 +- packages/flutter_webrtc | 1 + pubspec.yaml | 7 +- .../chapter_player_controller_test.dart | 9 +- 9 files changed, 74 insertions(+), 94 deletions(-) create mode 160000 packages/flutter_webrtc diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 734fa319..6235a76d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,7 @@ android:name=".MainActivity" android:exported="true" - android:launchMode="singleInstance" + android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/lib/controllers/chapter_player_controller.dart b/lib/controllers/chapter_player_controller.dart index 7a684086..ab8c1da0 100644 --- a/lib/controllers/chapter_player_controller.dart +++ b/lib/controllers/chapter_player_controller.dart @@ -1,29 +1,28 @@ import 'package:get/get.dart'; import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter_lyric/lyrics_reader_model.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; class ChapterPlayerController extends GetxController { Rx currentPage = 0.obs; - Rx lyricProgress = 0.obs; Rx sliderProgress = 0.0.obs; Rx isPlaying = false.obs; AudioPlayer? audioPlayer; late Duration chapterDuration; - late LyricsReaderModel lyricModel; + final LyricController lyricController = LyricController(); void initialize( AudioPlayer player, - LyricsReaderModel model, + String lyrics, Duration duration, ) { audioPlayer = player; - lyricModel = model; chapterDuration = duration; + lyricController.loadLyric(lyrics); audioPlayer?.setReleaseMode(ReleaseMode.stop); audioPlayer?.onPositionChanged.listen((Duration event) { sliderProgress.value = event.inMilliseconds.toDouble(); - lyricProgress.value = event.inMilliseconds; + lyricController.setProgress(event); }); audioPlayer?.onPlayerStateChanged.listen((PlayerState state) { @@ -43,6 +42,7 @@ class ChapterPlayerController extends GetxController { @override void onClose() { audioPlayer?.release(); + lyricController.dispose(); super.onClose(); } } diff --git a/lib/controllers/explore_story_controller.dart b/lib/controllers/explore_story_controller.dart index b2029a8c..63bb700b 100644 --- a/lib/controllers/explore_story_controller.dart +++ b/lib/controllers/explore_story_controller.dart @@ -326,6 +326,13 @@ class ExploreStoryController extends GetxController { log( "failed to upload $fileIdentificationForError to appwrite: ${e.message}", ); + Get.snackbar( + "Upload failed", + "Could not upload $fileIdentificationForError: ${e.message}", + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 6), + ); + rethrow; } return "$appwriteEndpoint/storage/buckets/$bucketId/files/$fileId/view?project=$appwriteProjectId"; @@ -401,11 +408,7 @@ class ExploreStoryController extends GetxController { primaryColor = const Color(0xffcbc6c6); } - try { - await pushChaptersToStory(chapters, storyId); - } on AppwriteException catch (e) { - log("failed to push chapters to appwrite: ${e.message}"); - } + await pushChaptersToStory(chapters, storyId); String colorString = primaryColor.toHex(leadingHashSign: false); diff --git a/lib/views/screens/chapter_play_screen.dart b/lib/views/screens/chapter_play_screen.dart index 02868ebe..0f35c013 100644 --- a/lib/views/screens/chapter_play_screen.dart +++ b/lib/views/screens/chapter_play_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:flutter_lyric/lyrics_reader.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; import 'package:get/get.dart'; import 'package:resonate/controllers/chapter_player_controller.dart'; import 'package:resonate/models/chapter.dart'; @@ -17,28 +17,30 @@ class ChapterPlayScreen extends StatefulWidget { } class _ChapterPlayScreenState extends State { - late UINetease lyricUI; + late LyricStyle lyricStyle; final ChapterPlayerController controller = Get.find(); @override void didChangeDependencies() { super.didChangeDependencies(); bool themeIsDark = Theme.of(context).brightness == Brightness.dark; - lyricUI = UINetease( - highlightColor: themeIsDark ? Colors.white : Colors.black, - playingMainTextStyle: TextStyle( + final Color mainTextColor = themeIsDark + ? const Color.fromARGB(255, 223, 222, 222) + : Colors.grey[600]!; + lyricStyle = LyricStyles.default1.copyWith( + textStyle: TextStyle( + fontSize: UiSizes.size_18, + color: mainTextColor, + ), + activeStyle: TextStyle( fontSize: UiSizes.size_20, fontWeight: FontWeight.bold, - color: themeIsDark - ? const Color.fromARGB(255, 223, 222, 222) - : Colors.grey[600], - ), - otherMainTextStyle: TextStyle( - fontSize: UiSizes.size_18, - color: themeIsDark - ? const Color.fromARGB(255, 223, 222, 222) - : Colors.grey[600], + color: mainTextColor, ), + activeHighlightColor: themeIsDark ? Colors.white : Colors.black, + selectedColor: themeIsDark ? Colors.white : Colors.black, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + anchorPosition: 0.5, ); } @@ -48,9 +50,7 @@ class _ChapterPlayScreenState extends State { controller.initialize( AudioPlayer()..setSourceUrl(widget.chapter.audioFileUrl), - LyricsModelBuilder.create() - .bindLyricToMain(widget.chapter.lyrics) - .getModel(), + widget.chapter.lyrics, Duration(milliseconds: widget.chapter.playDuration), ); } @@ -111,56 +111,31 @@ class _ChapterPlayScreenState extends State { : const Color.fromARGB(193, 232, 230, 230), borderRadius: BorderRadius.circular(10), ), - child: Obx( - () => LyricsReader( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - model: controller.lyricModel, - position: controller.lyricProgress.value, - lyricUi: lyricUI, - playing: controller.isPlaying.value, - size: const Size(double.infinity, 200), - emptyBuilder: () => Center( - child: Text( - AppLocalizations.of(context)!.noLyrics, - style: UINetease().getOtherMainTextStyle(), - ), - ), - selectLineBuilder: (progress, confirm) { - return Row( + child: widget.chapter.lyrics.trim().isEmpty + ? Center( + child: Text( + AppLocalizations.of(context)!.noLyrics, + style: lyricStyle.textStyle, + ), + ) + : Stack( children: [ - IconButton( - onPressed: () { - confirm.call(); - + LyricView( + controller: controller.lyricController, + style: lyricStyle, + height: 200, + ), + LyricSelectionProgress( + controller: controller.lyricController, + style: lyricStyle, + onPlay: (state) { controller.audioPlayer?.seek( - Duration(milliseconds: progress), + state.duration, ); }, - icon: Icon( - Icons.play_arrow, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary, - ), - height: 1, - width: double.infinity, - ), ), ], - ); - }, - ), - ), + ), ), ), diff --git a/lib/views/screens/create_story_screen.dart b/lib/views/screens/create_story_screen.dart index ada25ef7..a6575969 100644 --- a/lib/views/screens/create_story_screen.dart +++ b/lib/views/screens/create_story_screen.dart @@ -53,16 +53,19 @@ class CreateStoryPageState extends State { int totalPlayDuration = chapters.fold(0, (sum, chapter) { return sum + chapter.playDuration; }); - - // Create a new story instance - await exploreStoryController.createStory( - titleController.text, - aboutController.text, - selectedCategory, - coverImage?.path ?? storyCoverImagePlaceholderUrl, - totalPlayDuration, - chapters, - ); + try { + await exploreStoryController.createStory( + titleController.text, + aboutController.text, + selectedCategory, + coverImage?.path ?? storyCoverImagePlaceholderUrl, + totalPlayDuration, + chapters, + ); + } catch (e) { + log('Story not posted: $e'); + return; + } Navigator.pushNamed(Get.context!, AppRoutes.tabview); diff --git a/lib/views/widgets/chapter_player.dart b/lib/views/widgets/chapter_player.dart index 3c7fd93d..c6aa029b 100644 --- a/lib/views/widgets/chapter_player.dart +++ b/lib/views/widgets/chapter_player.dart @@ -97,7 +97,9 @@ class ChapterPlayer extends StatelessWidget { controller.sliderProgress.value = value; }, onChangeEnd: (double value) { - controller.lyricProgress.value = value.toInt(); + controller.lyricController.setProgress( + Duration(milliseconds: value.toInt()), + ); controller.audioPlayer?.seek( Duration(milliseconds: value.toInt()), diff --git a/packages/flutter_webrtc b/packages/flutter_webrtc new file mode 160000 index 00000000..f7b518c0 --- /dev/null +++ b/packages/flutter_webrtc @@ -0,0 +1 @@ +Subproject commit f7b518c0fee1e4d65700f55d45a6e3ebae7b220e diff --git a/pubspec.yaml b/pubspec.yaml index 978d8920..86f64bd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,10 +78,7 @@ dependencies: git: url: https://github.com/lionelmennig/textfield_tags.git ref: fixes/allow-controller-re-registration - flutter_lyric: - git: - url: https://github.com/Aarush-Acharya/flutter_lyric.git - ref: master + flutter_lyric: ^3.0.6 url_launcher: ^6.3.2 uuid: ^4.5.2 audio_metadata_reader: ^1.4.2 @@ -97,7 +94,7 @@ dependencies: whisper_flutter_new: ^1.0.1 ffmpeg_kit_flutter_new: ^4.1.0 -dependency_overrides: +dependency_overrides: flutter_web_auth_2: ^5.0.0-alpha.3 dev_dependencies: diff --git a/test/controllers/chapter_player_controller_test.dart b/test/controllers/chapter_player_controller_test.dart index 71cc8710..78a177a4 100644 --- a/test/controllers/chapter_player_controller_test.dart +++ b/test/controllers/chapter_player_controller_test.dart @@ -1,6 +1,6 @@ import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_lyric/lyrics_reader_model.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:resonate/controllers/chapter_player_controller.dart'; @@ -9,7 +9,6 @@ void main() { ChapterPlayerController chapterPlayerController = ChapterPlayerController(); test('check initial values', () { expect(chapterPlayerController.currentPage.value, 0.0); - expect(chapterPlayerController.lyricProgress.value, 0.0); expect(chapterPlayerController.sliderProgress.value, 0.0); expect(chapterPlayerController.isPlaying.value, false); }); @@ -19,11 +18,11 @@ void main() { await tester.pumpAndSettle(); chapterPlayerController.initialize( AudioPlayer(), - LyricsReaderModel(), + '', Duration(minutes: 3), ); - expect(chapterPlayerController.lyricModel, isA()); + expect(chapterPlayerController.lyricController, isA()); expect(chapterPlayerController.audioPlayer, isA()); expect(chapterPlayerController.audioPlayer?.releaseMode, ReleaseMode.stop); expect(chapterPlayerController.chapterDuration.inMinutes, 3); @@ -34,7 +33,7 @@ void main() { await tester.pumpAndSettle(); chapterPlayerController.initialize( AudioPlayer(), - LyricsReaderModel(), + '', Duration(minutes: 3), ); From db170f8bdc77b456c537484eeb63de879cfd140d Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:38:03 +0530 Subject: [PATCH 07/12] fix: added left localizations & subfolders for repo and service in data --- lib/controllers/tabview_controller.dart | 2 +- lib/core/container.dart | 2 +- .../{ => repositories}/auth_repository.dart | 0 .../generated/auth_repository.g.dart | 0 .../data/{ => services}/callkit_service.dart | 0 .../{ => services}/notification_service.dart | 0 .../viewmodel/app_bootstrap_notifier.dart | 4 +- .../auth/viewmodel/auth_notifier.dart | 2 +- .../auth/viewmodel/email_verify_notifier.dart | 2 +- .../viewmodel/forgot_password_notifier.dart | 2 +- .../viewmodel/reset_password_notifier.dart | 2 +- .../generated/profile_repository.g.dart | 0 .../profile_repository.dart | 0 .../viewmodel/change_email_notifier.dart | 2 +- .../viewmodel/edit_profile_notifier.dart | 2 +- .../viewmodel/onboarding_notifier.dart | 2 +- .../viewmodel/profile_view_notifier.dart | 2 +- .../generated/room_chat_repository.g.dart | 0 .../generated/rooms_repository.g.dart | 0 .../upcoming_rooms_repository.g.dart | 0 .../room_chat_repository.dart | 0 .../{ => repositories}/rooms_repository.dart | 16 +- .../upcoming_rooms_repository.dart | 0 .../{ => services}/audio_device_service.dart | 0 .../data/{ => services}/livekit_session.dart | 0 .../generated/single_room_state.freezed.dart | 39 +- .../rooms/model/single_room_state.dart | 1 + .../rooms/view/pages/create_room_page.dart | 69 +- .../rooms/view/pages/room_chat_page.dart | 35 +- lib/features/rooms/view/pages/room_page.dart | 11 + .../rooms/view/widgets/participant_block.dart | 12 +- .../viewmodel/audio_device_notifier.dart | 2 +- .../rooms/viewmodel/create_room_notifier.dart | 18 +- .../generated/create_room_notifier.g.dart | 2 +- .../generated/room_chat_notifier.g.dart | 2 +- .../viewmodel/generated/rooms_notifier.g.dart | 2 +- .../generated/single_room_notifier.g.dart | 2 +- .../rooms/viewmodel/livekit_notifier.dart | 2 +- .../rooms/viewmodel/room_chat_notifier.dart | 14 +- .../rooms/viewmodel/rooms_notifier.dart | 14 +- .../rooms/viewmodel/single_room_notifier.dart | 119 +- .../viewmodel/upcoming_rooms_notifier.dart | 2 +- lib/l10n/app_en.arb | 3725 +++++++++-------- lib/l10n/app_hi.arb | 3701 ++++++++-------- lib/l10n/app_localizations.dart | 48 + lib/l10n/app_localizations_bn.dart | 25 + lib/l10n/app_localizations_en.dart | 25 + lib/l10n/app_localizations_gu.dart | 25 + lib/l10n/app_localizations_hi.dart | 25 + lib/l10n/app_localizations_kn.dart | 25 + lib/l10n/app_localizations_ml.dart | 25 + lib/l10n/app_localizations_mr.dart | 25 + lib/l10n/app_localizations_pa.dart | 25 + lib/l10n/app_localizations_raj.dart | 25 + lib/l10n/app_localizations_ta.dart | 25 + lib/l10n/app_pa.arb | 3680 ++++++++-------- pubspec.lock | 11 +- .../auth/data/auth_repository_test.dart | 2 +- .../features/auth/view/auth_test_helpers.dart | 2 +- .../viewmodel/email_verify_notifier_test.dart | 2 +- .../reset_password_notifier_test.dart | 2 +- .../profile/data/profile_repository_test.dart | 2 +- .../profile/fake_profile_repository.dart | 2 +- .../profile/view/profile_test_helpers.dart | 4 +- .../viewmodel/change_email_notifier_test.dart | 4 +- .../viewmodel/edit_profile_notifier_test.dart | 4 +- .../viewmodel/onboarding_notifier_test.dart | 4 +- .../viewmodel/profile_view_notifier_test.dart | 4 +- .../rooms/data/rooms_repository_test.dart | 2 +- test/helpers/test_root_container.dart | 2 +- untranslated.txt | 62 + 71 files changed, 6185 insertions(+), 5714 deletions(-) rename lib/features/auth/data/{ => repositories}/auth_repository.dart (100%) rename lib/features/auth/data/{ => repositories}/generated/auth_repository.g.dart (100%) rename lib/features/auth/data/{ => services}/callkit_service.dart (100%) rename lib/features/auth/data/{ => services}/notification_service.dart (100%) rename lib/features/profile/data/{ => repositories}/generated/profile_repository.g.dart (100%) rename lib/features/profile/data/{ => repositories}/profile_repository.dart (100%) rename lib/features/rooms/data/{ => repositories}/generated/room_chat_repository.g.dart (100%) rename lib/features/rooms/data/{ => repositories}/generated/rooms_repository.g.dart (100%) rename lib/features/rooms/data/{ => repositories}/generated/upcoming_rooms_repository.g.dart (100%) rename lib/features/rooms/data/{ => repositories}/room_chat_repository.dart (100%) rename lib/features/rooms/data/{ => repositories}/rooms_repository.dart (96%) rename lib/features/rooms/data/{ => repositories}/upcoming_rooms_repository.dart (100%) rename lib/features/rooms/data/{ => services}/audio_device_service.dart (100%) rename lib/features/rooms/data/{ => services}/livekit_session.dart (100%) diff --git a/lib/controllers/tabview_controller.dart b/lib/controllers/tabview_controller.dart index ec1a4ef9..b7173b8b 100644 --- a/lib/controllers/tabview_controller.dart +++ b/lib/controllers/tabview_controller.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/rooms_repository.dart'; import 'package:resonate/features/rooms/view/widgets/live_room_tile.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/routes/app_router.dart'; diff --git a/lib/core/container.dart b/lib/core/container.dart index 7c6c04c6..9ee77f3d 100644 --- a/lib/core/container.dart +++ b/lib/core/container.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/rooms/data/livekit_session.dart'; +import 'package:resonate/features/rooms/data/services/livekit_session.dart'; import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; // Single root ProviderContainer for the app. diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/repositories/auth_repository.dart similarity index 100% rename from lib/features/auth/data/auth_repository.dart rename to lib/features/auth/data/repositories/auth_repository.dart diff --git a/lib/features/auth/data/generated/auth_repository.g.dart b/lib/features/auth/data/repositories/generated/auth_repository.g.dart similarity index 100% rename from lib/features/auth/data/generated/auth_repository.g.dart rename to lib/features/auth/data/repositories/generated/auth_repository.g.dart diff --git a/lib/features/auth/data/callkit_service.dart b/lib/features/auth/data/services/callkit_service.dart similarity index 100% rename from lib/features/auth/data/callkit_service.dart rename to lib/features/auth/data/services/callkit_service.dart diff --git a/lib/features/auth/data/notification_service.dart b/lib/features/auth/data/services/notification_service.dart similarity index 100% rename from lib/features/auth/data/notification_service.dart rename to lib/features/auth/data/services/notification_service.dart diff --git a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart index 2b38e784..f8f875d6 100644 --- a/lib/features/auth/viewmodel/app_bootstrap_notifier.dart +++ b/lib/features/auth/viewmodel/app_bootstrap_notifier.dart @@ -7,8 +7,8 @@ import 'package:get/get.dart'; import 'package:resonate/controllers/friend_calling_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; -import 'package:resonate/features/auth/data/callkit_service.dart'; -import 'package:resonate/features/auth/data/notification_service.dart'; +import 'package:resonate/features/auth/data/services/callkit_service.dart'; +import 'package:resonate/features/auth/data/services/notification_service.dart'; import 'package:resonate/routes/app_router.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/auth/viewmodel/auth_notifier.dart b/lib/features/auth/viewmodel/auth_notifier.dart index 77593a71..40f2fecf 100644 --- a/lib/features/auth/viewmodel/auth_notifier.dart +++ b/lib/features/auth/viewmodel/auth_notifier.dart @@ -1,6 +1,6 @@ import 'dart:developer' as developer; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/auth/viewmodel/email_verify_notifier.dart b/lib/features/auth/viewmodel/email_verify_notifier.dart index 99c0d1ec..2e0207c5 100644 --- a/lib/features/auth/viewmodel/email_verify_notifier.dart +++ b/lib/features/auth/viewmodel/email_verify_notifier.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/auth/viewmodel/forgot_password_notifier.dart b/lib/features/auth/viewmodel/forgot_password_notifier.dart index c6873b8b..89500c77 100644 --- a/lib/features/auth/viewmodel/forgot_password_notifier.dart +++ b/lib/features/auth/viewmodel/forgot_password_notifier.dart @@ -1,4 +1,4 @@ -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/forgot_password_notifier.g.dart'; diff --git a/lib/features/auth/viewmodel/reset_password_notifier.dart b/lib/features/auth/viewmodel/reset_password_notifier.dart index 696baef0..0988b1cb 100644 --- a/lib/features/auth/viewmodel/reset_password_notifier.dart +++ b/lib/features/auth/viewmodel/reset_password_notifier.dart @@ -1,4 +1,4 @@ -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/reset_password_notifier.g.dart'; diff --git a/lib/features/profile/data/generated/profile_repository.g.dart b/lib/features/profile/data/repositories/generated/profile_repository.g.dart similarity index 100% rename from lib/features/profile/data/generated/profile_repository.g.dart rename to lib/features/profile/data/repositories/generated/profile_repository.g.dart diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/repositories/profile_repository.dart similarity index 100% rename from lib/features/profile/data/profile_repository.dart rename to lib/features/profile/data/repositories/profile_repository.dart diff --git a/lib/features/profile/viewmodel/change_email_notifier.dart b/lib/features/profile/viewmodel/change_email_notifier.dart index 34592f1a..9ecc8769 100644 --- a/lib/features/profile/viewmodel/change_email_notifier.dart +++ b/lib/features/profile/viewmodel/change_email_notifier.dart @@ -1,5 +1,5 @@ import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/change_email_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/profile/viewmodel/edit_profile_notifier.dart b/lib/features/profile/viewmodel/edit_profile_notifier.dart index 8a960361..b86db747 100644 --- a/lib/features/profile/viewmodel/edit_profile_notifier.dart +++ b/lib/features/profile/viewmodel/edit_profile_notifier.dart @@ -1,5 +1,5 @@ import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/edit_profile_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/profile/viewmodel/onboarding_notifier.dart b/lib/features/profile/viewmodel/onboarding_notifier.dart index 4ba5c25f..4bcf6a79 100644 --- a/lib/features/profile/viewmodel/onboarding_notifier.dart +++ b/lib/features/profile/viewmodel/onboarding_notifier.dart @@ -1,5 +1,5 @@ import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/onboarding_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/profile/viewmodel/profile_view_notifier.dart b/lib/features/profile/viewmodel/profile_view_notifier.dart index a6a68c59..cecc7b2b 100644 --- a/lib/features/profile/viewmodel/profile_view_notifier.dart +++ b/lib/features/profile/viewmodel/profile_view_notifier.dart @@ -1,6 +1,6 @@ import 'package:appwrite/appwrite.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/profile_view_data.dart'; import 'package:resonate/models/follower_user_model.dart'; import 'package:resonate/models/story.dart'; diff --git a/lib/features/rooms/data/generated/room_chat_repository.g.dart b/lib/features/rooms/data/repositories/generated/room_chat_repository.g.dart similarity index 100% rename from lib/features/rooms/data/generated/room_chat_repository.g.dart rename to lib/features/rooms/data/repositories/generated/room_chat_repository.g.dart diff --git a/lib/features/rooms/data/generated/rooms_repository.g.dart b/lib/features/rooms/data/repositories/generated/rooms_repository.g.dart similarity index 100% rename from lib/features/rooms/data/generated/rooms_repository.g.dart rename to lib/features/rooms/data/repositories/generated/rooms_repository.g.dart diff --git a/lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart b/lib/features/rooms/data/repositories/generated/upcoming_rooms_repository.g.dart similarity index 100% rename from lib/features/rooms/data/generated/upcoming_rooms_repository.g.dart rename to lib/features/rooms/data/repositories/generated/upcoming_rooms_repository.g.dart diff --git a/lib/features/rooms/data/room_chat_repository.dart b/lib/features/rooms/data/repositories/room_chat_repository.dart similarity index 100% rename from lib/features/rooms/data/room_chat_repository.dart rename to lib/features/rooms/data/repositories/room_chat_repository.dart diff --git a/lib/features/rooms/data/rooms_repository.dart b/lib/features/rooms/data/repositories/rooms_repository.dart similarity index 96% rename from lib/features/rooms/data/rooms_repository.dart rename to lib/features/rooms/data/repositories/rooms_repository.dart index 5a045e7d..5849d499 100644 --- a/lib/features/rooms/data/rooms_repository.dart +++ b/lib/features/rooms/data/repositories/rooms_repository.dart @@ -53,7 +53,7 @@ class RoomsRepository { rooms.add(room); } } catch (_) { - // Skip rows that fail to hydrate (missing/malformed fields). + // Skiping rows that have missing/malformed fields. } } return rooms; @@ -91,7 +91,7 @@ class RoomsRepository { final url = userDoc.data['profileImageUrl']; if (url is String) memberAvatarUrls.add(url); } catch (_) { - // Skip avatars we can't fetch — the room can still render. + // Skiping avatars we can't fetch. } } @@ -213,7 +213,9 @@ class RoomsRepository { rowId: roomId, ); final newCount = - (roomDoc.data['totalParticipants'] as int) - existing.rows.length + 1; + ((roomDoc.data['totalParticipants'] as num?)?.toInt() ?? 0) - + existing.rows.length + + 1; await _tables.updateRow( databaseId: masterDatabaseId, tableId: roomsTableId, @@ -250,7 +252,8 @@ class RoomsRepository { } final remaining = - (roomDoc.data['totalParticipants'] as int) - participantDocs.rows.length; + ((roomDoc.data['totalParticipants'] as num?)?.toInt() ?? 0) - + participantDocs.rows.length; if (remaining == 0) { await _tables.deleteRow( databaseId: masterDatabaseId, @@ -310,7 +313,7 @@ class RoomsRepository { try { participants.add(await buildParticipantFromRow(row)); } catch (_) { - // Skip participants whose user record is missing/malformed. + // Skiping rows that have missing/malformed fields. } } return participants; @@ -354,7 +357,7 @@ class RoomsRepository { return controller.stream; } - Future getParticipantDocId({ + Future getParticipantDocId({ required String roomId, required String participantUid, }) async { @@ -366,6 +369,7 @@ class RoomsRepository { Query.equal('uid', participantUid), ], ); + if (docs.rows.isEmpty) return null; return docs.rows.first.$id; } diff --git a/lib/features/rooms/data/upcoming_rooms_repository.dart b/lib/features/rooms/data/repositories/upcoming_rooms_repository.dart similarity index 100% rename from lib/features/rooms/data/upcoming_rooms_repository.dart rename to lib/features/rooms/data/repositories/upcoming_rooms_repository.dart diff --git a/lib/features/rooms/data/audio_device_service.dart b/lib/features/rooms/data/services/audio_device_service.dart similarity index 100% rename from lib/features/rooms/data/audio_device_service.dart rename to lib/features/rooms/data/services/audio_device_service.dart diff --git a/lib/features/rooms/data/livekit_session.dart b/lib/features/rooms/data/services/livekit_session.dart similarity index 100% rename from lib/features/rooms/data/livekit_session.dart rename to lib/features/rooms/data/services/livekit_session.dart diff --git a/lib/features/rooms/model/generated/single_room_state.freezed.dart b/lib/features/rooms/model/generated/single_room_state.freezed.dart index 4ba42e9d..8ba8530b 100644 --- a/lib/features/rooms/model/generated/single_room_state.freezed.dart +++ b/lib/features/rooms/model/generated/single_room_state.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SingleRoomState { - Participant get me; List get participants; bool get isLoading; + Participant get me; List get participants; bool get isLoading; bool get wasKicked; /// Create a copy of SingleRoomState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $SingleRoomStateCopyWith get copyWith => _$SingleRoomStateCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other.participants, participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other.participants, participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.wasKicked, wasKicked) || other.wasKicked == wasKicked)); } @override -int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(participants),isLoading); +int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(participants),isLoading,wasKicked); @override String toString() { - return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading)'; + return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading, wasKicked: $wasKicked)'; } @@ -45,7 +45,7 @@ abstract mixin class $SingleRoomStateCopyWith<$Res> { factory $SingleRoomStateCopyWith(SingleRoomState value, $Res Function(SingleRoomState) _then) = _$SingleRoomStateCopyWithImpl; @useResult $Res call({ - Participant me, List participants, bool isLoading + Participant me, List participants, bool isLoading, bool wasKicked }); @@ -62,11 +62,12 @@ class _$SingleRoomStateCopyWithImpl<$Res> /// Create a copy of SingleRoomState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,Object? wasKicked = null,}) { return _then(_self.copyWith( me: null == me ? _self.me : me // ignore: cast_nullable_to_non_nullable as Participant,participants: null == participants ? _self.participants : participants // ignore: cast_nullable_to_non_nullable as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,wasKicked: null == wasKicked ? _self.wasKicked : wasKicked // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -161,10 +162,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Participant me, List participants, bool isLoading)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Participant me, List participants, bool isLoading, bool wasKicked)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SingleRoomState() when $default != null: -return $default(_that.me,_that.participants,_that.isLoading);case _: +return $default(_that.me,_that.participants,_that.isLoading,_that.wasKicked);case _: return orElse(); } @@ -182,10 +183,10 @@ return $default(_that.me,_that.participants,_that.isLoading);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Participant me, List participants, bool isLoading) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Participant me, List participants, bool isLoading, bool wasKicked) $default,) {final _that = this; switch (_that) { case _SingleRoomState(): -return $default(_that.me,_that.participants,_that.isLoading);case _: +return $default(_that.me,_that.participants,_that.isLoading,_that.wasKicked);case _: throw StateError('Unexpected subclass'); } @@ -202,10 +203,10 @@ return $default(_that.me,_that.participants,_that.isLoading);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Participant me, List participants, bool isLoading)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Participant me, List participants, bool isLoading, bool wasKicked)? $default,) {final _that = this; switch (_that) { case _SingleRoomState() when $default != null: -return $default(_that.me,_that.participants,_that.isLoading);case _: +return $default(_that.me,_that.participants,_that.isLoading,_that.wasKicked);case _: return null; } @@ -217,7 +218,7 @@ return $default(_that.me,_that.participants,_that.isLoading);case _: class _SingleRoomState implements SingleRoomState { - const _SingleRoomState({required this.me, final List participants = const [], this.isLoading = false}): _participants = participants; + const _SingleRoomState({required this.me, final List participants = const [], this.isLoading = false, this.wasKicked = false}): _participants = participants; @override final Participant me; @@ -229,6 +230,7 @@ class _SingleRoomState implements SingleRoomState { } @override@JsonKey() final bool isLoading; +@override@JsonKey() final bool wasKicked; /// Create a copy of SingleRoomState /// with the given fields replaced by the non-null parameter values. @@ -240,16 +242,16 @@ _$SingleRoomStateCopyWith<_SingleRoomState> get copyWith => __$SingleRoomStateCo @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other._participants, _participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SingleRoomState&&(identical(other.me, me) || other.me == me)&&const DeepCollectionEquality().equals(other._participants, _participants)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.wasKicked, wasKicked) || other.wasKicked == wasKicked)); } @override -int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(_participants),isLoading); +int get hashCode => Object.hash(runtimeType,me,const DeepCollectionEquality().hash(_participants),isLoading,wasKicked); @override String toString() { - return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading)'; + return 'SingleRoomState(me: $me, participants: $participants, isLoading: $isLoading, wasKicked: $wasKicked)'; } @@ -260,7 +262,7 @@ abstract mixin class _$SingleRoomStateCopyWith<$Res> implements $SingleRoomState factory _$SingleRoomStateCopyWith(_SingleRoomState value, $Res Function(_SingleRoomState) _then) = __$SingleRoomStateCopyWithImpl; @override @useResult $Res call({ - Participant me, List participants, bool isLoading + Participant me, List participants, bool isLoading, bool wasKicked }); @@ -277,11 +279,12 @@ class __$SingleRoomStateCopyWithImpl<$Res> /// Create a copy of SingleRoomState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? me = null,Object? participants = null,Object? isLoading = null,Object? wasKicked = null,}) { return _then(_SingleRoomState( me: null == me ? _self.me : me // ignore: cast_nullable_to_non_nullable as Participant,participants: null == participants ? _self._participants : participants // ignore: cast_nullable_to_non_nullable as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,wasKicked: null == wasKicked ? _self.wasKicked : wasKicked // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/features/rooms/model/single_room_state.dart b/lib/features/rooms/model/single_room_state.dart index 53cca0e1..b5d5dbcc 100644 --- a/lib/features/rooms/model/single_room_state.dart +++ b/lib/features/rooms/model/single_room_state.dart @@ -9,5 +9,6 @@ abstract class SingleRoomState with _$SingleRoomState { required Participant me, @Default([]) List participants, @Default(false) bool isLoading, + @Default(false) bool wasKicked, }) = _SingleRoomState; } diff --git a/lib/features/rooms/view/pages/create_room_page.dart b/lib/features/rooms/view/pages/create_room_page.dart index dbbe9deb..d40d3126 100644 --- a/lib/features/rooms/view/pages/create_room_page.dart +++ b/lib/features/rooms/view/pages/create_room_page.dart @@ -31,21 +31,6 @@ class CreateRoomPageState extends ConsumerState { bool _isScheduled = false; String? _scheduledDateTimeIso; - static const _monthMap = { - '1': 'Jan', - '2': 'Feb', - '3': 'March', - '4': 'April', - '5': 'May', - '6': 'June', - '7': 'July', - '8': 'Aug', - '9': 'Sep', - '10': 'Oct', - '11': 'Nov', - '12': 'Dec', - }; - @override void dispose() { _nameController.dispose(); @@ -83,9 +68,7 @@ class CreateRoomPageState extends ConsumerState { if (!mounted) return; final pickedTime = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime( - now.add(const Duration(minutes: 5)), - ), + initialTime: TimeOfDay.fromDateTime(now.add(const Duration(minutes: 5))), ); if (!mounted || pickedDate == null || pickedTime == null) return; @@ -100,9 +83,7 @@ class CreateRoomPageState extends ConsumerState { if (!pickedDateTime.isAfter(now)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - AppLocalizations.of(context)!.scheduledDateTimePast, - ), + content: Text(AppLocalizations.of(context)!.scheduledDateTimePast), ), ); return; @@ -111,16 +92,9 @@ class CreateRoomPageState extends ConsumerState { setState(() { _scheduledDateTimeIso = '${DateFormat('yyyy-MM-dd').format(pickedDate)}T${pickedTime.hour}:${pickedTime.minute}:00${isNeg ? '-' : '+'}$formattedOffset'; - final hour = pickedTime.hour > 12 - ? (pickedTime.hour - 12).toString() - : pickedTime.hour == 0 - ? '00' - : pickedTime.hour.toString(); - final minute = pickedTime.minute.toString().length < 2 - ? '0${pickedTime.minute}' - : pickedTime.minute.toString(); - _dateTimeController.text = - '${pickedDate.day} ${_monthMap[pickedDate.month.toString()]} ${pickedDate.year} $hour:$minute ${pickedTime.period.name.toUpperCase()}'; + _dateTimeController.text = DateFormat( + 'd MMM yyyy h:mm a', + ).format(pickedDateTime); }); } @@ -134,7 +108,6 @@ class CreateRoomPageState extends ConsumerState { }); } - Future submit() async { if (!(_formKey.currentState?.validate() ?? false)) return null; @@ -147,20 +120,20 @@ class CreateRoomPageState extends ConsumerState { try { if (_isScheduled) { if (_scheduledDateTimeIso == null) return null; - await ref.read(createRoomProvider.notifier).createScheduledRoom( - name: name, - description: description, - tags: tags, - scheduledDateTime: _scheduledDateTimeIso!, - ); + await ref + .read(createRoomProvider.notifier) + .createScheduledRoom( + name: name, + description: description, + tags: tags, + scheduledDateTime: _scheduledDateTimeIso!, + ); _clearForm(); return null; } else { - final room = await ref.read(createRoomProvider.notifier).createLiveRoom( - name: name, - description: description, - tags: tags, - ); + final room = await ref + .read(createRoomProvider.notifier) + .createLiveRoom(name: name, description: description, tags: tags); _clearForm(); return room; } @@ -168,7 +141,9 @@ class CreateRoomPageState extends ConsumerState { log('createRoom failed: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to create room: $e')), + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToCreateRoom), + ), ); } return null; @@ -233,14 +208,12 @@ class CreateRoomPageState extends ConsumerState { _ModeChip( label: AppLocalizations.of(context)!.live, active: !_isScheduled, - onTap: () => - setState(() => _isScheduled = false), + onTap: () => setState(() => _isScheduled = false), ), _ModeChip( label: AppLocalizations.of(context)!.scheduled, active: _isScheduled, - onTap: () => - setState(() => _isScheduled = true), + onTap: () => setState(() => _isScheduled = true), ), ], ), diff --git a/lib/features/rooms/view/pages/room_chat_page.dart b/lib/features/rooms/view/pages/room_chat_page.dart index 81376673..b672fed9 100644 --- a/lib/features/rooms/view/pages/room_chat_page.dart +++ b/lib/features/rooms/view/pages/room_chat_page.dart @@ -79,7 +79,7 @@ class _RoomChatPageState extends ConsumerState { icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), - title: const Text('Room Chat'), + title: Text(AppLocalizations.of(context)!.roomChat), centerTitle: true, actions: [ IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), @@ -90,7 +90,8 @@ class _RoomChatPageState extends ConsumerState { Expanded( child: asyncState.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), + error: (e, _) => + Center(child: Text(AppLocalizations.of(context)!.error)), data: (state) => ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16.0), @@ -145,7 +146,11 @@ class _RoomChatPageState extends ConsumerState { ); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to resend')), + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToResend, + ), + ), ); } }, @@ -440,9 +445,9 @@ class _ChatMessageItemState extends State { ), ), if (widget.message.isEdited) - const Text( - ' (edited)', - style: TextStyle( + Text( + AppLocalizations.of(context)!.edited, + style: const TextStyle( fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey, @@ -541,8 +546,8 @@ class _ChatInputFieldState extends ConsumerState { ); if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to send. Tap the message to retry.'), + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToSendTapRetry), ), ); } @@ -607,7 +612,7 @@ class _ChatInputFieldState extends ConsumerState { controller: _messageController, onSubmitted: (_) => _send(), decoration: InputDecoration( - hintText: 'Say Something', + hintText: AppLocalizations.of(context)!.saySomething, border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none, @@ -694,15 +699,15 @@ class _StatusIndicator extends StatelessWidget { return GestureDetector( onTap: onRetry, child: Tooltip( - message: 'Tap to retry', + message: AppLocalizations.of(context)!.tapToRetry, child: Row( mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.error_outline, size: 14, color: Colors.red), - SizedBox(width: 4), + children: [ + const Icon(Icons.error_outline, size: 14, color: Colors.red), + const SizedBox(width: 4), Text( - 'Retry', - style: TextStyle( + AppLocalizations.of(context)!.retry, + style: const TextStyle( color: Colors.red, fontSize: 12, fontWeight: FontWeight.w500, diff --git a/lib/features/rooms/view/pages/room_page.dart b/lib/features/rooms/view/pages/room_page.dart index 7ac76361..d4d09279 100644 --- a/lib/features/rooms/view/pages/room_page.dart +++ b/lib/features/rooms/view/pages/room_page.dart @@ -48,6 +48,17 @@ class RoomPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // If an admin kicks + ref.listen(singleRoomProvider(room), (_, next) { + if (next.value?.wasKicked ?? false) { + final navigator = Navigator.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.removedFromRoom)), + ); + if (navigator.canPop()) navigator.pop(); + } + }); + final asyncState = ref.watch(singleRoomProvider(room)); return Scaffold( diff --git a/lib/features/rooms/view/widgets/participant_block.dart b/lib/features/rooms/view/widgets/participant_block.dart index 7869fe72..9b1a94cd 100644 --- a/lib/features/rooms/view/widgets/participant_block.dart +++ b/lib/features/rooms/view/widgets/participant_block.dart @@ -89,7 +89,7 @@ class ParticipantBlock extends ConsumerWidget { return _makeItems([ _FocusedMenuItemData( AppLocalizations.of(context)!.removeModerator, - () => notifier.removeModerator(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.listener), ), _FocusedMenuItemData( AppLocalizations.of(context)!.kickOut, @@ -104,17 +104,17 @@ class ParticipantBlock extends ConsumerWidget { return _makeItems([ _FocusedMenuItemData( AppLocalizations.of(context)!.addModerator, - () => notifier.makeModerator(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.moderator), ), if (participant.hasRequestedToBeSpeaker) _FocusedMenuItemData( AppLocalizations.of(context)!.addSpeaker, - () => notifier.makeSpeaker(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.speaker), ), if (participant.isSpeaker) _FocusedMenuItemData( AppLocalizations.of(context)!.makeListener, - () => notifier.makeListener(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.listener), ), _FocusedMenuItemData( AppLocalizations.of(context)!.kickOut, @@ -134,12 +134,12 @@ class ParticipantBlock extends ConsumerWidget { if (participant.hasRequestedToBeSpeaker) _FocusedMenuItemData( AppLocalizations.of(context)!.addSpeaker, - () => notifier.makeSpeaker(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.speaker), ), if (participant.isSpeaker) _FocusedMenuItemData( AppLocalizations.of(context)!.makeListener, - () => notifier.makeListener(room, participant), + () => notifier.setRole(room, participant, ParticipantRole.listener), ), _FocusedMenuItemData( AppLocalizations.of(context)!.kickOut, diff --git a/lib/features/rooms/viewmodel/audio_device_notifier.dart b/lib/features/rooms/viewmodel/audio_device_notifier.dart index 60bfca76..8910291d 100644 --- a/lib/features/rooms/viewmodel/audio_device_notifier.dart +++ b/lib/features/rooms/viewmodel/audio_device_notifier.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:developer'; -import 'package:resonate/features/rooms/data/audio_device_service.dart'; +import 'package:resonate/features/rooms/data/services/audio_device_service.dart'; import 'package:resonate/features/rooms/model/audio_device_state.dart'; import 'package:resonate/models/audio_device.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/rooms/viewmodel/create_room_notifier.dart b/lib/features/rooms/viewmodel/create_room_notifier.dart index 5858b092..6c5b1c37 100644 --- a/lib/features/rooms/viewmodel/create_room_notifier.dart +++ b/lib/features/rooms/viewmodel/create_room_notifier.dart @@ -1,7 +1,8 @@ import 'package:resonate/core/container.dart'; -import 'package:resonate/features/rooms/data/rooms_repository.dart'; -import 'package:resonate/features/rooms/data/upcoming_rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/upcoming_rooms_repository.dart'; import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/room_failure.dart'; import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; import 'package:resonate/features/rooms/viewmodel/upcoming_rooms_notifier.dart'; @@ -29,10 +30,21 @@ class CreateRoomNotifier extends _$CreateRoomNotifier { tags: tags, adminUid: requireCurrentAuthUser.uid, ); - await ref.read(liveKitProvider.notifier).connect( + final connected = await ref.read(liveKitProvider.notifier).connect( liveKitUri: result.liveKitUri, roomToken: result.roomToken, ); + if (!connected) { + // Audio session failed to come up — tear down the room we just + // created so it isn't orphaned, then surface the failure. + try { + await repo.deleteRoom(roomId: result.roomId); + } catch (_) {/* best-effort cleanup */} + ref.invalidate(roomsProvider); + throw const RoomFailure.liveKit( + 'Could not connect to the audio session.', + ); + } // Refresh global rooms list. ref.invalidate(roomsProvider); diff --git a/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart b/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart index b9e9f215..151daa1c 100644 --- a/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart +++ b/lib/features/rooms/viewmodel/generated/create_room_notifier.g.dart @@ -42,7 +42,7 @@ final class CreateRoomNotifierProvider } String _$createRoomNotifierHash() => - r'56e1b3fdda5466998682742e7e469134f92ccb7a'; + r'132870ade89706b22fe810f19d681c3f614ad702'; abstract class _$CreateRoomNotifier extends $Notifier { bool build(); diff --git a/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart b/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart index 60b7bc83..d0bdfb65 100644 --- a/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart +++ b/lib/features/rooms/viewmodel/generated/room_chat_notifier.g.dart @@ -50,7 +50,7 @@ final class RoomChatNotifierProvider } } -String _$roomChatNotifierHash() => r'23050ba4be956bd36fc50fb714e8e193ac578b4e'; +String _$roomChatNotifierHash() => r'e4f78c40e748551c88c810d0a84fd6ea8735ac6d'; final class RoomChatNotifierFamily extends $Family with diff --git a/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart b/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart index 4a727bb2..ad6ab43f 100644 --- a/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart +++ b/lib/features/rooms/viewmodel/generated/rooms_notifier.g.dart @@ -33,7 +33,7 @@ final class RoomsNotifierProvider RoomsNotifier create() => RoomsNotifier(); } -String _$roomsNotifierHash() => r'71f3665edd5dd53478437d1822feb9bc35806270'; +String _$roomsNotifierHash() => r'9192fd2b341f40ffea0a1738f6c1ecd5aa05eccf'; abstract class _$RoomsNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart b/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart index 3ac30259..19327cea 100644 --- a/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart +++ b/lib/features/rooms/viewmodel/generated/single_room_notifier.g.dart @@ -51,7 +51,7 @@ final class SingleRoomNotifierProvider } String _$singleRoomNotifierHash() => - r'2734ebcd25c8e2c440b211c5e248764b5d5c9d19'; + r'1145b0f960ca2af5ec4b15bebcd1534037b5e3dd'; final class SingleRoomNotifierFamily extends $Family with diff --git a/lib/features/rooms/viewmodel/livekit_notifier.dart b/lib/features/rooms/viewmodel/livekit_notifier.dart index 81657bdf..9959f067 100644 --- a/lib/features/rooms/viewmodel/livekit_notifier.dart +++ b/lib/features/rooms/viewmodel/livekit_notifier.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:livekit_client/livekit_client.dart'; -import 'package:resonate/features/rooms/data/livekit_session.dart'; +import 'package:resonate/features/rooms/data/services/livekit_session.dart'; import 'package:resonate/features/rooms/model/livekit_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/rooms/viewmodel/room_chat_notifier.dart b/lib/features/rooms/viewmodel/room_chat_notifier.dart index 33bd6680..4ec426cd 100644 --- a/lib/features/rooms/viewmodel/room_chat_notifier.dart +++ b/lib/features/rooms/viewmodel/room_chat_notifier.dart @@ -5,7 +5,7 @@ import 'package:appwrite/appwrite.dart' show ID; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; import 'package:resonate/core/container.dart'; -import 'package:resonate/features/rooms/data/room_chat_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/room_chat_repository.dart'; import 'package:resonate/features/rooms/model/reply_to.dart'; import 'package:resonate/features/rooms/model/room_chat_state.dart'; import 'package:resonate/features/rooms/model/room_message.dart'; @@ -235,9 +235,9 @@ class RoomChatNotifier extends _$RoomChatNotifier { final current = state.value; if (current == null) return; - final original = current.messages.firstWhere( - (m) => m.messageId == messageId, - ); + final idx = current.messages.indexWhere((m) => m.messageId == messageId); + if (idx < 0) return; // message no longer in the list + final original = current.messages[idx]; final updated = original.copyWith( content: newContent, isEdited: true, @@ -262,9 +262,9 @@ class RoomChatNotifier extends _$RoomChatNotifier { final current = state.value; if (current == null) return; - final original = current.messages.firstWhere( - (m) => m.messageId == messageId, - ); + final idx = current.messages.indexWhere((m) => m.messageId == messageId); + if (idx < 0) return; // message no longer in the list + final original = current.messages[idx]; final softDeleted = original.copyWith(content: '', isDeleted: true); try { diff --git a/lib/features/rooms/viewmodel/rooms_notifier.dart b/lib/features/rooms/viewmodel/rooms_notifier.dart index 720c61ee..b9df3ccb 100644 --- a/lib/features/rooms/viewmodel/rooms_notifier.dart +++ b/lib/features/rooms/viewmodel/rooms_notifier.dart @@ -1,6 +1,7 @@ import 'package:resonate/core/container.dart'; -import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/rooms_repository.dart'; import 'package:resonate/features/rooms/model/appwrite_room.dart'; +import 'package:resonate/features/rooms/model/room_failure.dart'; import 'package:resonate/features/rooms/model/rooms_state.dart'; import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -27,15 +28,22 @@ class RoomsNotifier extends _$RoomsNotifier { Future joinRoom(AppwriteRoom room) async { final repo = ref.read(roomsRepositoryProvider); + final userId = requireCurrentAuthUser.uid; final result = await repo.joinRoom( roomId: room.id, - userId: requireCurrentAuthUser.uid, + userId: userId, isAdmin: room.isUserAdmin, ); - await ref.read(liveKitProvider.notifier).connect( + final connected = await ref.read(liveKitProvider.notifier).connect( liveKitUri: result.liveKitUri, roomToken: result.roomToken, ); + if (!connected) { + try { + await repo.leaveRoom(roomId: room.id, userId: userId); + } catch (_) {} + throw const RoomFailure.liveKit('Could not connect to the audio session.'); + } return room.copyWith(myDocId: result.myDocId); } diff --git a/lib/features/rooms/viewmodel/single_room_notifier.dart b/lib/features/rooms/viewmodel/single_room_notifier.dart index 5974d54c..a8781d06 100644 --- a/lib/features/rooms/viewmodel/single_room_notifier.dart +++ b/lib/features/rooms/viewmodel/single_room_notifier.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:appwrite/appwrite.dart'; import 'package:appwrite/models.dart'; import 'package:resonate/core/container.dart'; -import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/rooms_repository.dart'; import 'package:resonate/features/rooms/model/appwrite_room.dart'; import 'package:resonate/features/rooms/model/participant.dart'; import 'package:resonate/features/rooms/model/single_room_state.dart'; @@ -13,6 +13,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/single_room_notifier.g.dart'; +enum ParticipantRole { moderator, speaker, listener } + @riverpod class SingleRoomNotifier extends _$SingleRoomNotifier { StreamSubscription? _participantSub; @@ -50,7 +52,9 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { final repo = ref.read(roomsRepositoryProvider); final channel = RoomsRepository.participantChannel(); - _participantSub = repo.participantStream(appwriteRoom.id).listen((event) async { + _participantSub = repo.participantStream(appwriteRoom.id).listen(( + event, + ) async { final docId = event.payload['\$id'] as String; final action = event.events.first.substring( channel.length + 1 + docId.length + 1, @@ -76,7 +80,8 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { nextMe = current.me.copyWith( isModerator: event.payload['isModerator'] as bool, hasRequestedToBeSpeaker: - (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? false, + (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? + false, isMicOn: event.payload['isMicOn'] as bool, isSpeaker: event.payload['isSpeaker'] as bool, ); @@ -86,7 +91,8 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { return p.copyWith( isModerator: event.payload['isModerator'] as bool, hasRequestedToBeSpeaker: - (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? false, + (event.payload['hasRequestedToBeSpeaker'] as bool?) ?? + false, isMicOn: event.payload['isMicOn'] as bool, isSpeaker: event.payload['isSpeaker'] as bool, ); @@ -95,7 +101,6 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { state = AsyncData( current.copyWith(me: nextMe, participants: _sort(updated)), ); - // If we got demoted from speaker while mic was on, mute. if (updatedUid == current.me.uid && !(event.payload['isSpeaker'] as bool) && @@ -108,8 +113,13 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { { final removedUid = event.payload['uid'] as String; if (removedUid == current.me.uid) { - // We were kicked. Notifier disposal handled by view layer - // observing the participant list / failure. + // kicked + await _disposeStream(); + await ref.read(liveKitProvider.notifier).disconnect(); + final latest = state.value; + if (latest != null) { + state = AsyncData(latest.copyWith(wasKicked: true)); + } break; } final filtered = current.participants @@ -143,97 +153,83 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { return sorted; } - Future turnOnMic(AppwriteRoom appwriteRoom) => _setMic(appwriteRoom, true); - Future turnOffMic(AppwriteRoom appwriteRoom) => _setMic(appwriteRoom, false); + Future turnOnMic(AppwriteRoom appwriteRoom) => + _setMic(appwriteRoom, true); + Future turnOffMic(AppwriteRoom appwriteRoom) => + _setMic(appwriteRoom, false); Future _setMic(AppwriteRoom appwriteRoom, bool enabled) async { - // Optimistic UI update so the button reacts even if LiveKit/Appwrite are - // slow or fail. The Realtime stream will overwrite this with the - // authoritative value once the update propagates. final current = state.value; if (current != null) { state = AsyncData( current.copyWith(me: current.me.copyWith(isMicOn: enabled)), ); } - try { await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(enabled); } catch (_) {} - final docId = appwriteRoom.myDocId; if (docId == null) return; try { - await ref.read(roomsRepositoryProvider).updateParticipantDoc( - docId: docId, - data: {'isMicOn': enabled}, - ); + await ref + .read(roomsRepositoryProvider) + .updateParticipantDoc(docId: docId, data: {'isMicOn': enabled}); } catch (_) {} } Future raiseHand(AppwriteRoom appwriteRoom) async { - await ref.read(roomsRepositoryProvider).updateParticipantDoc( - docId: appwriteRoom.myDocId!, - data: {'hasRequestedToBeSpeaker': true}, - ); + await ref + .read(roomsRepositoryProvider) + .updateParticipantDoc( + docId: appwriteRoom.myDocId!, + data: {'hasRequestedToBeSpeaker': true}, + ); final current = state.value; if (current != null) { state = AsyncData( - current.copyWith(me: current.me.copyWith(hasRequestedToBeSpeaker: true)), + current.copyWith( + me: current.me.copyWith(hasRequestedToBeSpeaker: true), + ), ); } } Future unRaiseHand(AppwriteRoom appwriteRoom) async { - await ref.read(roomsRepositoryProvider).updateParticipantDoc( - docId: appwriteRoom.myDocId!, - data: {'hasRequestedToBeSpeaker': false}, - ); + await ref + .read(roomsRepositoryProvider) + .updateParticipantDoc( + docId: appwriteRoom.myDocId!, + data: {'hasRequestedToBeSpeaker': false}, + ); final current = state.value; if (current != null) { state = AsyncData( - current.copyWith(me: current.me.copyWith(hasRequestedToBeSpeaker: false)), + current.copyWith( + me: current.me.copyWith(hasRequestedToBeSpeaker: false), + ), ); } } - Future makeModerator(AppwriteRoom appwriteRoom, Participant participant) => - _participantMutation(appwriteRoom, participant, { - 'isSpeaker': true, - 'hasRequestedToBeSpeaker': false, - 'isModerator': true, - }); - - Future removeModerator(AppwriteRoom appwriteRoom, Participant participant) => - _participantMutation(appwriteRoom, participant, { - 'isSpeaker': false, - 'hasRequestedToBeSpeaker': false, - 'isModerator': false, - }); - - Future makeSpeaker(AppwriteRoom appwriteRoom, Participant participant) => - _participantMutation(appwriteRoom, participant, { - 'isSpeaker': true, - 'hasRequestedToBeSpeaker': false, - }); - - Future makeListener(AppwriteRoom appwriteRoom, Participant participant) => - _participantMutation(appwriteRoom, participant, { - 'isSpeaker': false, - 'hasRequestedToBeSpeaker': false, - }); - - Future _participantMutation( + Future setRole( AppwriteRoom appwriteRoom, Participant participant, - Map data, + ParticipantRole role, ) async { final repo = ref.read(roomsRepositoryProvider); final docId = await repo.getParticipantDocId( roomId: appwriteRoom.id, participantUid: participant.uid, ); - await repo.updateParticipantDoc(docId: docId, data: data); + if (docId == null) return; // participant already left + await repo.updateParticipantDoc( + docId: docId, + data: { + 'isModerator': role == ParticipantRole.moderator, + 'isSpeaker': role != ParticipantRole.listener, + 'hasRequestedToBeSpeaker': false, + }, + ); } Future kickOutParticipant( @@ -245,6 +241,7 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { roomId: appwriteRoom.id, participantUid: participant.uid, ); + if (docId == null) return; // participant already left await repo.kickParticipant(docId); } @@ -267,11 +264,11 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { state = AsyncData(current.copyWith(isLoading: true)); } await _disposeStream(); - await ref.read(roomsRepositoryProvider).leaveRoom( - roomId: appwriteRoom.id, - userId: requireCurrentAuthUser.uid, - ); + await ref + .read(roomsRepositoryProvider) + .leaveRoom(roomId: appwriteRoom.id, userId: requireCurrentAuthUser.uid); await ref.read(liveKitProvider.notifier).disconnect(); + ref.invalidate(roomsProvider); } Future deleteRoom(AppwriteRoom appwriteRoom) async { diff --git a/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart b/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart index a6b550cb..9c962994 100644 --- a/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart +++ b/lib/features/rooms/viewmodel/upcoming_rooms_notifier.dart @@ -1,7 +1,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:resonate/core/container.dart'; import 'package:resonate/core/providers/get_storage_provider.dart'; -import 'package:resonate/features/rooms/data/upcoming_rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/upcoming_rooms_repository.dart'; import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; import 'package:resonate/features/rooms/viewmodel/create_room_notifier.dart'; import 'package:resonate/features/rooms/viewmodel/rooms_notifier.dart'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6295a23c..0b4dde83 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,1849 +1,1876 @@ -{ - "@@locale": "en", - "title": "Resonate", - "@title": { - "description": "The title of the application." - }, - "roomDescription": "Be polite and respect the other person's opinion. Avoid rude comments.", - "@roomDescription": { - "description": "Guideline message for users in a chat room." - }, - "hidePassword": "Hide Password", - "@hidePassword": { - "description": "Button text to conceal the password field." - }, - "showPassword": "Show Password", - "@showPassword": { - "description": "Button text to reveal the password field." - }, - "passwordEmpty": "Password cannot be empty", - "@passwordEmpty": { - "description": "Error message when the password field is left blank." - }, - "password": "Password", - "@password": { - "description": "Label for the password input field." - }, - "confirmPassword": "Confirm Password", - "@confirmPassword": { - "description": "Label for the confirm password input field." - }, - "passwordsNotMatch": "Passwords do not match", - "@passwordsNotMatch": { - "description": "Error message when password and confirmation password do not match." - }, - "userCreatedStories": "User Created Stories", - "@userCreatedStories": { - "description": "Header for the section showing stories created by another user." - }, - "yourStories": "Your Stories", - "@yourStories": { - "description": "Header for the section showing stories created by the current user." - }, - "userNoStories": "User has not created any story", - "@userNoStories": { - "description": "Message displayed when viewing another user's profile and they have no stories." - }, - "youNoStories": "You have not created any story", - "@youNoStories": { - "description": "Message displayed on the current user's profile when they have no stories." - }, - "follow": "Follow", - "@follow": { - "description": "Button text to follow a user." - }, - "editProfile": "Edit Profile", - "@editProfile": { - "description": "Button text to navigate to the profile editing screen." - }, - "verifyEmail": "Verify Email", - "@verifyEmail": { - "description": "Button or link text to start the email verification process." - }, - "verified": "Verified", - "@verified": { - "description": "A status label indicating that the user's email has been verified." - }, - "profile": "Profile", - "@profile": { - "description": "Label for the user profile section or page." - }, - "userLikedStories": "User Liked Stories", - "@userLikedStories": { - "description": "Header for the section showing stories liked by another user." - }, - "yourLikedStories": "Your Liked Stories", - "@yourLikedStories": { - "description": "Header for the section showing stories liked by the current user." - }, - "userNoLikedStories": "User has not liked any story", - "@userNoLikedStories": { - "description": "Message displayed when viewing another user's profile and they have no liked stories." - }, - "youNoLikedStories": "You have not liked any story", - "@youNoLikedStories": { - "description": "Message displayed on the current user's profile when they have no liked stories." - }, - "live": "Live", - "@live": { - "description": "Tab or label for live audio rooms." - }, - "upcoming": "Upcoming", - "@upcoming": { - "description": "Tab or label for scheduled/upcoming audio rooms." - }, - "noAvailableRoom": "{isRoom, select, true{No Room Available} false{No Upcoming Room Available} other{No Room Information Available}}\nGet Started By Adding One Below!", - "@noAvailableRoom": { - "description": "Message when no room or upcoming room is available, with a call to action.", - "placeholders": { - "isRoom": { - "type": "String", - "example": "true" - } - } - }, - "user1": "User 1", - "@user1": { - "description": "A generic placeholder name for a user." - }, - "user2": "User 2", - "@user2": { - "description": "A second generic placeholder name for a user." - }, - "you": "You", - "@you": { - "description": "Label to identify the current user in a list or chat." - }, - "areYouSure": "Are you sure?", - "@areYouSure": { - "description": "Confirmation prompt title." - }, - "loggingOut": "You are logging out of Resonate.", - "@loggingOut": { - "description": "Confirmation prompt message for logging out." - }, - "yes": "Yes", - "@yes": { - "description": "Generic confirmation button text." - }, - "no": "No", - "@no": { - "description": "Generic cancellation button text." - }, - "incorrectEmailOrPassword": "Incorrect email or password", - "@incorrectEmailOrPassword": { - "description": "Error message for failed login attempt." - }, - "passwordShort": "Password is less than 8 characters", - "@passwordShort": { - "description": "Error message for a password that is too short." - }, - "tryAgain": "Try Again!", - "@tryAgain": { - "description": "Button text to retry a failed action." - }, - "success": "Success", - "@success": { - "description": "Generic title for a successful operation." - }, - "passwordResetSent": "Password reset email sent!", - "@passwordResetSent": { - "description": "Success message after a password reset email has been dispatched." - }, - "error": "Error", - "@error": { - "description": "Generic title for a failed operation." - }, - "resetPassword": "Reset Password", - "@resetPassword": { - "description": "Title or button text for the reset password feature." - }, - "enterNewPassword": "Enter your new password", - "@enterNewPassword": { - "description": "Instructional text on the reset password screen." - }, - "newPassword": "New Password", - "@newPassword": { - "description": "Label for the new password input field." - }, - "setNewPassword": "Set New Password", - "@setNewPassword": { - "description": "Button text to confirm setting a new password." - }, - "emailChanged": "Email Changed", - "@emailChanged": { - "description": "Title for the success dialog after an email change." - }, - "emailChangeSuccess": "Email changed successfully!", - "@emailChangeSuccess": { - "description": "Success message after changing the user's email." - }, - "failed": "Failed", - "@failed": { - "description": "Generic title for a failed operation." - }, - "emailChangeFailed": "Failed to change email", - "@emailChangeFailed": { - "description": "Error message when an email change operation fails." - }, - "oops": "Oops!", - "@oops": { - "description": "An informal title for an error or warning message." - }, - "emailExists": "Email already exists", - "@emailExists": { - "description": "Error message when a user tries to register or change to an email that is already in use." - }, - "changeEmail": "Change Email", - "@changeEmail": { - "description": "Title or button text for the change email feature." - }, - "enterValidEmail": "Please enter a valid email address", - "@enterValidEmail": { - "description": "Error message for an invalid email format." - }, - "newEmail": "New Email", - "@newEmail": { - "description": "Label for the new email input field." - }, - "currentPassword": "Current Password", - "@currentPassword": { - "description": "Label for the current password input field, used for verification." - }, - "emailChangeInfo": "For added security, you must provide your account's current password when changing your email address. After changing your email, use the updated email for future logins.", - "@emailChangeInfo": { - "description": "Informational text explaining the process and security requirements for changing an email address for standard users." - }, - "oauthUsersMessage": "(Only for users who logged in using Google or Github)", - "@oauthUsersMessage": { - "description": "A message to specify that the following instructions are for OAuth users." - }, - "oauthUsersEmailChangeInfo": "To change your email, please enter a new password in the \"Current Password\" field. Be sure to remember this password, as you'll need it for any future email changes. Moving forward, you can log in using Google/GitHub or your new email and password combination.", - "@oauthUsersEmailChangeInfo": { - "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." - }, - "resonateTagline": "Enter a world of limitless\nconversations.", - "@resonateTagline": { - "description": "The application's tagline, used on splash or login screens." - }, - "signInWithEmail": "Sign in with Email", - "@signInWithEmail": { - "description": "Button text for signing in using email and password." - }, - "or": "Or", - "@or": { - "description": "A separator text, typically used between different login options." - }, - "continueWith": "Continue with", - "@continueWith": { - "description": "Informational text preceding a list of third-party login providers." - }, - "continueWithGoogle": "Continue with Google", - "@continueWithGoogle": { - "description": "Button text for signing in with a Google account." - }, - "continueWithGitHub": "Continue with GitHub", - "@continueWithGitHub": { - "description": "Button text for signing in with a GitHub account." - }, - "resonateLogo": "Resonate Logo", - "@resonateLogo": { - "description": "Accessibility text for the Resonate application logo image." - }, - "iAlreadyHaveAnAccount": "I already have an account", - "@iAlreadyHaveAnAccount": { - "description": "Text for a link or button to navigate to the login screen from the sign-up screen." - }, - "createNewAccount": "Create new account", - "@createNewAccount": { - "description": "Text for a link or button to navigate to the sign-up screen from the login screen." - }, - "userProfile": "User profile", - "@userProfile": { - "description": "Accessibility text for a user's profile picture or avatar." - }, - "passwordIsStrong": "Password is strong", - "@passwordIsStrong": { - "description": "Validation message indicating that the entered password meets all strength requirements." - }, - "admin": "Admin", - "@admin": { - "description": "Label for the Admin user role in a room." - }, - "moderator": "Moderator", - "@moderator": { - "description": "Label for the Moderator user role in a room." - }, - "speaker": "Speaker", - "@speaker": { - "description": "Label for the Speaker user role in a room." - }, - "listener": "Listener", - "@listener": { - "description": "Label for the Listener user role in a room." - }, - "removeModerator": "Remove Moderator", - "@removeModerator": { - "description": "Menu item text to revoke moderator privileges from a user." - }, - "kickOut": "Kick Out", - "@kickOut": { - "description": "Menu item text to remove a user from a room." - }, - "addModerator": "Add Moderator", - "@addModerator": { - "description": "Menu item text to grant moderator privileges to a user." - }, - "addSpeaker": "Add Speaker", - "@addSpeaker": { - "description": "Menu item text to grant speaker privileges to a listener." - }, - "makeListener": "Make Listener", - "@makeListener": { - "description": "Menu item text to change a speaker's role to listener." - }, - "pairChat": "Pair Chat", - "@pairChat": { - "description": "A feature name for one-on-one random chat." - }, - "chooseIdentity": "Choose Identity", - "@chooseIdentity": { - "description": "Prompt for the user to choose their identity, e.g., anonymous or public." - }, - "selectLanguage": "Select Language", - "@selectLanguage": { - "description": "Label for the language selection setting." - }, - "noConnection": "No Connection", - "@noConnection": { - "description": "Title indicating that there is no internet connection." - }, - "loadingDialog": "Loading Dialog", - "@loadingDialog": { - "description": "Accessibility text for a loading indicator or spinner." - }, - "createAccount": "Create Account", - "@createAccount": { - "description": "Button text or page title for the account creation screen." - }, - "enterValidEmailAddress": "Enter Valid Email Address", - "@enterValidEmailAddress": { - "description": "Error message shown for an invalid email format." - }, - "email": "Email", - "@email": { - "description": "Label for the email input field." - }, - "passwordRequirements": "Password must be at least 8 characters long", - "@passwordRequirements": { - "description": "Instructional text listing the requirements for a valid password, part 1." - }, - "includeNumericDigit": "Include at least 1 numeric digit", - "@includeNumericDigit": { - "description": "Instructional text listing the requirements for a valid password, part 2." - }, - "includeUppercase": "Include at least 1 uppercase letter", - "@includeUppercase": { - "description": "Instructional text listing the requirements for a valid password, part 3." - }, - "includeLowercase": "Include at least 1 lowercase letter", - "@includeLowercase": { - "description": "Instructional text listing the requirements for a valid password, part 4." - }, - "includeSymbol": "Include at least 1 symbol", - "@includeSymbol": { - "description": "Instructional text listing the requirements for a valid password, part 5." - }, - "signedUpSuccessfully": "Signed Up Successfully", - "@signedUpSuccessfully": { - "description": "Title for a success message after account creation." - }, - "newAccountCreated": "You have successfully created a new account", - "@newAccountCreated": { - "description": "Success message confirming that a new account has been created." - }, - "signUp": "Sign up", - "@signUp": { - "description": "Button text to submit the sign-up form." - }, - "login": "Login", - "@login": { - "description": "Button text to submit the login form." - }, - "settings": "Settings", - "@settings": { - "description": "Title for the settings page." - }, - "accountSettings": "Account settings", - "@accountSettings": { - "description": "Sub-header for account-related settings." - }, - "account": "Account", - "@account": { - "description": "Label for the account settings section." - }, - "appSettings": "App settings", - "@appSettings": { - "description": "Sub-header for application-related settings." - }, - "themes": "Themes", - "@themes": { - "description": "Label for the theme selection setting." - }, - "about": "About", - "@about": { - "description": "Label for the 'About' section or page of the app or a story." - }, - "other": "Other", - "@other": { - "description": "A generic category label for miscellaneous settings or items." - }, - "contribute": "Contribute", - "@contribute": { - "description": "Label for a section encouraging users to contribute to the project." - }, - "appPreferences": "App Preferences", - "@appPreferences": { - "description": "Label for the app preferences settings page." - }, - "transcriptionModel": "Transcription Model", - "@transcriptionModel": { - "description": "Section title for choosing AI transcription model." - }, - "transcriptionModelDescription": "Choose the AI model for voice transcription. Larger models are more accurate but slower and require more storage.", - "@transcriptionModelDescription": { - "description": "Description text explaining transcription model choices." - }, - "whisperModelTiny": "Tiny", - "@whisperModelTiny": { - "description": "Name of the smallest Whisper AI model." - }, - "whisperModelTinyDescription": "Fastest, least accurate (~39 MB)", - "@whisperModelTinyDescription": { - "description": "Description of the Tiny Whisper model performance and size." - }, - "whisperModelBase": "Base", - "@whisperModelBase": { - "description": "Name of the base Whisper AI model." - }, - "whisperModelBaseDescription": "Balanced speed and accuracy (~74 MB)", - "@whisperModelBaseDescription": { - "description": "Description of the Base Whisper model performance and size." - }, - "whisperModelSmall": "Small", - "@whisperModelSmall": { - "description": "Name of the small Whisper AI model." - }, - "whisperModelSmallDescription": "Good accuracy, slower (~244 MB)", - "@whisperModelSmallDescription": { - "description": "Description of the Small Whisper model performance and size." - }, - "whisperModelMedium": "Medium", - "@whisperModelMedium": { - "description": "Name of the medium Whisper AI model." - }, - "whisperModelMediumDescription": "High accuracy, slower (~769 MB)", - "@whisperModelMediumDescription": { - "description": "Description of the Medium Whisper model performance and size." - }, - "whisperModelLargeV1": "Large V1", - "@whisperModelLargeV1": { - "description": "Name of the large V1 Whisper AI model." - }, - "whisperModelLargeV1Description": "Most accurate, slowest (~1.55 GB)", - "@whisperModelLargeV1Description": { - "description": "Description of the Large V1 Whisper model performance and size." - }, - "whisperModelLargeV2": "Large V2", - "@whisperModelLargeV2": { - "description": "Name of the large V2 Whisper AI model." - }, - "whisperModelLargeV2Description": "Improved large model with higher accuracy (~1.55 GB)", - "@whisperModelLargeV2Description": { - "description": "Description of the Large V2 Whisper model performance and size." - }, - "modelDownloadInfo": "Models are downloaded when first used. We recommend using Base, Small, or Medium. Large models require very high-end devices.", - "@modelDownloadInfo": { - "description": "Information message about model download." - }, - "logOut": "Log out", - "@logOut": { - "description": "Button text to log the user out of their account." - }, - "participants": "Participants", - "@participants": { - "description": "Label or title for the list of participants in a room." - }, - "delete": "delete", - "@delete": { - "description": "Generic button text for a delete action. Parameter for toRoomAction." - }, - "leave": "leave", - "@leave": { - "description": "Generic button text for a leave action. Parameter for toRoomAction." - }, - "leaveButton": "Leave", - "@leaveButton": { - "description": "Button text to leave a room or conversation." - }, - "findingRandomPartner": "Finding a Random Partner For You", - "@findingRandomPartner": { - "description": "Status message shown while the system is searching for a random chat partner." - }, - "quickFact": "Quick fact", - "@quickFact": { - "description": "Header for a quick fact or tip, often shown during loading." - }, - "cancel": "Cancel", - "@cancel": { - "description": "Generic button text for a cancel action." - }, - "hide": "Remove", - "@hide": { - "description": "Button text to remove an item from view." - }, - "removeRoom": "Remove Room", - "@removeRoom": { - "description": "Dialog title for removing an upcoming room." - }, - "removeRoomFromList": "Remove from list", - "@removeRoomFromList": { - "description": "Tooltip text for the remove room button." - }, - "removeRoomConfirmation": "Are you sure you want to remove this upcoming room from your list?", - "@removeRoomConfirmation": { - "description": "Confirmation message asking if the user wants to remove an upcoming room from their list." - }, - "completeYourProfile": "Complete your Profile", - "@completeYourProfile": { - "description": "Page title or prompt for the user to finish setting up their profile." - }, - "uploadProfilePicture": "Upload profile picture", - "@uploadProfilePicture": { - "description": "Instructional text or button to upload a profile picture." - }, - "enterValidName": "Enter Valid Name", - "@enterValidName": { - "description": "Error message for an invalid name format." - }, - "name": "Name", - "@name": { - "description": "Label for the name input field." - }, - "username": "Username", - "@username": { - "description": "Label for the username input field." - }, - "enterValidDOB": "Enter Valid DOB", - "@enterValidDOB": { - "description": "Error message for an invalid date of birth." - }, - "dateOfBirth": "Date of Birth", - "@dateOfBirth": { - "description": "Label for the date of birth input field." - }, - "forgotPassword": "Forgot Password?", - "@forgotPassword": { - "description": "Link text for users who have forgotten their password." - }, - "next": "Next", - "@next": { - "description": "Button text to proceed to the next step in a process." - }, - "noStoriesExist": "No stories exist to present", - "@noStoriesExist": { - "description": "Message displayed when there are no stories available in a list." - }, - "enterVerificationCode": "Enter your\nVerification Code", - "@enterVerificationCode": { - "description": "Prompt for the user to enter the verification code sent to their email." - }, - "verificationCodeSent": "We sent a 6-digit verification code to\n", - "@verificationCodeSent": { - "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." - }, - "verificationComplete": "Verification Complete", - "@verificationComplete": { - "description": "Title for a success message after email verification." - }, - "verificationCompleteMessage": "Congratulations you have verified your Email", - "@verificationCompleteMessage": { - "description": "Success message confirming email verification." - }, - "verificationFailed": "Verification Failed", - "@verificationFailed": { - "description": "Title for an error message when verification fails." - }, - "otpMismatch": "OTP mismatch occurred please try again", - "@otpMismatch": { - "description": "Error message when the entered OTP is incorrect." - }, - "otpResent": "OTP resent", - "@otpResent": { - "description": "Confirmation message that the OTP has been resent." - }, - "requestNewCode": "Request a new code", - "@requestNewCode": { - "description": "Button text to request a new verification code." - }, - "requestNewCodeIn": "Request a new code in", - "@requestNewCodeIn": { - "description": "Text displayed before a countdown timer for requesting a new code." - }, - "clickPictureCamera": "Click picture using camera", - "@clickPictureCamera": { - "description": "Option to take a new photo using the device camera." - }, - "pickImageGallery": "Pick image from gallery", - "@pickImageGallery": { - "description": "Option to choose an existing image from the device gallery." - }, - "deleteMyAccount": "Delete My Account", - "@deleteMyAccount": { - "description": "Button text for the account deletion feature." - }, - "createNewRoom": "Create New Room", - "@createNewRoom": { - "description": "Button text or page title for creating a new audio room." - }, - "pleaseEnterScheduledDateTime": "Please Enter Scheduled Date-Time", - "@pleaseEnterScheduledDateTime": { - "description": "Error message when the scheduled date and time for a room is not provided." - }, - "scheduleDateTimeLabel": "Schedule Date Time", - "@scheduleDateTimeLabel": { - "description": "Label for the input field to schedule a room's date and time." - }, - "enterTags": "Enter tags", - "@enterTags": { - "description": "Placeholder or label for the input field for adding tags to a room or story." - }, - "joinCommunity": "Join Community", - "@joinCommunity": { - "description": "Button text or header for the community section." - }, - "followUsOnX": "Follow us on X", - "@followUsOnX": { - "description": "Link text to the company's profile on X (formerly Twitter)." - }, - "joinDiscordServer": "Join discord server", - "@joinDiscordServer": { - "description": "Link text to join the project's Discord server." - }, - "noLyrics": "No lyrics", - "@noLyrics": { - "description": "Message displayed when lyrics for an audio file are not available." - }, - "noStoriesInCategory": "No stories currently exist in the {categoryName} category to present", - "@noStoriesInCategory": { - "description": "Message when no stories exist in a specific category.", - "placeholders": { - "categoryName": { - "type": "String", - "example": "drama" - } - } - }, - "newChapters": "New Chapters", - "@newChapters": { - "description": "Header or label for the section to add or view new chapters of a story." - }, - "helpToGrow": "Help to grow", - "@helpToGrow": { - "description": "A call to action asking users to help the platform grow, often a header for sharing/rating." - }, - "share": "Share", - "@share": { - "description": "Button text for sharing content." - }, - "rate": "Rate", - "@rate": { - "description": "Button text for rating the app or content." - }, - "aboutResonate": "About Resonate", - "@aboutResonate": { - "description": "Title for the 'About' page of the Resonate application." - }, - "description": "Description", - "@description": { - "description": "Label for a description field." - }, - "confirm": "Confirm", - "@confirm": { - "description": "Generic button text for a confirm action." - }, - "classic": "Classic", - "@classic": { - "description": "Name of a theme option." - }, - "time": "Time", - "@time": { - "description": "Name of a theme option." - }, - "vintage": "Vintage", - "@vintage": { - "description": "Name of a theme option." - }, - "amber": "Amber", - "@amber": { - "description": "Name of a theme option." - }, - "forest": "Forest", - "@forest": { - "description": "Name of a theme option." - }, - "cream": "Cream", - "@cream": { - "description": "Name of a theme option." - }, - "none": "none", - "@none": { - "description": "Text representing no selection or absence of a value." - }, - "checkOutGitHub": "Check out our GitHub repository: {url}", - "@checkOutGitHub": { - "description": "Share message for the GitHub repository, including a placeholder for the URL.", - "placeholders": { - "url": { - "type": "String", - "example": "https://github.com/AOSSIE-Org/Resonate" - } - } - }, - "aossie": "AOSSIE", - "@aossie": { - "description": "The name of the organization." - }, - "aossieLogo": "aossie logo", - "@aossieLogo": { - "description": "Accessibility text for the AOSSIE organization logo." - }, - "errorLoadPackageInfo": "Could not load package info", - "@errorLoadPackageInfo": { - "description": "Error message when the application fails to load its package information (e.g., version)." - }, - "searchFailed": "Failed to search rooms. Please try again.", - "@searchFailed": { - "description": "Error message when searching for rooms fails." - }, - "updateAvailable": "Update Available", - "@updateAvailable": { - "description": "Title indicating that a new version of the app is available." - }, - "newVersionAvailable": "A new version is available!", - "@newVersionAvailable": { - "description": "Message informing the user about an available update." - }, - "upToDate": "Up to Date", - "@upToDate": { - "description": "Title indicating the application is on the latest version." - }, - "latestVersion": "You're using the latest version", - "@latestVersion": { - "description": "Message confirming the user has the latest version of the app." - }, - "profileCreatedSuccessfully": "Profile created successfully", - "@profileCreatedSuccessfully": { - "description": "Success message after a user's profile is created." - }, - "invalidScheduledDateTime": "Invalid Scheduled Date Time", - "@invalidScheduledDateTime": { - "description": "Error message for an invalid date-time format." - }, - "scheduledDateTimePast": "Scheduled Date Time cannot be in past", - "@scheduledDateTimePast": { - "description": "Error message when the user selects a past date-time for a scheduled event." - }, - "joinRoom": "Join Room", - "@joinRoom": { - "description": "Button text to join an audio room." - }, - "unknownUser": "Unknown", - "@unknownUser": { - "description": "Placeholder text for a user whose name is not available." - }, - "canceled": "canceled", - "@canceled": { - "description": "A status label indicating that an action was canceled." - }, - "english": "en", - "@english": { - "description": "The language code for English." - }, - "emailVerificationRequired": "Email Verification Required", - "@emailVerificationRequired": { - "description": "A title or message indicating that email verification is needed to proceed." - }, - "verify": "Verify", - "@verify": { - "description": "Button text to initiate a verification process." - }, - "audioRoom": "Audio Room", - "@audioRoom": { - "description": "Generic title for an audio room." - }, - "toRoomAction": "To {action} the room", - "@toRoomAction": { - "description": "A dynamic message for confirming an action on a room, like deleting or leaving.", - "placeholders": { - "action": { - "type": "String", - "example": "delete" - } - } - }, - "mailSentMessage": "mail sent", - "@mailSentMessage": { - "description": "A confirmation message indicating an email has been sent." - }, - "disconnected": "disconnected", - "@disconnected": { - "description": "A status label indicating a loss of connection." - }, - "micOn": "mic", - "@micOn": { - "description": "Accessibility label for the microphone button when it is on." - }, - "speakerOn": "speaker", - "@speakerOn": { - "description": "Accessibility label for the speakerphone button when it is on." - }, - "endChat": "end-chat", - "@endChat": { - "description": "Accessibility label for the end chat/call button." - }, - "monthJan": "Jan", - "@monthJan": { - "description": "Abbreviation for January." - }, - "monthFeb": "Feb", - "@monthFeb": { - "description": "Abbreviation for February." - }, - "monthMar": "March", - "@monthMar": { - "description": "Full name for March." - }, - "monthApr": "April", - "@monthApr": { - "description": "Full name for April." - }, - "monthMay": "May", - "@monthMay": { - "description": "Full name for May." - }, - "monthJun": "June", - "@monthJun": { - "description": "Full name for June." - }, - "monthJul": "July", - "@monthJul": { - "description": "Full name for July." - }, - "monthAug": "Aug", - "@monthAug": { - "description": "Abbreviation for August." - }, - "monthSep": "Sep", - "@monthSep": { - "description": "Abbreviation for September." - }, - "monthOct": "Oct", - "@monthOct": { - "description": "Abbreviation for October." - }, - "monthNov": "Nov", - "@monthNov": { - "description": "Abbreviation for November." - }, - "monthDec": "Dec", - "@monthDec": { - "description": "Abbreviation for December." - }, - "register": "Register", - "@register": { - "description": "Button text to register a new account." - }, - "newToResonate": "New to Resonate? ", - "@newToResonate": { - "description": "Text preceding the 'Create new account' link on the login screen." - }, - "alreadyHaveAccount": "Already have an account? ", - "@alreadyHaveAccount": { - "description": "Text preceding the 'Login' link on the sign-up screen." - }, - "checking": "Checking...", - "@checking": { - "description": "A temporary status message indicating a check is in progress." - }, - "forgotPasswordMessage": "Enter your registered email address to reset your password.", - "@forgotPasswordMessage": { - "description": "Instructional text on the forgot password screen." - }, - "usernameUnavailable": "Username Unavailable!", - "@usernameUnavailable": { - "description": "Title for the error when a chosen username is taken." - }, - "usernameInvalidOrTaken": "This username is invalid or either taken already.", - "@usernameInvalidOrTaken": { - "description": "Error message explaining why a username cannot be used." - }, - "otpResentMessage": "Please check your mail for a new OTP.", - "@otpResentMessage": { - "description": "Message informing the user to check their email for a new OTP." - }, - "connectionError": "There is a connection error. Please check your internet and try again.", - "@connectionError": { - "description": "Generic error message for network connection issues." - }, - "seconds": "seconds.", - "@seconds": { - "description": "The word 'seconds', often used after a countdown number." - }, - "unsavedChangesWarning": "If you proceed without saving, any unsaved changes will be lost.", - "@unsavedChangesWarning": { - "description": "A warning message shown when a user tries to leave a screen with unsaved changes." - }, - "deleteAccountPermanent": "This action will Delete Your Account Permanently. It is irreversible process. We will delete your username, email address, and all other data associated with your account. You will not be able to recover it.", - "@deleteAccountPermanent": { - "description": "A detailed warning message explaining the consequences of deleting an account." - }, - "giveGreatName": "Give a great name..", - "@giveGreatName": { - "description": "Placeholder text for a name input field, encouraging a creative name." - }, - "joinCommunityDescription": "By joining community you can Clear your doubts, Suggest for new features, Report issues you faced and More.", - "@joinCommunityDescription": { - "description": "A description of the benefits of joining the community." - }, - "resonateDescription": "Resonate is a social media platform, where every voice is valued. Share your thoughts, stories, and experiences with others. Start your audio journey now. Dive into diverse discussions and topics. Find rooms that resonate with you and become a part of the community. Join the conversation! Explore rooms, connect with friends, and share your voice with the world.", - "@resonateDescription": { - "description": "A short, user-facing description of the Resonate application." - }, - "resonateFullDescription": "Resonate is a revolutionary voice-based social media platform where every voice matters. \nJoin real-time audio conversations, participate in diverse discussions, and connect with \nlike-minded individuals. Our platform offers:\n- Live audio rooms with topic-based discussions\n- Seamless social networking through voice\n- Community-driven content moderation\n- Cross-platform compatibility\n- End-to-end encrypted private conversations\n\nDeveloped by the AOSSIE open source community, we prioritize user privacy and \ncommunity-driven development. Join us in shaping the future of social audio!", - "@resonateFullDescription": { - "description": "A comprehensive, detailed description of the Resonate application, its features, and its mission." - }, - "stable": "Stable", - "@stable": { - "description": "A label indicating a stable, non-beta version of the app." - }, - "usernameCharacterLimit": "Username should contain more than 7 characters.", - "@usernameCharacterLimit": { - "description": "Error message when a chosen username is too short." - }, - "submit": "Submit", - "@submit": { - "description": "Generic button text for submitting a form." - }, - "anonymous": "Anonymous", - "@anonymous": { - "description": "Label for an anonymous user or identity." - }, - "noSearchResults": "No Search Results", - "@noSearchResults": { - "description": "Message displayed when a search returns no results." - }, - "searchRooms": "Search rooms...", - "@searchRooms": { - "description": "Placeholder text for room search input field." - }, - "searchingRooms": "Searching rooms...", - "@searchingRooms": { - "description": "Loading message shown while searching for rooms." - }, - "clearSearch": "Clear search", - "@clearSearch": { - "description": "Text for button to clear search results." - }, - "searchError": "Search Error", - "@searchError": { - "description": "Title for search error messages." - }, - "searchRoomsError": "Failed to search rooms. Please try again.", - "@searchRoomsError": { - "description": "Error message when room search fails." - }, - "searchUpcomingRoomsError": "Failed to search upcoming rooms. Please try again.", - "@searchUpcomingRoomsError": { - "description": "Error message when upcoming room search fails." - }, - "search": "Search", - "@search": { - "description": "Tooltip text for search button." - }, - "clear": "Clear", - "@clear": { - "description": "Tooltip text for clear button." - }, - "shareRoomMessage": "🚀 Check out this amazing room: {roomName}!\n\n📖 Description: {description}\n👥 Join {participants} participants now!", - "@shareRoomMessage": { - "description": "The default message template used when sharing a room.", - "placeholders": { - "roomName": { - "type": "String", - "example": "Discussion Room" - }, - "description": { - "type": "String", - "example": "A great place to discuss" - }, - "participants": { - "type": "int", - "example": "5" - } - } - }, - "participantsCount": "{count} Participants", - "@participantsCount": { - "description": "Displays the number of participants in a room.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "join": "Join", - "@join": { - "description": "Generic button text to join an activity or group." - }, - "invalidTags": "Invalid Tag:", - "@invalidTags": { - "description": "Error message prefix for an invalid tag." - }, - "cropImage": "Crop Image", - "@cropImage": { - "description": "Title or button text for the image cropping tool." - }, - "profileSavedSuccessfully": "Profile updated", - "@profileSavedSuccessfully": { - "description": "Short success message indicating profile changes have been saved." - }, - "profileUpdatedSuccessfully": "All changes are saved successfully.", - "@profileUpdatedSuccessfully": { - "description": "Detailed success message confirming profile update." - }, - "profileUpToDate": "Profile up to date", - "@profileUpToDate": { - "description": "Title for the message when there are no changes to save on the profile." - }, - "noChangesToSave": "There are no new changes made, Nothing to save.", - "@noChangesToSave": { - "description": "Message displayed when the user tries to save their profile without making any changes." - }, - "connectionFailed": "Connection Failed", - "@connectionFailed": { - "description": "Generic title for connection-related errors." - }, - "unableToJoinRoom": "Unable to join the room. Please check your network and try again.", - "@unableToJoinRoom": { - "description": "Error message when a user fails to join a room due to connection issues." - }, - "connectionLost": "Connection Lost", - "@connectionLost": { - "description": "Title indicating that the connection to a service was lost." - }, - "unableToReconnect": "Unable to reconnect to the room. Please try rejoining.", - "@unableToReconnect": { - "description": "Error message when the app fails to reconnect the user to a room." - }, - "invalidFormat": "Invalid Format!", - "@invalidFormat": { - "description": "Generic error message for data with an incorrect format." - }, - "usernameAlphanumeric": "Username must be alphanumeric and should not contain special characters.", - "@usernameAlphanumeric": { - "description": "Error message detailing the character requirements for a valid username." - }, - "userProfileCreatedSuccessfully": "Your user profile is successfully created.", - "@userProfileCreatedSuccessfully": { - "description": "Success message after a user completes their profile setup." - }, - "emailVerificationMessage": "To proceed, verify your email address.", - "@emailVerificationMessage": { - "description": "An instructional message prompting the user to verify their email." - }, - "addNewChaptersToStory": "Add New Chapters to {storyName}", - "@addNewChaptersToStory": { - "description": "Title for the screen where new chapters are added to an existing story.", - "placeholders": { - "storyName": { - "type": "String", - "example": "My Story" - } - } - }, - "currentChapters": "Current Chapters", - "@currentChapters": { - "description": "Header for the list of existing chapters in a story." - }, - "sourceCodeOnGitHub": "Source code on GitHub", - "@sourceCodeOnGitHub": { - "description": "Link text to the project's source code on GitHub." - }, - "createAChapter": "Create a Chapter", - "@createAChapter": { - "description": "Title for the screen where a new chapter is created." - }, - "chapterTitle": "Chapter Title *", - "@chapterTitle": { - "description": "Label for the chapter title input field, indicating it is required." - }, - "aboutRequired": "About *", - "@aboutRequired": { - "description": "Label for the 'About' or description input field, indicating it is required." - }, - "changeCoverImage": "Change Cover Image", - "@changeCoverImage": { - "description": "Button text to change the cover image of a story or chapter." - }, - "uploadAudioFile": "Upload Audio File", - "@uploadAudioFile": { - "description": "Button text to upload an audio file." - }, - "uploadLyricsFile": "Upload Lyrics File", - "@uploadLyricsFile": { - "description": "Button text to upload a lyrics file." - }, - "createChapter": "Create Chapter", - "@createChapter": { - "description": "Button text to finalize and create a new chapter." - }, - "audioFileSelected": "Audio file Selected: {fileName}", - "@audioFileSelected": { - "description": "Message shown after an audio file has been selected for upload.", - "placeholders": { - "fileName": { - "type": "String", - "example": "audio.mp3" - } - } - }, - "lyricsFileSelected": "Lyrics File Selected: {fileName}", - "@lyricsFileSelected": { - "description": "Message shown after a lyrics file has been selected for upload.", - "placeholders": { - "fileName": { - "type": "String", - "example": "lyrics.txt" - } - } - }, - "fillAllRequiredFields": "Please fill in all required fields and upload your Audio file and Lyrics file", - "@fillAllRequiredFields": { - "description": "Error message when required fields or files are missing from a form." - }, - "scheduled": "Scheduled", - "@scheduled": { - "description": "A status label indicating an event is scheduled for the future." - }, - "ok": "OK", - "@ok": { - "description": "Generic confirmation button text, often for closing a dialog." - }, - "roomDescriptionOptional": "Room Description (optional)", - "@roomDescriptionOptional": { - "description": "Label for the room description input field, indicating it is not required." - }, - "deleteAccount": "Delete account", - "@deleteAccount": { - "description": "Button text to initiate the account deletion process." - }, - "createYourStory": "Create Your Story", - "@createYourStory": { - "description": "Title for the screen where a new story is created." - }, - "titleRequired": "Title *", - "@titleRequired": { - "description": "Label for the title input field, indicating it is required." - }, - "category": "Category *", - "@category": { - "description": "Label for the category selection, indicating it is required." - }, - "addChapter": "Add Chapter", - "@addChapter": { - "description": "Button text to add a chapter to a story." - }, - "createStory": "Create Story", - "@createStory": { - "description": "Button text to finalize and create a new story." - }, - "fillAllRequiredFieldsAndChapter": "Please fill in all required fields, add at least one chapter, and select a cover image.", - "@fillAllRequiredFieldsAndChapter": { - "description": "Error message for the story creation form when requirements are not met." - }, - "toConfirmType": "To confirm, type", - "@toConfirmType": { - "description": "Instructional text in a confirmation dialog, preceding the text to be typed." - }, - "inTheBoxBelow": "in the box below", - "@inTheBoxBelow": { - "description": "Instructional text in a confirmation dialog, following the text to be typed." - }, - "iUnderstandDeleteMyAccount": "I understand, Delete My Account", - "@iUnderstandDeleteMyAccount": { - "description": "The specific phrase a user must type to confirm account deletion." - }, - "whatDoYouWantToListenTo": "What do you want to listen to?", - "@whatDoYouWantToListenTo": { - "description": "A prompt on the main screen, encouraging user engagement." - }, - "categories": "Categories", - "@categories": { - "description": "Header for the list of categories." - }, - "stories": "Stories", - "@stories": { - "description": "Header for the list of stories." - }, - "someSuggestions": "Some Suggestions", - "@someSuggestions": { - "description": "Header for a list of suggested content." - }, - "getStarted": "Get Started", - "@getStarted": { - "description": "Button text on an onboarding screen to begin using the app." - }, - "skip": "Skip", - "@skip": { - "description": "Button text to skip an optional step, like onboarding." - }, - "welcomeToResonate": "Welcome to Resonate", - "@welcomeToResonate": { - "description": "Onboarding screen title." - }, - "exploreDiverseConversations": "Explore Diverse Conversations", - "@exploreDiverseConversations": { - "description": "Feature highlight on an onboarding screen." - }, - "yourVoiceMatters": "Your Voice Matters", - "@yourVoiceMatters": { - "description": "Feature highlight on an onboarding screen." - }, - "joinConversationExploreRooms": "Join the conversation! Explore rooms, connect with friends, and share your voice with the world.", - "@joinConversationExploreRooms": { - "description": "Descriptive text on an onboarding screen." - }, - "diveIntoDiverseDiscussions": "Dive into diverse discussions and topics. \nFind rooms that resonate with you and become a part of the community.", - "@diveIntoDiverseDiscussions": { - "description": "Descriptive text on an onboarding screen." - }, - "atResonateEveryVoiceValued": "At Resonate, every voice is valued. Share your thoughts, stories, and experiences with others. Start your audio journey now.", - "@atResonateEveryVoiceValued": { - "description": "Descriptive text on an onboarding screen." - }, - "notifications": "Notifications", - "@notifications": { - "description": "Title for the notifications screen." - }, - "taggedYouInUpcomingRoom": "{username} tagged you in an upcoming room: {subject}", - "@taggedYouInUpcomingRoom": { - "description": "Notification message when tagged in an upcoming room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "taggedYouInRoom": "{username} tagged you in room: {subject}", - "@taggedYouInRoom": { - "description": "Notification message when tagged in a live room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "likedYourStory": "{username} liked your story: {subject}", - "@likedYourStory": { - "description": "Notification message when someone likes your story.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Story" - } - } - }, - "subscribedToYourRoom": "{username} subscribed to your room: {subject}", - "@subscribedToYourRoom": { - "description": "Notification message when someone subscribes to your room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Room" - } - } - }, - "startedFollowingYou": "{username} started following you", - "@startedFollowingYou": { - "description": "Notification message when someone starts following you.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "youHaveNewNotification": "You have a new notification", - "@youHaveNewNotification": { - "description": "Generic title for a new notification." - }, - "hangOnGoodThingsTakeTime": "Hang on, Good Things take time 🔍", - "@hangOnGoodThingsTakeTime": { - "description": "A friendly message displayed during a long loading process." - }, - "resonateOpenSourceProject": "Resonate is an open source project maintained by AOSSIE. Checkout our github to contribute.", - "@resonateOpenSourceProject": { - "description": "Informational text about the open-source nature of the project." - }, - "mute": "Mute", - "@mute": { - "description": "Button text to mute the microphone." - }, - "speakerLabel": "Speaker", - "@speakerLabel": { - "description": "Label for the speakerphone toggle." - }, - "audioOptions": "Audio Options", - "@audioOptions": { - "description": "Label for the audio options/settings button." - }, - "end": "End", - "@end": { - "description": "Button text to end a call or session." - }, - "saveChanges": "Save changes", - "@saveChanges": { - "description": "Button text to save modified settings or profile information." - }, - "discard": "DISCARD", - "@discard": { - "description": "Button text to discard changes." - }, - "save": "SAVE", - "@save": { - "description": "Button text to save changes." - }, - "changeProfilePicture": "Change profile picture", - "@changeProfilePicture": { - "description": "Button text to open the profile picture selection options." - }, - "camera": "Camera", - "@camera": { - "description": "Option to use the camera to take a picture." - }, - "gallery": "Gallery", - "@gallery": { - "description": "Option to choose a picture from the gallery." - }, - "remove": "Remove", - "@remove": { - "description": "Button text to remove an item, like a profile picture." - }, - "created": "Created {date}", - "@created": { - "description": "Displays the creation date of a story or content.", - "placeholders": { - "date": { - "type": "String", - "example": "Jan 15, 2023" - } - } - }, - "chapters": "Chapters", - "@chapters": { - "description": "Header for the list of chapters in a story." - }, - "deleteStory": "Delete Story", - "@deleteStory": { - "description": "Button text to delete a story." - }, - "createdBy": "Created by {creatorName}", - "@createdBy": { - "description": "Displays the name of the content creator.", - "placeholders": { - "creatorName": { - "type": "String", - "example": "John Doe" - } - } - }, - "start": "Start", - "@start": { - "description": "Button text to start an activity or session." - }, - "unsubscribe": "Unsubscribe", - "@unsubscribe": { - "description": "Button text to unsubscribe from a room or notification." - }, - "subscribe": "Subscribe", - "@subscribe": { - "description": "Button text to subscribe to a room or notification." - }, - "storyCategory": "{category, select, drama{Drama} comedy{Comedy} horror{Horror} romance{Romance} thriller{Thriller} spiritual{Spiritual} other{Other}}", - "@storyCategory": { - "description": "Selects the appropriate category name for a story based on a key.", - "placeholders": { - "category": { - "type": "String", - "example": "drama" - } - } - }, - "chooseTheme": "{category, select, classicTheme{Classic} timeTheme{Time} vintageTheme{Vintage} amberTheme{Amber} forestTheme{Forest} creamTheme{Cream} other{Other}}", - "@chooseTheme": { - "description": "Selects the appropriate theme name based on a key.", - "placeholders": { - "category": { - "type": "String", - "example": "classic" - } - } - }, - "minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", - "@minutesAgo": { - "description": "Displays time in a relative format for minutes.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", - "@hoursAgo": { - "description": "Displays time in a relative format for hours.", - "placeholders": { - "count": { - "type": "int", - "example": "2" - } - } - }, - "daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}", - "@daysAgo": { - "description": "Displays time in a relative format for days.", - "placeholders": { - "count": { - "type": "int", - "example": "3" - } - } - }, - "by": "by", - "@by": { - "description": "A preposition, typically used between a content title and the author's name." - }, - "likes": "Likes", - "@likes": { - "description": "Label for the number of likes." - }, - "lengthMinutes": "min", - "@lengthMinutes": { - "description": "Abbreviation for 'minutes', used to display audio length." - }, - "requiredField": "Required field", - "@requiredField": { - "description": "Error message for a mandatory field that was left empty." - }, - "onlineUsers": "Online Users", - "@onlineUsers": { - "description": "Header for the list of users who are currently online." - }, - "noOnlineUsers": "No users currently online", - "@noOnlineUsers": { - "description": "Message displayed when no users are online." - }, - "chooseUser": "Choose User to chat with", - "@chooseUser": { - "description": "Instructional text to select a user to start a chat." - }, - "quickMatch": "Quick Match", - "@quickMatch": { - "description": "Button text for a feature that quickly matches the user with a random chat partner." - }, - "story": "Story", - "@story": { - "description": "Label for a story." - }, - "user": "User", - "@user": { - "description": "Label for a user." - }, - "following": "Following", - "@following": { - "description": "Label for the list of users that the current user is following." - }, - "followers": "Followers", - "@followers": { - "description": "Label for the list of users who are following the current user." - }, - "friendRequests": "Friend Requests", - "@friendRequests": { - "description": "Header for the list of incoming friend requests." - }, - "friendRequestSent": "Friend request sent", - "@friendRequestSent": { - "description": "Title for the confirmation message after sending a friend request." - }, - "friendRequestSentTo": "Your friend request to {username} has been sent.", - "@friendRequestSentTo": { - "description": "Confirmation message after sending a friend request to a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestCancelled": "Friend request cancelled", - "@friendRequestCancelled": { - "description": "Title for the confirmation message after cancelling a friend request." - }, - "friendRequestCancelledTo": "Your friend request to {username} has been cancelled.", - "@friendRequestCancelledTo": { - "description": "Confirmation message after cancelling a friend request to a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "requested": "Requested", - "@requested": { - "description": "A status label on a button indicating a friend request has been sent." - }, - "friends": "Friends", - "@friends": { - "description": "A status label or header for the list of friends." - }, - "addFriend": "Add Friend", - "@addFriend": { - "description": "Button text to send a friend request." - }, - "friendRequestAccepted": "Friend request accepted", - "@friendRequestAccepted": { - "description": "Title for the confirmation message after accepting a friend request." - }, - "friendRequestAcceptedTo": "You are now friends with {username}.", - "@friendRequestAcceptedTo": { - "description": "Confirmation message after accepting a friend request from a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestDeclined": "Friend request declined", - "@friendRequestDeclined": { - "description": "Title for the confirmation message after declining a friend request." - }, - "friendRequestDeclinedTo": "You have declined the friend request from {username}.", - "@friendRequestDeclinedTo": { - "description": "Confirmation message after declining a friend request from a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "accept": "Accept", - "@accept": { - "description": "Button text to accept a request or invitation." - }, - "callDeclined": "Call declined", - "@callDeclined": { - "description": "Title for the message when a call is declined." - }, - "callDeclinedTo": "User {username} declined the call.", - "@callDeclinedTo": { - "description": "Message indicating that a specific user declined a call.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "checkForUpdates": "Check Updates", - "@checkForUpdates": { - "description": "Button text to manually check for application updates." - }, - "updateNow": "Update Now", - "@updateNow": { - "description": "Button text to start the update process." - }, - "updateLater": "Later", - "@updateLater": { - "description": "Button text to postpone an update." - }, - "updateSuccessful": "Update Successful", - "@updateSuccessful": { - "description": "Title for the success message after an update." - }, - "updateSuccessfulMessage": "Resonate has been updated successfully!", - "@updateSuccessfulMessage": { - "description": "Success message confirming the application has been updated." - }, - "updateCancelled": "Update Cancelled", - "@updateCancelled": { - "description": "Title for the message when an update is cancelled." - }, - "updateCancelledMessage": "Update was cancelled by user", - "@updateCancelledMessage": { - "description": "Message indicating the user cancelled the update process." - }, - "updateFailed": "Update Failed", - "@updateFailed": { - "description": "Title for the message when an update fails." - }, - "updateFailedMessage": "Failed to update. Please try updating from Play Store manually.", - "@updateFailedMessage": { - "description": "Error message when an in-app update fails, suggesting a manual update." - }, - "updateError": "Update Error", - "@updateError": { - "description": "Generic title for an update-related error." - }, - "updateErrorMessage": "An error occurred while updating. Please try again.", - "@updateErrorMessage": { - "description": "Generic error message for an update that failed due to an unknown error." - }, - "platformNotSupported": "Platform Not Supported", - "@platformNotSupported": { - "description": "Title for an error when a feature is not supported on the current platform." - }, - "platformNotSupportedMessage": "Update checking is only available on Android devices", - "@platformNotSupportedMessage": { - "description": "Message explaining that the in-app update feature is platform-specific." - }, - "updateCheckFailed": "Update Check Failed", - "@updateCheckFailed": { - "description": "Title for an error when the app fails to check for updates." - }, - "updateCheckFailedMessage": "Could not check for updates. Please try again later.", - "@updateCheckFailedMessage": { - "description": "Error message when the check for updates process fails." - }, - "upToDateTitle": "You're Up to Date!", - "@upToDateTitle": { - "description": "Title for the message when the app is already up to date." - }, - "upToDateMessage": "You're using the latest version of Resonate", - "@upToDateMessage": { - "description": "Message confirming that no update is needed." - }, - "updateAvailableTitle": "Update Available!", - "@updateAvailableTitle": { - "description": "Title for the dialog informing the user about a new update." - }, - "updateAvailableMessage": "A new version of Resonate is available on Play Store", - "@updateAvailableMessage": { - "description": "Message prompting the user to update the app from the Play Store." - }, - "updateFeaturesImprovement": "Get the latest features and improvements!", - "@updateFeaturesImprovement": { - "description": "A message highlighting the benefit of updating the application." - }, - "failedToRemoveRoom": "Failed to remove room", - "@failedToRemoveRoom": { - "description": "Error message when unable to remove a room from the list" - }, - "roomRemovedSuccessfully": "Room removed from your list successfully", - "@roomRemovedSuccessfully": { - "description": "Success message when a room is successfully removed from the user's list" - }, - "alert": "Alert", - "@alert": { - "description": "Title for an alert dialog." - }, - "removedFromRoom": "You have been reported or removed from the room", - "@removedFromRoom": { - "description": "Message shown when a user is removed or reported from a room." - }, - "reportType": "{type, select, harassment{Harassment / Hate Speech} abuse{Abusive content / Violence} spam{Spam / Scams / Fraud} impersonation{Impersonation / Fake Accounts} illegal{Illegal Activities} selfharm{Self-harm / Suicide / Mental health} misuse{Misuse of platform} other{Other}}", - "@reportType": { - "description": "Selects the appropriate report type label based on a key.", - "placeholders": { - "type": { - "type": "String", - "example": "harassment" - } - } - }, - "userBlockedFromResonate": "You have received multiple reports from users and you have been blocked from using Resonate. Please contact AOSSIE if you believe this is a mistake.", - "@userBlockedFromResonate": { - "description": "Message shown when a user is blocked from using Resonate." - }, - "reportParticipant": "Report Participant", - "@reportParticipant": { - "description": "Label for the button to report a participant." - }, - "selectReportType": "Please select a report type", - "@selectReportType": { - "description": "Prompt for the user to select a report type." - }, - "reportSubmitted": "Report Submitted Successfully", - "@reportSubmitted": { - "description": "Message shown when a report is submitted successfully." - }, - "reportFailed": "Report Submission Failed", - "@reportFailed": { - "description": "Message shown when a report submission fails." - }, - "additionalDetailsOptional": "Additional details (optional)", - "@additionalDetailsOptional": { - "description": "Label for an optional input field for additional details in a report." - }, - "submitReport": "Submit Report", - "@submitReport": { - "description": "Button text to submit a report." - }, - "actionBlocked": "Action Blocked", - "@actionBlocked": { - "description": "Title for a message indicating that a user action is blocked." - }, - "cannotStopRecording": "You cannot stop the recording manually, the recording will be stopped when the room is closed.", - "@cannotStopRecording": { - "description": "Message explaining why a user cannot stop a recording manually." - }, - "liveChapter": "Live Chapter", - "@liveChapter": { - "description": "Label indicating that a chapter is currently live." - }, - "viewOrEditLyrics": "View or Edit Lyrics", - "@viewOrEditLyrics": { - "description": "Button text to view or edit the lyrics of a chapter." - }, - "close": "Close", - "@close": { - "description": "Label for the button to close a dialog." - }, - "verifyChapterDetails": "Verify Chapter Details", - "@verifyChapterDetails": { - "description": "Title for the screen where Live chapter details are verified before publishing." - }, - "author": "Author", - "@author": { - "description": "Label for the author of a story or chapter." - }, - "startLiveChapter": "Start a Live Chapter", - "@startLiveChapter": { - "description": "Title for the screen where a live chapter is initiated." - }, - "fillAllFields": "Please fill in all required fields", - "@fillAllFields": { - "description": "Error message when required fields are not filled in a form." - }, - "noRecordingError": "You have not recorded anything for the chapter. Please record a chapter before exiting the room", - "@noRecordingError": { - "description": "Error message when trying to exit a live chapter room without any recording." - }, - "audioOutput": "Audio Output", - "@audioOutput": { - "description": "Title for audio output device selector." - }, - "selectPreferredSpeaker": "Select your preferred speaker", - "@selectPreferredSpeaker": { - "description": "Subtitle for audio device selector dialog." - }, - "noAudioOutputDevices": "No audio output devices detected", - "@noAudioOutputDevices": { - "description": "Message shown when no audio output devices are available." - }, - "refresh": "Refresh", - "@refresh": { - "description": "Button text to refresh audio device list." - }, - "done": "Done", - "@done": { - "description": "Button text to close audio device selector." - }, - "deleteMessageTitle": "Delete Message", -"@deleteMessageTitle": { - "description": "Title shown in the delete message confirmation dialog." -}, -"deleteMessageContent": "Are you sure you want to delete this message?", -"@deleteMessageContent": { - "description": "Confirmation text asking the user if they want to delete a message." -}, -"thisMessageWasDeleted": "This message was deleted", -"failedToDeleteMessage": "Failed to delete message", - -"noFriendsYet": "No Friends Yet", -"@noFriendsYet": { - "description": "Title shown when user has no friends." -}, -"noFriendsDescription": "Your friends list is empty. Start connecting with people and grow your network!", -"@noFriendsDescription": { - "description": "Description shown when user has no friends." -}, -"findFriends": "Find Friends", -"@findFriends": { - "description": "Button text to navigate to find friends screen." -}, -"inviteFriend": "Invite a Friend", -"@inviteFriend": { - "description": "Button text to invite friends to the app." -}, -"noFriendRequestsYet": "No Friend Requests", -"@noFriendRequestsYet": { - "description": "Title shown when user has no friend requests." -}, -"noFriendRequestsDescription": "You don't have any pending friend requests. Invite your friends to connect!", -"@noFriendRequestsDescription": { - "description": "Description shown when user has no friend requests." -}, -"inviteToResonate": "Hey! Join me on Resonate - a social audio platform where every voice is valued. Download now: {url}", -"@inviteToResonate": { - "description": "Text used when inviting friends to the app.", - "placeholders": { - "url": { - "type": "String" - } - } -}, -"@thisMessageWasDeleted": { - "description": "Status text shown when a previously sent message has been deleted." -}, - -"failedToDeleteMessage": "Failed to delete message", -"@failedToDeleteMessage": { - "description": "Error message shown when the system is unable to delete a message." -}, - -"usernameInvalidFormat": "Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.", -"@usernameInvalidFormat": { - "description": "Validation error displayed when the user enters a username with unsupported characters." -}, - -"usernameAlreadyTaken": "This username is already taken. Try a different one.", -"@usernameAlreadyTaken": { - "description": "Error shown when the chosen username is unavailable because another user has already registered it." -} - - -} \ No newline at end of file +{ + "@@locale": "en", + "title": "Resonate", + "@title": { + "description": "The title of the application." + }, + "roomDescription": "Be polite and respect the other person's opinion. Avoid rude comments.", + "@roomDescription": { + "description": "Guideline message for users in a chat room." + }, + "hidePassword": "Hide Password", + "@hidePassword": { + "description": "Button text to conceal the password field." + }, + "showPassword": "Show Password", + "@showPassword": { + "description": "Button text to reveal the password field." + }, + "passwordEmpty": "Password cannot be empty", + "@passwordEmpty": { + "description": "Error message when the password field is left blank." + }, + "password": "Password", + "@password": { + "description": "Label for the password input field." + }, + "confirmPassword": "Confirm Password", + "@confirmPassword": { + "description": "Label for the confirm password input field." + }, + "passwordsNotMatch": "Passwords do not match", + "@passwordsNotMatch": { + "description": "Error message when password and confirmation password do not match." + }, + "userCreatedStories": "User Created Stories", + "@userCreatedStories": { + "description": "Header for the section showing stories created by another user." + }, + "yourStories": "Your Stories", + "@yourStories": { + "description": "Header for the section showing stories created by the current user." + }, + "userNoStories": "User has not created any story", + "@userNoStories": { + "description": "Message displayed when viewing another user's profile and they have no stories." + }, + "youNoStories": "You have not created any story", + "@youNoStories": { + "description": "Message displayed on the current user's profile when they have no stories." + }, + "follow": "Follow", + "@follow": { + "description": "Button text to follow a user." + }, + "editProfile": "Edit Profile", + "@editProfile": { + "description": "Button text to navigate to the profile editing screen." + }, + "verifyEmail": "Verify Email", + "@verifyEmail": { + "description": "Button or link text to start the email verification process." + }, + "verified": "Verified", + "@verified": { + "description": "A status label indicating that the user's email has been verified." + }, + "profile": "Profile", + "@profile": { + "description": "Label for the user profile section or page." + }, + "userLikedStories": "User Liked Stories", + "@userLikedStories": { + "description": "Header for the section showing stories liked by another user." + }, + "yourLikedStories": "Your Liked Stories", + "@yourLikedStories": { + "description": "Header for the section showing stories liked by the current user." + }, + "userNoLikedStories": "User has not liked any story", + "@userNoLikedStories": { + "description": "Message displayed when viewing another user's profile and they have no liked stories." + }, + "youNoLikedStories": "You have not liked any story", + "@youNoLikedStories": { + "description": "Message displayed on the current user's profile when they have no liked stories." + }, + "live": "Live", + "@live": { + "description": "Tab or label for live audio rooms." + }, + "upcoming": "Upcoming", + "@upcoming": { + "description": "Tab or label for scheduled/upcoming audio rooms." + }, + "noAvailableRoom": "{isRoom, select, true{No Room Available} false{No Upcoming Room Available} other{No Room Information Available}}\nGet Started By Adding One Below!", + "@noAvailableRoom": { + "description": "Message when no room or upcoming room is available, with a call to action.", + "placeholders": { + "isRoom": { + "type": "String", + "example": "true" + } + } + }, + "user1": "User 1", + "@user1": { + "description": "A generic placeholder name for a user." + }, + "user2": "User 2", + "@user2": { + "description": "A second generic placeholder name for a user." + }, + "you": "You", + "@you": { + "description": "Label to identify the current user in a list or chat." + }, + "areYouSure": "Are you sure?", + "@areYouSure": { + "description": "Confirmation prompt title." + }, + "loggingOut": "You are logging out of Resonate.", + "@loggingOut": { + "description": "Confirmation prompt message for logging out." + }, + "yes": "Yes", + "@yes": { + "description": "Generic confirmation button text." + }, + "no": "No", + "@no": { + "description": "Generic cancellation button text." + }, + "incorrectEmailOrPassword": "Incorrect email or password", + "@incorrectEmailOrPassword": { + "description": "Error message for failed login attempt." + }, + "passwordShort": "Password is less than 8 characters", + "@passwordShort": { + "description": "Error message for a password that is too short." + }, + "tryAgain": "Try Again!", + "@tryAgain": { + "description": "Button text to retry a failed action." + }, + "success": "Success", + "@success": { + "description": "Generic title for a successful operation." + }, + "passwordResetSent": "Password reset email sent!", + "@passwordResetSent": { + "description": "Success message after a password reset email has been dispatched." + }, + "error": "Error", + "@error": { + "description": "Generic title for a failed operation." + }, + "resetPassword": "Reset Password", + "@resetPassword": { + "description": "Title or button text for the reset password feature." + }, + "enterNewPassword": "Enter your new password", + "@enterNewPassword": { + "description": "Instructional text on the reset password screen." + }, + "newPassword": "New Password", + "@newPassword": { + "description": "Label for the new password input field." + }, + "setNewPassword": "Set New Password", + "@setNewPassword": { + "description": "Button text to confirm setting a new password." + }, + "emailChanged": "Email Changed", + "@emailChanged": { + "description": "Title for the success dialog after an email change." + }, + "emailChangeSuccess": "Email changed successfully!", + "@emailChangeSuccess": { + "description": "Success message after changing the user's email." + }, + "failed": "Failed", + "@failed": { + "description": "Generic title for a failed operation." + }, + "emailChangeFailed": "Failed to change email", + "@emailChangeFailed": { + "description": "Error message when an email change operation fails." + }, + "oops": "Oops!", + "@oops": { + "description": "An informal title for an error or warning message." + }, + "emailExists": "Email already exists", + "@emailExists": { + "description": "Error message when a user tries to register or change to an email that is already in use." + }, + "changeEmail": "Change Email", + "@changeEmail": { + "description": "Title or button text for the change email feature." + }, + "enterValidEmail": "Please enter a valid email address", + "@enterValidEmail": { + "description": "Error message for an invalid email format." + }, + "newEmail": "New Email", + "@newEmail": { + "description": "Label for the new email input field." + }, + "currentPassword": "Current Password", + "@currentPassword": { + "description": "Label for the current password input field, used for verification." + }, + "emailChangeInfo": "For added security, you must provide your account's current password when changing your email address. After changing your email, use the updated email for future logins.", + "@emailChangeInfo": { + "description": "Informational text explaining the process and security requirements for changing an email address for standard users." + }, + "oauthUsersMessage": "(Only for users who logged in using Google or Github)", + "@oauthUsersMessage": { + "description": "A message to specify that the following instructions are for OAuth users." + }, + "oauthUsersEmailChangeInfo": "To change your email, please enter a new password in the \"Current Password\" field. Be sure to remember this password, as you'll need it for any future email changes. Moving forward, you can log in using Google/GitHub or your new email and password combination.", + "@oauthUsersEmailChangeInfo": { + "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." + }, + "resonateTagline": "Enter a world of limitless\nconversations.", + "@resonateTagline": { + "description": "The application's tagline, used on splash or login screens." + }, + "signInWithEmail": "Sign in with Email", + "@signInWithEmail": { + "description": "Button text for signing in using email and password." + }, + "or": "Or", + "@or": { + "description": "A separator text, typically used between different login options." + }, + "continueWith": "Continue with", + "@continueWith": { + "description": "Informational text preceding a list of third-party login providers." + }, + "continueWithGoogle": "Continue with Google", + "@continueWithGoogle": { + "description": "Button text for signing in with a Google account." + }, + "continueWithGitHub": "Continue with GitHub", + "@continueWithGitHub": { + "description": "Button text for signing in with a GitHub account." + }, + "resonateLogo": "Resonate Logo", + "@resonateLogo": { + "description": "Accessibility text for the Resonate application logo image." + }, + "iAlreadyHaveAnAccount": "I already have an account", + "@iAlreadyHaveAnAccount": { + "description": "Text for a link or button to navigate to the login screen from the sign-up screen." + }, + "createNewAccount": "Create new account", + "@createNewAccount": { + "description": "Text for a link or button to navigate to the sign-up screen from the login screen." + }, + "userProfile": "User profile", + "@userProfile": { + "description": "Accessibility text for a user's profile picture or avatar." + }, + "passwordIsStrong": "Password is strong", + "@passwordIsStrong": { + "description": "Validation message indicating that the entered password meets all strength requirements." + }, + "admin": "Admin", + "@admin": { + "description": "Label for the Admin user role in a room." + }, + "moderator": "Moderator", + "@moderator": { + "description": "Label for the Moderator user role in a room." + }, + "speaker": "Speaker", + "@speaker": { + "description": "Label for the Speaker user role in a room." + }, + "listener": "Listener", + "@listener": { + "description": "Label for the Listener user role in a room." + }, + "removeModerator": "Remove Moderator", + "@removeModerator": { + "description": "Menu item text to revoke moderator privileges from a user." + }, + "kickOut": "Kick Out", + "@kickOut": { + "description": "Menu item text to remove a user from a room." + }, + "addModerator": "Add Moderator", + "@addModerator": { + "description": "Menu item text to grant moderator privileges to a user." + }, + "addSpeaker": "Add Speaker", + "@addSpeaker": { + "description": "Menu item text to grant speaker privileges to a listener." + }, + "makeListener": "Make Listener", + "@makeListener": { + "description": "Menu item text to change a speaker's role to listener." + }, + "pairChat": "Pair Chat", + "@pairChat": { + "description": "A feature name for one-on-one random chat." + }, + "chooseIdentity": "Choose Identity", + "@chooseIdentity": { + "description": "Prompt for the user to choose their identity, e.g., anonymous or public." + }, + "selectLanguage": "Select Language", + "@selectLanguage": { + "description": "Label for the language selection setting." + }, + "noConnection": "No Connection", + "@noConnection": { + "description": "Title indicating that there is no internet connection." + }, + "loadingDialog": "Loading Dialog", + "@loadingDialog": { + "description": "Accessibility text for a loading indicator or spinner." + }, + "createAccount": "Create Account", + "@createAccount": { + "description": "Button text or page title for the account creation screen." + }, + "enterValidEmailAddress": "Enter Valid Email Address", + "@enterValidEmailAddress": { + "description": "Error message shown for an invalid email format." + }, + "email": "Email", + "@email": { + "description": "Label for the email input field." + }, + "passwordRequirements": "Password must be at least 8 characters long", + "@passwordRequirements": { + "description": "Instructional text listing the requirements for a valid password, part 1." + }, + "includeNumericDigit": "Include at least 1 numeric digit", + "@includeNumericDigit": { + "description": "Instructional text listing the requirements for a valid password, part 2." + }, + "includeUppercase": "Include at least 1 uppercase letter", + "@includeUppercase": { + "description": "Instructional text listing the requirements for a valid password, part 3." + }, + "includeLowercase": "Include at least 1 lowercase letter", + "@includeLowercase": { + "description": "Instructional text listing the requirements for a valid password, part 4." + }, + "includeSymbol": "Include at least 1 symbol", + "@includeSymbol": { + "description": "Instructional text listing the requirements for a valid password, part 5." + }, + "signedUpSuccessfully": "Signed Up Successfully", + "@signedUpSuccessfully": { + "description": "Title for a success message after account creation." + }, + "newAccountCreated": "You have successfully created a new account", + "@newAccountCreated": { + "description": "Success message confirming that a new account has been created." + }, + "signUp": "Sign up", + "@signUp": { + "description": "Button text to submit the sign-up form." + }, + "login": "Login", + "@login": { + "description": "Button text to submit the login form." + }, + "settings": "Settings", + "@settings": { + "description": "Title for the settings page." + }, + "accountSettings": "Account settings", + "@accountSettings": { + "description": "Sub-header for account-related settings." + }, + "account": "Account", + "@account": { + "description": "Label for the account settings section." + }, + "appSettings": "App settings", + "@appSettings": { + "description": "Sub-header for application-related settings." + }, + "themes": "Themes", + "@themes": { + "description": "Label for the theme selection setting." + }, + "about": "About", + "@about": { + "description": "Label for the 'About' section or page of the app or a story." + }, + "other": "Other", + "@other": { + "description": "A generic category label for miscellaneous settings or items." + }, + "contribute": "Contribute", + "@contribute": { + "description": "Label for a section encouraging users to contribute to the project." + }, + "appPreferences": "App Preferences", + "@appPreferences": { + "description": "Label for the app preferences settings page." + }, + "transcriptionModel": "Transcription Model", + "@transcriptionModel": { + "description": "Section title for choosing AI transcription model." + }, + "transcriptionModelDescription": "Choose the AI model for voice transcription. Larger models are more accurate but slower and require more storage.", + "@transcriptionModelDescription": { + "description": "Description text explaining transcription model choices." + }, + "whisperModelTiny": "Tiny", + "@whisperModelTiny": { + "description": "Name of the smallest Whisper AI model." + }, + "whisperModelTinyDescription": "Fastest, least accurate (~39 MB)", + "@whisperModelTinyDescription": { + "description": "Description of the Tiny Whisper model performance and size." + }, + "whisperModelBase": "Base", + "@whisperModelBase": { + "description": "Name of the base Whisper AI model." + }, + "whisperModelBaseDescription": "Balanced speed and accuracy (~74 MB)", + "@whisperModelBaseDescription": { + "description": "Description of the Base Whisper model performance and size." + }, + "whisperModelSmall": "Small", + "@whisperModelSmall": { + "description": "Name of the small Whisper AI model." + }, + "whisperModelSmallDescription": "Good accuracy, slower (~244 MB)", + "@whisperModelSmallDescription": { + "description": "Description of the Small Whisper model performance and size." + }, + "whisperModelMedium": "Medium", + "@whisperModelMedium": { + "description": "Name of the medium Whisper AI model." + }, + "whisperModelMediumDescription": "High accuracy, slower (~769 MB)", + "@whisperModelMediumDescription": { + "description": "Description of the Medium Whisper model performance and size." + }, + "whisperModelLargeV1": "Large V1", + "@whisperModelLargeV1": { + "description": "Name of the large V1 Whisper AI model." + }, + "whisperModelLargeV1Description": "Most accurate, slowest (~1.55 GB)", + "@whisperModelLargeV1Description": { + "description": "Description of the Large V1 Whisper model performance and size." + }, + "whisperModelLargeV2": "Large V2", + "@whisperModelLargeV2": { + "description": "Name of the large V2 Whisper AI model." + }, + "whisperModelLargeV2Description": "Improved large model with higher accuracy (~1.55 GB)", + "@whisperModelLargeV2Description": { + "description": "Description of the Large V2 Whisper model performance and size." + }, + "modelDownloadInfo": "Models are downloaded when first used. We recommend using Base, Small, or Medium. Large models require very high-end devices.", + "@modelDownloadInfo": { + "description": "Information message about model download." + }, + "logOut": "Log out", + "@logOut": { + "description": "Button text to log the user out of their account." + }, + "participants": "Participants", + "@participants": { + "description": "Label or title for the list of participants in a room." + }, + "delete": "delete", + "@delete": { + "description": "Generic button text for a delete action. Parameter for toRoomAction." + }, + "leave": "leave", + "@leave": { + "description": "Generic button text for a leave action. Parameter for toRoomAction." + }, + "leaveButton": "Leave", + "@leaveButton": { + "description": "Button text to leave a room or conversation." + }, + "findingRandomPartner": "Finding a Random Partner For You", + "@findingRandomPartner": { + "description": "Status message shown while the system is searching for a random chat partner." + }, + "quickFact": "Quick fact", + "@quickFact": { + "description": "Header for a quick fact or tip, often shown during loading." + }, + "cancel": "Cancel", + "@cancel": { + "description": "Generic button text for a cancel action." + }, + "hide": "Remove", + "@hide": { + "description": "Button text to remove an item from view." + }, + "removeRoom": "Remove Room", + "@removeRoom": { + "description": "Dialog title for removing an upcoming room." + }, + "removeRoomFromList": "Remove from list", + "@removeRoomFromList": { + "description": "Tooltip text for the remove room button." + }, + "removeRoomConfirmation": "Are you sure you want to remove this upcoming room from your list?", + "@removeRoomConfirmation": { + "description": "Confirmation message asking if the user wants to remove an upcoming room from their list." + }, + "completeYourProfile": "Complete your Profile", + "@completeYourProfile": { + "description": "Page title or prompt for the user to finish setting up their profile." + }, + "uploadProfilePicture": "Upload profile picture", + "@uploadProfilePicture": { + "description": "Instructional text or button to upload a profile picture." + }, + "enterValidName": "Enter Valid Name", + "@enterValidName": { + "description": "Error message for an invalid name format." + }, + "name": "Name", + "@name": { + "description": "Label for the name input field." + }, + "username": "Username", + "@username": { + "description": "Label for the username input field." + }, + "enterValidDOB": "Enter Valid DOB", + "@enterValidDOB": { + "description": "Error message for an invalid date of birth." + }, + "dateOfBirth": "Date of Birth", + "@dateOfBirth": { + "description": "Label for the date of birth input field." + }, + "forgotPassword": "Forgot Password?", + "@forgotPassword": { + "description": "Link text for users who have forgotten their password." + }, + "next": "Next", + "@next": { + "description": "Button text to proceed to the next step in a process." + }, + "noStoriesExist": "No stories exist to present", + "@noStoriesExist": { + "description": "Message displayed when there are no stories available in a list." + }, + "enterVerificationCode": "Enter your\nVerification Code", + "@enterVerificationCode": { + "description": "Prompt for the user to enter the verification code sent to their email." + }, + "verificationCodeSent": "We sent a 6-digit verification code to\n", + "@verificationCodeSent": { + "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." + }, + "verificationComplete": "Verification Complete", + "@verificationComplete": { + "description": "Title for a success message after email verification." + }, + "verificationCompleteMessage": "Congratulations you have verified your Email", + "@verificationCompleteMessage": { + "description": "Success message confirming email verification." + }, + "verificationFailed": "Verification Failed", + "@verificationFailed": { + "description": "Title for an error message when verification fails." + }, + "otpMismatch": "OTP mismatch occurred please try again", + "@otpMismatch": { + "description": "Error message when the entered OTP is incorrect." + }, + "otpResent": "OTP resent", + "@otpResent": { + "description": "Confirmation message that the OTP has been resent." + }, + "requestNewCode": "Request a new code", + "@requestNewCode": { + "description": "Button text to request a new verification code." + }, + "requestNewCodeIn": "Request a new code in", + "@requestNewCodeIn": { + "description": "Text displayed before a countdown timer for requesting a new code." + }, + "clickPictureCamera": "Click picture using camera", + "@clickPictureCamera": { + "description": "Option to take a new photo using the device camera." + }, + "pickImageGallery": "Pick image from gallery", + "@pickImageGallery": { + "description": "Option to choose an existing image from the device gallery." + }, + "deleteMyAccount": "Delete My Account", + "@deleteMyAccount": { + "description": "Button text for the account deletion feature." + }, + "createNewRoom": "Create New Room", + "@createNewRoom": { + "description": "Button text or page title for creating a new audio room." + }, + "pleaseEnterScheduledDateTime": "Please Enter Scheduled Date-Time", + "@pleaseEnterScheduledDateTime": { + "description": "Error message when the scheduled date and time for a room is not provided." + }, + "scheduleDateTimeLabel": "Schedule Date Time", + "@scheduleDateTimeLabel": { + "description": "Label for the input field to schedule a room's date and time." + }, + "enterTags": "Enter tags", + "@enterTags": { + "description": "Placeholder or label for the input field for adding tags to a room or story." + }, + "joinCommunity": "Join Community", + "@joinCommunity": { + "description": "Button text or header for the community section." + }, + "followUsOnX": "Follow us on X", + "@followUsOnX": { + "description": "Link text to the company's profile on X (formerly Twitter)." + }, + "joinDiscordServer": "Join discord server", + "@joinDiscordServer": { + "description": "Link text to join the project's Discord server." + }, + "noLyrics": "No lyrics", + "@noLyrics": { + "description": "Message displayed when lyrics for an audio file are not available." + }, + "noStoriesInCategory": "No stories currently exist in the {categoryName} category to present", + "@noStoriesInCategory": { + "description": "Message when no stories exist in a specific category.", + "placeholders": { + "categoryName": { + "type": "String", + "example": "drama" + } + } + }, + "newChapters": "New Chapters", + "@newChapters": { + "description": "Header or label for the section to add or view new chapters of a story." + }, + "helpToGrow": "Help to grow", + "@helpToGrow": { + "description": "A call to action asking users to help the platform grow, often a header for sharing/rating." + }, + "share": "Share", + "@share": { + "description": "Button text for sharing content." + }, + "rate": "Rate", + "@rate": { + "description": "Button text for rating the app or content." + }, + "aboutResonate": "About Resonate", + "@aboutResonate": { + "description": "Title for the 'About' page of the Resonate application." + }, + "description": "Description", + "@description": { + "description": "Label for a description field." + }, + "confirm": "Confirm", + "@confirm": { + "description": "Generic button text for a confirm action." + }, + "classic": "Classic", + "@classic": { + "description": "Name of a theme option." + }, + "time": "Time", + "@time": { + "description": "Name of a theme option." + }, + "vintage": "Vintage", + "@vintage": { + "description": "Name of a theme option." + }, + "amber": "Amber", + "@amber": { + "description": "Name of a theme option." + }, + "forest": "Forest", + "@forest": { + "description": "Name of a theme option." + }, + "cream": "Cream", + "@cream": { + "description": "Name of a theme option." + }, + "none": "none", + "@none": { + "description": "Text representing no selection or absence of a value." + }, + "checkOutGitHub": "Check out our GitHub repository: {url}", + "@checkOutGitHub": { + "description": "Share message for the GitHub repository, including a placeholder for the URL.", + "placeholders": { + "url": { + "type": "String", + "example": "https://github.com/AOSSIE-Org/Resonate" + } + } + }, + "aossie": "AOSSIE", + "@aossie": { + "description": "The name of the organization." + }, + "aossieLogo": "aossie logo", + "@aossieLogo": { + "description": "Accessibility text for the AOSSIE organization logo." + }, + "errorLoadPackageInfo": "Could not load package info", + "@errorLoadPackageInfo": { + "description": "Error message when the application fails to load its package information (e.g., version)." + }, + "searchFailed": "Failed to search rooms. Please try again.", + "@searchFailed": { + "description": "Error message when searching for rooms fails." + }, + "updateAvailable": "Update Available", + "@updateAvailable": { + "description": "Title indicating that a new version of the app is available." + }, + "newVersionAvailable": "A new version is available!", + "@newVersionAvailable": { + "description": "Message informing the user about an available update." + }, + "upToDate": "Up to Date", + "@upToDate": { + "description": "Title indicating the application is on the latest version." + }, + "latestVersion": "You're using the latest version", + "@latestVersion": { + "description": "Message confirming the user has the latest version of the app." + }, + "profileCreatedSuccessfully": "Profile created successfully", + "@profileCreatedSuccessfully": { + "description": "Success message after a user's profile is created." + }, + "invalidScheduledDateTime": "Invalid Scheduled Date Time", + "@invalidScheduledDateTime": { + "description": "Error message for an invalid date-time format." + }, + "scheduledDateTimePast": "Scheduled Date Time cannot be in past", + "@scheduledDateTimePast": { + "description": "Error message when the user selects a past date-time for a scheduled event." + }, + "joinRoom": "Join Room", + "@joinRoom": { + "description": "Button text to join an audio room." + }, + "unknownUser": "Unknown", + "@unknownUser": { + "description": "Placeholder text for a user whose name is not available." + }, + "canceled": "canceled", + "@canceled": { + "description": "A status label indicating that an action was canceled." + }, + "english": "en", + "@english": { + "description": "The language code for English." + }, + "emailVerificationRequired": "Email Verification Required", + "@emailVerificationRequired": { + "description": "A title or message indicating that email verification is needed to proceed." + }, + "verify": "Verify", + "@verify": { + "description": "Button text to initiate a verification process." + }, + "audioRoom": "Audio Room", + "@audioRoom": { + "description": "Generic title for an audio room." + }, + "toRoomAction": "To {action} the room", + "@toRoomAction": { + "description": "A dynamic message for confirming an action on a room, like deleting or leaving.", + "placeholders": { + "action": { + "type": "String", + "example": "delete" + } + } + }, + "mailSentMessage": "mail sent", + "@mailSentMessage": { + "description": "A confirmation message indicating an email has been sent." + }, + "disconnected": "disconnected", + "@disconnected": { + "description": "A status label indicating a loss of connection." + }, + "micOn": "mic", + "@micOn": { + "description": "Accessibility label for the microphone button when it is on." + }, + "speakerOn": "speaker", + "@speakerOn": { + "description": "Accessibility label for the speakerphone button when it is on." + }, + "endChat": "end-chat", + "@endChat": { + "description": "Accessibility label for the end chat/call button." + }, + "monthJan": "Jan", + "@monthJan": { + "description": "Abbreviation for January." + }, + "monthFeb": "Feb", + "@monthFeb": { + "description": "Abbreviation for February." + }, + "monthMar": "March", + "@monthMar": { + "description": "Full name for March." + }, + "monthApr": "April", + "@monthApr": { + "description": "Full name for April." + }, + "monthMay": "May", + "@monthMay": { + "description": "Full name for May." + }, + "monthJun": "June", + "@monthJun": { + "description": "Full name for June." + }, + "monthJul": "July", + "@monthJul": { + "description": "Full name for July." + }, + "monthAug": "Aug", + "@monthAug": { + "description": "Abbreviation for August." + }, + "monthSep": "Sep", + "@monthSep": { + "description": "Abbreviation for September." + }, + "monthOct": "Oct", + "@monthOct": { + "description": "Abbreviation for October." + }, + "monthNov": "Nov", + "@monthNov": { + "description": "Abbreviation for November." + }, + "monthDec": "Dec", + "@monthDec": { + "description": "Abbreviation for December." + }, + "register": "Register", + "@register": { + "description": "Button text to register a new account." + }, + "newToResonate": "New to Resonate? ", + "@newToResonate": { + "description": "Text preceding the 'Create new account' link on the login screen." + }, + "alreadyHaveAccount": "Already have an account? ", + "@alreadyHaveAccount": { + "description": "Text preceding the 'Login' link on the sign-up screen." + }, + "checking": "Checking...", + "@checking": { + "description": "A temporary status message indicating a check is in progress." + }, + "forgotPasswordMessage": "Enter your registered email address to reset your password.", + "@forgotPasswordMessage": { + "description": "Instructional text on the forgot password screen." + }, + "usernameUnavailable": "Username Unavailable!", + "@usernameUnavailable": { + "description": "Title for the error when a chosen username is taken." + }, + "usernameInvalidOrTaken": "This username is invalid or either taken already.", + "@usernameInvalidOrTaken": { + "description": "Error message explaining why a username cannot be used." + }, + "otpResentMessage": "Please check your mail for a new OTP.", + "@otpResentMessage": { + "description": "Message informing the user to check their email for a new OTP." + }, + "connectionError": "There is a connection error. Please check your internet and try again.", + "@connectionError": { + "description": "Generic error message for network connection issues." + }, + "seconds": "seconds.", + "@seconds": { + "description": "The word 'seconds', often used after a countdown number." + }, + "unsavedChangesWarning": "If you proceed without saving, any unsaved changes will be lost.", + "@unsavedChangesWarning": { + "description": "A warning message shown when a user tries to leave a screen with unsaved changes." + }, + "deleteAccountPermanent": "This action will Delete Your Account Permanently. It is irreversible process. We will delete your username, email address, and all other data associated with your account. You will not be able to recover it.", + "@deleteAccountPermanent": { + "description": "A detailed warning message explaining the consequences of deleting an account." + }, + "giveGreatName": "Give a great name..", + "@giveGreatName": { + "description": "Placeholder text for a name input field, encouraging a creative name." + }, + "joinCommunityDescription": "By joining community you can Clear your doubts, Suggest for new features, Report issues you faced and More.", + "@joinCommunityDescription": { + "description": "A description of the benefits of joining the community." + }, + "resonateDescription": "Resonate is a social media platform, where every voice is valued. Share your thoughts, stories, and experiences with others. Start your audio journey now. Dive into diverse discussions and topics. Find rooms that resonate with you and become a part of the community. Join the conversation! Explore rooms, connect with friends, and share your voice with the world.", + "@resonateDescription": { + "description": "A short, user-facing description of the Resonate application." + }, + "resonateFullDescription": "Resonate is a revolutionary voice-based social media platform where every voice matters. \nJoin real-time audio conversations, participate in diverse discussions, and connect with \nlike-minded individuals. Our platform offers:\n- Live audio rooms with topic-based discussions\n- Seamless social networking through voice\n- Community-driven content moderation\n- Cross-platform compatibility\n- End-to-end encrypted private conversations\n\nDeveloped by the AOSSIE open source community, we prioritize user privacy and \ncommunity-driven development. Join us in shaping the future of social audio!", + "@resonateFullDescription": { + "description": "A comprehensive, detailed description of the Resonate application, its features, and its mission." + }, + "stable": "Stable", + "@stable": { + "description": "A label indicating a stable, non-beta version of the app." + }, + "usernameCharacterLimit": "Username should contain more than 7 characters.", + "@usernameCharacterLimit": { + "description": "Error message when a chosen username is too short." + }, + "submit": "Submit", + "@submit": { + "description": "Generic button text for submitting a form." + }, + "anonymous": "Anonymous", + "@anonymous": { + "description": "Label for an anonymous user or identity." + }, + "noSearchResults": "No Search Results", + "@noSearchResults": { + "description": "Message displayed when a search returns no results." + }, + "searchRooms": "Search rooms...", + "@searchRooms": { + "description": "Placeholder text for room search input field." + }, + "searchingRooms": "Searching rooms...", + "@searchingRooms": { + "description": "Loading message shown while searching for rooms." + }, + "clearSearch": "Clear search", + "@clearSearch": { + "description": "Text for button to clear search results." + }, + "searchError": "Search Error", + "@searchError": { + "description": "Title for search error messages." + }, + "searchRoomsError": "Failed to search rooms. Please try again.", + "@searchRoomsError": { + "description": "Error message when room search fails." + }, + "searchUpcomingRoomsError": "Failed to search upcoming rooms. Please try again.", + "@searchUpcomingRoomsError": { + "description": "Error message when upcoming room search fails." + }, + "search": "Search", + "@search": { + "description": "Tooltip text for search button." + }, + "clear": "Clear", + "@clear": { + "description": "Tooltip text for clear button." + }, + "shareRoomMessage": "🚀 Check out this amazing room: {roomName}!\n\n📖 Description: {description}\n👥 Join {participants} participants now!", + "@shareRoomMessage": { + "description": "The default message template used when sharing a room.", + "placeholders": { + "roomName": { + "type": "String", + "example": "Discussion Room" + }, + "description": { + "type": "String", + "example": "A great place to discuss" + }, + "participants": { + "type": "int", + "example": "5" + } + } + }, + "participantsCount": "{count} Participants", + "@participantsCount": { + "description": "Displays the number of participants in a room.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "join": "Join", + "@join": { + "description": "Generic button text to join an activity or group." + }, + "invalidTags": "Invalid Tag:", + "@invalidTags": { + "description": "Error message prefix for an invalid tag." + }, + "cropImage": "Crop Image", + "@cropImage": { + "description": "Title or button text for the image cropping tool." + }, + "profileSavedSuccessfully": "Profile updated", + "@profileSavedSuccessfully": { + "description": "Short success message indicating profile changes have been saved." + }, + "profileUpdatedSuccessfully": "All changes are saved successfully.", + "@profileUpdatedSuccessfully": { + "description": "Detailed success message confirming profile update." + }, + "profileUpToDate": "Profile up to date", + "@profileUpToDate": { + "description": "Title for the message when there are no changes to save on the profile." + }, + "noChangesToSave": "There are no new changes made, Nothing to save.", + "@noChangesToSave": { + "description": "Message displayed when the user tries to save their profile without making any changes." + }, + "connectionFailed": "Connection Failed", + "@connectionFailed": { + "description": "Generic title for connection-related errors." + }, + "unableToJoinRoom": "Unable to join the room. Please check your network and try again.", + "@unableToJoinRoom": { + "description": "Error message when a user fails to join a room due to connection issues." + }, + "connectionLost": "Connection Lost", + "@connectionLost": { + "description": "Title indicating that the connection to a service was lost." + }, + "unableToReconnect": "Unable to reconnect to the room. Please try rejoining.", + "@unableToReconnect": { + "description": "Error message when the app fails to reconnect the user to a room." + }, + "invalidFormat": "Invalid Format!", + "@invalidFormat": { + "description": "Generic error message for data with an incorrect format." + }, + "usernameAlphanumeric": "Username must be alphanumeric and should not contain special characters.", + "@usernameAlphanumeric": { + "description": "Error message detailing the character requirements for a valid username." + }, + "userProfileCreatedSuccessfully": "Your user profile is successfully created.", + "@userProfileCreatedSuccessfully": { + "description": "Success message after a user completes their profile setup." + }, + "emailVerificationMessage": "To proceed, verify your email address.", + "@emailVerificationMessage": { + "description": "An instructional message prompting the user to verify their email." + }, + "addNewChaptersToStory": "Add New Chapters to {storyName}", + "@addNewChaptersToStory": { + "description": "Title for the screen where new chapters are added to an existing story.", + "placeholders": { + "storyName": { + "type": "String", + "example": "My Story" + } + } + }, + "currentChapters": "Current Chapters", + "@currentChapters": { + "description": "Header for the list of existing chapters in a story." + }, + "sourceCodeOnGitHub": "Source code on GitHub", + "@sourceCodeOnGitHub": { + "description": "Link text to the project's source code on GitHub." + }, + "createAChapter": "Create a Chapter", + "@createAChapter": { + "description": "Title for the screen where a new chapter is created." + }, + "chapterTitle": "Chapter Title *", + "@chapterTitle": { + "description": "Label for the chapter title input field, indicating it is required." + }, + "aboutRequired": "About *", + "@aboutRequired": { + "description": "Label for the 'About' or description input field, indicating it is required." + }, + "changeCoverImage": "Change Cover Image", + "@changeCoverImage": { + "description": "Button text to change the cover image of a story or chapter." + }, + "uploadAudioFile": "Upload Audio File", + "@uploadAudioFile": { + "description": "Button text to upload an audio file." + }, + "uploadLyricsFile": "Upload Lyrics File", + "@uploadLyricsFile": { + "description": "Button text to upload a lyrics file." + }, + "createChapter": "Create Chapter", + "@createChapter": { + "description": "Button text to finalize and create a new chapter." + }, + "audioFileSelected": "Audio file Selected: {fileName}", + "@audioFileSelected": { + "description": "Message shown after an audio file has been selected for upload.", + "placeholders": { + "fileName": { + "type": "String", + "example": "audio.mp3" + } + } + }, + "lyricsFileSelected": "Lyrics File Selected: {fileName}", + "@lyricsFileSelected": { + "description": "Message shown after a lyrics file has been selected for upload.", + "placeholders": { + "fileName": { + "type": "String", + "example": "lyrics.txt" + } + } + }, + "fillAllRequiredFields": "Please fill in all required fields and upload your Audio file and Lyrics file", + "@fillAllRequiredFields": { + "description": "Error message when required fields or files are missing from a form." + }, + "scheduled": "Scheduled", + "@scheduled": { + "description": "A status label indicating an event is scheduled for the future." + }, + "ok": "OK", + "@ok": { + "description": "Generic confirmation button text, often for closing a dialog." + }, + "roomDescriptionOptional": "Room Description (optional)", + "@roomDescriptionOptional": { + "description": "Label for the room description input field, indicating it is not required." + }, + "deleteAccount": "Delete account", + "@deleteAccount": { + "description": "Button text to initiate the account deletion process." + }, + "createYourStory": "Create Your Story", + "@createYourStory": { + "description": "Title for the screen where a new story is created." + }, + "titleRequired": "Title *", + "@titleRequired": { + "description": "Label for the title input field, indicating it is required." + }, + "category": "Category *", + "@category": { + "description": "Label for the category selection, indicating it is required." + }, + "addChapter": "Add Chapter", + "@addChapter": { + "description": "Button text to add a chapter to a story." + }, + "createStory": "Create Story", + "@createStory": { + "description": "Button text to finalize and create a new story." + }, + "fillAllRequiredFieldsAndChapter": "Please fill in all required fields, add at least one chapter, and select a cover image.", + "@fillAllRequiredFieldsAndChapter": { + "description": "Error message for the story creation form when requirements are not met." + }, + "toConfirmType": "To confirm, type", + "@toConfirmType": { + "description": "Instructional text in a confirmation dialog, preceding the text to be typed." + }, + "inTheBoxBelow": "in the box below", + "@inTheBoxBelow": { + "description": "Instructional text in a confirmation dialog, following the text to be typed." + }, + "iUnderstandDeleteMyAccount": "I understand, Delete My Account", + "@iUnderstandDeleteMyAccount": { + "description": "The specific phrase a user must type to confirm account deletion." + }, + "whatDoYouWantToListenTo": "What do you want to listen to?", + "@whatDoYouWantToListenTo": { + "description": "A prompt on the main screen, encouraging user engagement." + }, + "categories": "Categories", + "@categories": { + "description": "Header for the list of categories." + }, + "stories": "Stories", + "@stories": { + "description": "Header for the list of stories." + }, + "someSuggestions": "Some Suggestions", + "@someSuggestions": { + "description": "Header for a list of suggested content." + }, + "getStarted": "Get Started", + "@getStarted": { + "description": "Button text on an onboarding screen to begin using the app." + }, + "skip": "Skip", + "@skip": { + "description": "Button text to skip an optional step, like onboarding." + }, + "welcomeToResonate": "Welcome to Resonate", + "@welcomeToResonate": { + "description": "Onboarding screen title." + }, + "exploreDiverseConversations": "Explore Diverse Conversations", + "@exploreDiverseConversations": { + "description": "Feature highlight on an onboarding screen." + }, + "yourVoiceMatters": "Your Voice Matters", + "@yourVoiceMatters": { + "description": "Feature highlight on an onboarding screen." + }, + "joinConversationExploreRooms": "Join the conversation! Explore rooms, connect with friends, and share your voice with the world.", + "@joinConversationExploreRooms": { + "description": "Descriptive text on an onboarding screen." + }, + "diveIntoDiverseDiscussions": "Dive into diverse discussions and topics. \nFind rooms that resonate with you and become a part of the community.", + "@diveIntoDiverseDiscussions": { + "description": "Descriptive text on an onboarding screen." + }, + "atResonateEveryVoiceValued": "At Resonate, every voice is valued. Share your thoughts, stories, and experiences with others. Start your audio journey now.", + "@atResonateEveryVoiceValued": { + "description": "Descriptive text on an onboarding screen." + }, + "notifications": "Notifications", + "@notifications": { + "description": "Title for the notifications screen." + }, + "taggedYouInUpcomingRoom": "{username} tagged you in an upcoming room: {subject}", + "@taggedYouInUpcomingRoom": { + "description": "Notification message when tagged in an upcoming room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "taggedYouInRoom": "{username} tagged you in room: {subject}", + "@taggedYouInRoom": { + "description": "Notification message when tagged in a live room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "likedYourStory": "{username} liked your story: {subject}", + "@likedYourStory": { + "description": "Notification message when someone likes your story.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Story" + } + } + }, + "subscribedToYourRoom": "{username} subscribed to your room: {subject}", + "@subscribedToYourRoom": { + "description": "Notification message when someone subscribes to your room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Room" + } + } + }, + "startedFollowingYou": "{username} started following you", + "@startedFollowingYou": { + "description": "Notification message when someone starts following you.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "youHaveNewNotification": "You have a new notification", + "@youHaveNewNotification": { + "description": "Generic title for a new notification." + }, + "hangOnGoodThingsTakeTime": "Hang on, Good Things take time 🔍", + "@hangOnGoodThingsTakeTime": { + "description": "A friendly message displayed during a long loading process." + }, + "resonateOpenSourceProject": "Resonate is an open source project maintained by AOSSIE. Checkout our github to contribute.", + "@resonateOpenSourceProject": { + "description": "Informational text about the open-source nature of the project." + }, + "mute": "Mute", + "@mute": { + "description": "Button text to mute the microphone." + }, + "speakerLabel": "Speaker", + "@speakerLabel": { + "description": "Label for the speakerphone toggle." + }, + "audioOptions": "Audio Options", + "@audioOptions": { + "description": "Label for the audio options/settings button." + }, + "end": "End", + "@end": { + "description": "Button text to end a call or session." + }, + "saveChanges": "Save changes", + "@saveChanges": { + "description": "Button text to save modified settings or profile information." + }, + "discard": "DISCARD", + "@discard": { + "description": "Button text to discard changes." + }, + "save": "SAVE", + "@save": { + "description": "Button text to save changes." + }, + "changeProfilePicture": "Change profile picture", + "@changeProfilePicture": { + "description": "Button text to open the profile picture selection options." + }, + "camera": "Camera", + "@camera": { + "description": "Option to use the camera to take a picture." + }, + "gallery": "Gallery", + "@gallery": { + "description": "Option to choose a picture from the gallery." + }, + "remove": "Remove", + "@remove": { + "description": "Button text to remove an item, like a profile picture." + }, + "created": "Created {date}", + "@created": { + "description": "Displays the creation date of a story or content.", + "placeholders": { + "date": { + "type": "String", + "example": "Jan 15, 2023" + } + } + }, + "chapters": "Chapters", + "@chapters": { + "description": "Header for the list of chapters in a story." + }, + "deleteStory": "Delete Story", + "@deleteStory": { + "description": "Button text to delete a story." + }, + "createdBy": "Created by {creatorName}", + "@createdBy": { + "description": "Displays the name of the content creator.", + "placeholders": { + "creatorName": { + "type": "String", + "example": "John Doe" + } + } + }, + "start": "Start", + "@start": { + "description": "Button text to start an activity or session." + }, + "unsubscribe": "Unsubscribe", + "@unsubscribe": { + "description": "Button text to unsubscribe from a room or notification." + }, + "subscribe": "Subscribe", + "@subscribe": { + "description": "Button text to subscribe to a room or notification." + }, + "storyCategory": "{category, select, drama{Drama} comedy{Comedy} horror{Horror} romance{Romance} thriller{Thriller} spiritual{Spiritual} other{Other}}", + "@storyCategory": { + "description": "Selects the appropriate category name for a story based on a key.", + "placeholders": { + "category": { + "type": "String", + "example": "drama" + } + } + }, + "chooseTheme": "{category, select, classicTheme{Classic} timeTheme{Time} vintageTheme{Vintage} amberTheme{Amber} forestTheme{Forest} creamTheme{Cream} other{Other}}", + "@chooseTheme": { + "description": "Selects the appropriate theme name based on a key.", + "placeholders": { + "category": { + "type": "String", + "example": "classic" + } + } + }, + "minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "@minutesAgo": { + "description": "Displays time in a relative format for minutes.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "@hoursAgo": { + "description": "Displays time in a relative format for hours.", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}", + "@daysAgo": { + "description": "Displays time in a relative format for days.", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "by": "by", + "@by": { + "description": "A preposition, typically used between a content title and the author's name." + }, + "likes": "Likes", + "@likes": { + "description": "Label for the number of likes." + }, + "lengthMinutes": "min", + "@lengthMinutes": { + "description": "Abbreviation for 'minutes', used to display audio length." + }, + "requiredField": "Required field", + "@requiredField": { + "description": "Error message for a mandatory field that was left empty." + }, + "onlineUsers": "Online Users", + "@onlineUsers": { + "description": "Header for the list of users who are currently online." + }, + "noOnlineUsers": "No users currently online", + "@noOnlineUsers": { + "description": "Message displayed when no users are online." + }, + "chooseUser": "Choose User to chat with", + "@chooseUser": { + "description": "Instructional text to select a user to start a chat." + }, + "quickMatch": "Quick Match", + "@quickMatch": { + "description": "Button text for a feature that quickly matches the user with a random chat partner." + }, + "story": "Story", + "@story": { + "description": "Label for a story." + }, + "user": "User", + "@user": { + "description": "Label for a user." + }, + "following": "Following", + "@following": { + "description": "Label for the list of users that the current user is following." + }, + "followers": "Followers", + "@followers": { + "description": "Label for the list of users who are following the current user." + }, + "friendRequests": "Friend Requests", + "@friendRequests": { + "description": "Header for the list of incoming friend requests." + }, + "friendRequestSent": "Friend request sent", + "@friendRequestSent": { + "description": "Title for the confirmation message after sending a friend request." + }, + "friendRequestSentTo": "Your friend request to {username} has been sent.", + "@friendRequestSentTo": { + "description": "Confirmation message after sending a friend request to a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestCancelled": "Friend request cancelled", + "@friendRequestCancelled": { + "description": "Title for the confirmation message after cancelling a friend request." + }, + "friendRequestCancelledTo": "Your friend request to {username} has been cancelled.", + "@friendRequestCancelledTo": { + "description": "Confirmation message after cancelling a friend request to a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "requested": "Requested", + "@requested": { + "description": "A status label on a button indicating a friend request has been sent." + }, + "friends": "Friends", + "@friends": { + "description": "A status label or header for the list of friends." + }, + "addFriend": "Add Friend", + "@addFriend": { + "description": "Button text to send a friend request." + }, + "friendRequestAccepted": "Friend request accepted", + "@friendRequestAccepted": { + "description": "Title for the confirmation message after accepting a friend request." + }, + "friendRequestAcceptedTo": "You are now friends with {username}.", + "@friendRequestAcceptedTo": { + "description": "Confirmation message after accepting a friend request from a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestDeclined": "Friend request declined", + "@friendRequestDeclined": { + "description": "Title for the confirmation message after declining a friend request." + }, + "friendRequestDeclinedTo": "You have declined the friend request from {username}.", + "@friendRequestDeclinedTo": { + "description": "Confirmation message after declining a friend request from a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "accept": "Accept", + "@accept": { + "description": "Button text to accept a request or invitation." + }, + "callDeclined": "Call declined", + "@callDeclined": { + "description": "Title for the message when a call is declined." + }, + "callDeclinedTo": "User {username} declined the call.", + "@callDeclinedTo": { + "description": "Message indicating that a specific user declined a call.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "checkForUpdates": "Check Updates", + "@checkForUpdates": { + "description": "Button text to manually check for application updates." + }, + "updateNow": "Update Now", + "@updateNow": { + "description": "Button text to start the update process." + }, + "updateLater": "Later", + "@updateLater": { + "description": "Button text to postpone an update." + }, + "updateSuccessful": "Update Successful", + "@updateSuccessful": { + "description": "Title for the success message after an update." + }, + "updateSuccessfulMessage": "Resonate has been updated successfully!", + "@updateSuccessfulMessage": { + "description": "Success message confirming the application has been updated." + }, + "updateCancelled": "Update Cancelled", + "@updateCancelled": { + "description": "Title for the message when an update is cancelled." + }, + "updateCancelledMessage": "Update was cancelled by user", + "@updateCancelledMessage": { + "description": "Message indicating the user cancelled the update process." + }, + "updateFailed": "Update Failed", + "@updateFailed": { + "description": "Title for the message when an update fails." + }, + "updateFailedMessage": "Failed to update. Please try updating from Play Store manually.", + "@updateFailedMessage": { + "description": "Error message when an in-app update fails, suggesting a manual update." + }, + "updateError": "Update Error", + "@updateError": { + "description": "Generic title for an update-related error." + }, + "updateErrorMessage": "An error occurred while updating. Please try again.", + "@updateErrorMessage": { + "description": "Generic error message for an update that failed due to an unknown error." + }, + "platformNotSupported": "Platform Not Supported", + "@platformNotSupported": { + "description": "Title for an error when a feature is not supported on the current platform." + }, + "platformNotSupportedMessage": "Update checking is only available on Android devices", + "@platformNotSupportedMessage": { + "description": "Message explaining that the in-app update feature is platform-specific." + }, + "updateCheckFailed": "Update Check Failed", + "@updateCheckFailed": { + "description": "Title for an error when the app fails to check for updates." + }, + "updateCheckFailedMessage": "Could not check for updates. Please try again later.", + "@updateCheckFailedMessage": { + "description": "Error message when the check for updates process fails." + }, + "upToDateTitle": "You're Up to Date!", + "@upToDateTitle": { + "description": "Title for the message when the app is already up to date." + }, + "upToDateMessage": "You're using the latest version of Resonate", + "@upToDateMessage": { + "description": "Message confirming that no update is needed." + }, + "updateAvailableTitle": "Update Available!", + "@updateAvailableTitle": { + "description": "Title for the dialog informing the user about a new update." + }, + "updateAvailableMessage": "A new version of Resonate is available on Play Store", + "@updateAvailableMessage": { + "description": "Message prompting the user to update the app from the Play Store." + }, + "updateFeaturesImprovement": "Get the latest features and improvements!", + "@updateFeaturesImprovement": { + "description": "A message highlighting the benefit of updating the application." + }, + "failedToRemoveRoom": "Failed to remove room", + "@failedToRemoveRoom": { + "description": "Error message when unable to remove a room from the list" + }, + "failedToCreateRoom": "Failed to create room", + "@failedToCreateRoom": { + "description": "Error message when unable to create a room" + }, + "roomChat": "Room Chat", + "@roomChat": { + "description": "Title of the in-room chat screen" + }, + "failedToResend": "Failed to resend", + "@failedToResend": { + "description": "Snackbar shown when retrying a failed chat message fails again" + }, + "edited": " (edited)", + "@edited": { + "description": "Inline suffix appended to an edited chat message" + }, + "failedToSendTapRetry": "Failed to send. Tap the message to retry.", + "@failedToSendTapRetry": { + "description": "Snackbar shown when a chat message fails to send" + }, + "saySomething": "Say Something", + "@saySomething": { + "description": "Hint text in the chat message input field" + }, + "tapToRetry": "Tap to retry", + "@tapToRetry": { + "description": "Tooltip on the retry affordance of a failed chat message" + }, + "retry": "Retry", + "@retry": { + "description": "Label of the retry action on a failed chat message" + }, + "roomRemovedSuccessfully": "Room removed from your list successfully", + "@roomRemovedSuccessfully": { + "description": "Success message when a room is successfully removed from the user's list" + }, + "alert": "Alert", + "@alert": { + "description": "Title for an alert dialog." + }, + "removedFromRoom": "You have been reported or removed from the room", + "@removedFromRoom": { + "description": "Message shown when a user is removed or reported from a room." + }, + "reportType": "{type, select, harassment{Harassment / Hate Speech} abuse{Abusive content / Violence} spam{Spam / Scams / Fraud} impersonation{Impersonation / Fake Accounts} illegal{Illegal Activities} selfharm{Self-harm / Suicide / Mental health} misuse{Misuse of platform} other{Other}}", + "@reportType": { + "description": "Selects the appropriate report type label based on a key.", + "placeholders": { + "type": { + "type": "String", + "example": "harassment" + } + } + }, + "userBlockedFromResonate": "You have received multiple reports from users and you have been blocked from using Resonate. Please contact AOSSIE if you believe this is a mistake.", + "@userBlockedFromResonate": { + "description": "Message shown when a user is blocked from using Resonate." + }, + "reportParticipant": "Report Participant", + "@reportParticipant": { + "description": "Label for the button to report a participant." + }, + "selectReportType": "Please select a report type", + "@selectReportType": { + "description": "Prompt for the user to select a report type." + }, + "reportSubmitted": "Report Submitted Successfully", + "@reportSubmitted": { + "description": "Message shown when a report is submitted successfully." + }, + "reportFailed": "Report Submission Failed", + "@reportFailed": { + "description": "Message shown when a report submission fails." + }, + "additionalDetailsOptional": "Additional details (optional)", + "@additionalDetailsOptional": { + "description": "Label for an optional input field for additional details in a report." + }, + "submitReport": "Submit Report", + "@submitReport": { + "description": "Button text to submit a report." + }, + "actionBlocked": "Action Blocked", + "@actionBlocked": { + "description": "Title for a message indicating that a user action is blocked." + }, + "cannotStopRecording": "You cannot stop the recording manually, the recording will be stopped when the room is closed.", + "@cannotStopRecording": { + "description": "Message explaining why a user cannot stop a recording manually." + }, + "liveChapter": "Live Chapter", + "@liveChapter": { + "description": "Label indicating that a chapter is currently live." + }, + "viewOrEditLyrics": "View or Edit Lyrics", + "@viewOrEditLyrics": { + "description": "Button text to view or edit the lyrics of a chapter." + }, + "close": "Close", + "@close": { + "description": "Label for the button to close a dialog." + }, + "verifyChapterDetails": "Verify Chapter Details", + "@verifyChapterDetails": { + "description": "Title for the screen where Live chapter details are verified before publishing." + }, + "author": "Author", + "@author": { + "description": "Label for the author of a story or chapter." + }, + "startLiveChapter": "Start a Live Chapter", + "@startLiveChapter": { + "description": "Title for the screen where a live chapter is initiated." + }, + "fillAllFields": "Please fill in all required fields", + "@fillAllFields": { + "description": "Error message when required fields are not filled in a form." + }, + "noRecordingError": "You have not recorded anything for the chapter. Please record a chapter before exiting the room", + "@noRecordingError": { + "description": "Error message when trying to exit a live chapter room without any recording." + }, + "audioOutput": "Audio Output", + "@audioOutput": { + "description": "Title for audio output device selector." + }, + "selectPreferredSpeaker": "Select your preferred speaker", + "@selectPreferredSpeaker": { + "description": "Subtitle for audio device selector dialog." + }, + "noAudioOutputDevices": "No audio output devices detected", + "@noAudioOutputDevices": { + "description": "Message shown when no audio output devices are available." + }, + "refresh": "Refresh", + "@refresh": { + "description": "Button text to refresh audio device list." + }, + "done": "Done", + "@done": { + "description": "Button text to close audio device selector." + }, + "deleteMessageTitle": "Delete Message", +"@deleteMessageTitle": { + "description": "Title shown in the delete message confirmation dialog." +}, +"deleteMessageContent": "Are you sure you want to delete this message?", +"@deleteMessageContent": { + "description": "Confirmation text asking the user if they want to delete a message." +}, +"thisMessageWasDeleted": "This message was deleted", +"failedToDeleteMessage": "Failed to delete message", + +"noFriendsYet": "No Friends Yet", +"@noFriendsYet": { + "description": "Title shown when user has no friends." +}, +"noFriendsDescription": "Your friends list is empty. Start connecting with people and grow your network!", +"@noFriendsDescription": { + "description": "Description shown when user has no friends." +}, +"findFriends": "Find Friends", +"@findFriends": { + "description": "Button text to navigate to find friends screen." +}, +"inviteFriend": "Invite a Friend", +"@inviteFriend": { + "description": "Button text to invite friends to the app." +}, +"noFriendRequestsYet": "No Friend Requests", +"@noFriendRequestsYet": { + "description": "Title shown when user has no friend requests." +}, +"noFriendRequestsDescription": "You don't have any pending friend requests. Invite your friends to connect!", +"@noFriendRequestsDescription": { + "description": "Description shown when user has no friend requests." +}, +"inviteToResonate": "Hey! Join me on Resonate - a social audio platform where every voice is valued. Download now: {url}", +"@inviteToResonate": { + "description": "Text used when inviting friends to the app.", + "placeholders": { + "url": { + "type": "String" + } + } +}, +"@thisMessageWasDeleted": { + "description": "Status text shown when a previously sent message has been deleted." +}, + +"usernameInvalidFormat": "Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.", +"@usernameInvalidFormat": { + "description": "Validation error displayed when the user enters a username with unsupported characters." +}, + +"usernameAlreadyTaken": "This username is already taken. Try a different one.", +"@usernameAlreadyTaken": { + "description": "Error shown when the chosen username is unavailable because another user has already registered it." +} + + +} \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4f493c13..3b84af20 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,1831 +1,1870 @@ -{ - "@@locale": "hi", - "title": "रेज़ोनेट", - "@title": { - "description": "The title of the application." - }, - "roomDescription": "विनम्र रहें और दूसरे व्यक्ति की राय का सम्मान करें। अभद्र टिप्पणियों से बचें।", - "@roomDescription": { - "description": "Guideline message for users in a chat room." - }, - "hidePassword": "पासवर्ड छिपाएं", - "@hidePassword": { - "description": "Button text to conceal the password field." - }, - "showPassword": "पासवर्ड दिखाएं", - "@showPassword": { - "description": "Button text to reveal the password field." - }, - "passwordEmpty": "पासवर्ड खाली नहीं हो सकता", - "@passwordEmpty": { - "description": "Error message when the password field is left blank." - }, - "password": "पासवर्ड", - "@password": { - "description": "Label for the password input field." - }, - "confirmPassword": "पासवर्ड की पुष्टि करें", - "@confirmPassword": { - "description": "Label for the confirm password input field." - }, - "passwordsNotMatch": "पासवर्ड मेल नहीं खा रहे हैं", - "@passwordsNotMatch": { - "description": "Error message when password and confirmation password do not match." - }, - "userCreatedStories": "यूज़र की बनाई कहानियाँ", - "@userCreatedStories": { - "description": "Header for the section showing stories created by another user." - }, - "yourStories": "तुम्हारी कहानियाँ", - "@yourStories": { - "description": "Header for the section showing stories created by the current user." - }, - "userNoStories": "यूज़र ने अभी तक कोई कहानी नहीं बनाई", - "@userNoStories": { - "description": "Message displayed when viewing another user's profile and they have no stories." - }, - "youNoStories": "तुमने अभी तक कोई कहानी नहीं बनाई", - "@youNoStories": { - "description": "Message displayed on the current user's profile when they have no stories." - }, - "follow": "फॉलो करो", - "@follow": { - "description": "Button text to follow a user." - }, - "editProfile": "प्रोफाइल एडिट करो", - "@editProfile": { - "description": "Button text to navigate to the profile editing screen." - }, - "verifyEmail": "ईमेल वेरीफाई करो", - "@verifyEmail": { - "description": "Button or link text to start the email verification process." - }, - "verified": "वेरीफाइड", - "@verified": { - "description": "A status label indicating that the user's email has been verified." - }, - "profile": "प्रोफ़ाइल", - "@profile": { - "description": "Label for the user profile section or page." - }, - "userLikedStories": "यूज़र को पसंद आई कहानियाँ", - "@userLikedStories": { - "description": "Header for the section showing stories liked by another user." - }, - "yourLikedStories": "तुम्हें पसंद आई कहानियाँ", - "@yourLikedStories": { - "description": "Header for the section showing stories liked by the current user." - }, - "userNoLikedStories": "यूज़र ने अभी तक कोई कहानी लाइक नहीं की", - "@userNoLikedStories": { - "description": "Message displayed when viewing another user's profile and they have no liked stories." - }, - "youNoLikedStories": "तुमने अभी तक कोई कहानी लाइक नहीं की", - "@youNoLikedStories": { - "description": "Message displayed on the current user's profile when they have no liked stories." - }, - "live": "लाइव", - "@live": { - "description": "Tab or label for live audio rooms." - }, - "upcoming": "आने वाला", - "@upcoming": { - "description": "Tab or label for scheduled/upcoming audio rooms." - }, - "noAvailableRoom": "{isRoom, select, true{कोई रूम उपलब्ध नहीं है} false{कोई आने वाला रूम उपलब्ध नहीं है} other{रूम की जानकारी उपलब्ध नहीं है}}\nनीचे से एक रूम बनाकर शुरुआत करें!", - "@noAvailableRoom": { - "description": "Message when no room or upcoming room is available, with a call to action.", - "placeholders": { - "isRoom": { - "type": "String", - "example": "true" - } - } - }, - "user1": "यूज़र 1", - "@user1": { - "description": "A generic placeholder name for a user." - }, - "user2": "यूज़र 2", - "@user2": { - "description": "A second generic placeholder name for a user." - }, - "you": "तुम", - "@you": { - "description": "Label to identify the current user in a list or chat." - }, - "areYouSure": "क्या आप पक्के हैं?", - "@areYouSure": { - "description": "Confirmation prompt title." - }, - "loggingOut": "आप रेज़ोनेट से लॉग आउट हो रहे हैं।", - "@loggingOut": { - "description": "Confirmation prompt message for logging out." - }, - "yes": "हाँ", - "@yes": { - "description": "Generic confirmation button text." - }, - "no": "नहीं", - "@no": { - "description": "Generic cancellation button text." - }, - "incorrectEmailOrPassword": "ईमेल या पासवर्ड गलत है", - "@incorrectEmailOrPassword": { - "description": "Error message for failed login attempt." - }, - "passwordShort": "पासवर्ड 8 अक्षर से छोटा है", - "@passwordShort": { - "description": "Error message for a password that is too short." - }, - "tryAgain": "फिर कोशिश करो!", - "@tryAgain": { - "description": "Button text to retry a failed action." - }, - "success": "सफलता", - "@success": { - "description": "Generic title for a successful operation." - }, - "passwordResetSent": "पासवर्ड रीसेट ईमेल भेजा गया!", - "@passwordResetSent": { - "description": "Success message after a password reset email has been dispatched." - }, - "error": "गलती", - "@error": { - "description": "Generic title for a failed operation." - }, - "resetPassword": "पासवर्ड रीसेट करो", - "@resetPassword": { - "description": "Title or button text for the reset password feature." - }, - "enterNewPassword": "नया पासवर्ड डालो", - "@enterNewPassword": { - "description": "Instructional text on the reset password screen." - }, - "newPassword": "नया पासवर्ड", - "@newPassword": { - "description": "Label for the new password input field." - }, - "setNewPassword": "नया पासवर्ड सेट करो", - "@setNewPassword": { - "description": "Button text to confirm setting a new password." - }, - "emailChanged": "ईमेल बदल दी गई", - "@emailChanged": { - "description": "Title for the success dialog after an email change." - }, - "emailChangeSuccess": "ईमेल सफलतापूर्वक बदल गई!", - "@emailChangeSuccess": { - "description": "Success message after changing the user's email." - }, - "failed": "नाकाम", - "@failed": { - "description": "Generic title for a failed operation." - }, - "emailChangeFailed": "ईमेल बदलने में नाकाम", - "@emailChangeFailed": { - "description": "Error message when an email change operation fails." - }, - "oops": "उफ़!", - "@oops": { - "description": "An informal title for an error or warning message." - }, - "emailExists": "ईमेल पहले से मौजूद है", - "@emailExists": { - "description": "Error message when a user tries to register or change to an email that is already in use." - }, - "changeEmail": "ईमेल बदलो", - "@changeEmail": { - "description": "Title or button text for the change email feature." - }, - "enterValidEmail": "कृपया एक मान्य ईमेल डालो", - "@enterValidEmail": { - "description": "Error message for an invalid email format." - }, - "newEmail": "नया ईमेल", - "@newEmail": { - "description": "Label for the new email input field." - }, - "currentPassword": "मौजूदा पासवर्ड", - "@currentPassword": { - "description": "Label for the current password input field, used for verification." - }, - "emailChangeInfo": "सुरक्षा के लिए, ईमेल बदलने के लिए मौजूदा पासवर्ड डालें। बदलने के बाद, नए ईमेल से लॉगिन करें।", - "@emailChangeInfo": { - "description": "Informational text explaining the process and security requirements for changing an email address for standard users." - }, - "oauthUsersMessage": "(केवल Google या Github से लॉगिन करने वाले यूज़र्स के लिए)", - "@oauthUsersMessage": { - "description": "A message to specify that the following instructions are for OAuth users." - }, - "oauthUsersEmailChangeInfo": "ईमेल बदलने के लिए, \"मौजूदा पासवर्ड\" फील्ड में नया पासवर्ड डालें। इसे याद रखें—आगे केवल Google/GitHub या नए पासवर्ड से लॉगिन होगा।", - "@oauthUsersEmailChangeInfo": { - "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." - }, - "resonateTagline": "बातों की एक अनंत दुनिया में कदम रखें", - "@resonateTagline": { - "description": "The application's tagline, used on splash or login screens." - }, - "signInWithEmail": "ईमेल से साइन इन करो", - "@signInWithEmail": { - "description": "Button text for signing in using email and password." - }, - "or": "या", - "@or": { - "description": "A separator text, typically used between different login options." - }, - "continueWith": "इनमें से किसी एक से लॉगिन करें", - "@continueWith": { - "description": "Informational text preceding a list of third-party login providers." - }, - "continueWithGoogle": "Google से लॉगिन करें", - "@continueWithGoogle": { - "description": "Button text for signing in with a Google account." - }, - "continueWithGitHub": "GitHub से लॉगिन करें", - "@continueWithGitHub": { - "description": "Button text for signing in with a GitHub account." - }, - "resonateLogo": "रेज़ोनेट लोगो", - "@resonateLogo": { - "description": "Accessibility text for the Resonate application logo image." - }, - "iAlreadyHaveAnAccount": "मेरे पास पहले से एक अकाउंट है", - "@iAlreadyHaveAnAccount": { - "description": "Text for a link or button to navigate to the login screen from the sign-up screen." - }, - "createNewAccount": "नया अकाउंट बनाओ", - "@createNewAccount": { - "description": "Text for a link or button to navigate to the sign-up screen from the login screen." - }, - "userProfile": "यूज़र प्रोफ़ाइल", - "@userProfile": { - "description": "Accessibility text for a user's profile picture or avatar." - }, - "passwordIsStrong": "पासवर्ड मजबूत है", - "@passwordIsStrong": { - "description": "Validation message indicating that the entered password meets all strength requirements." - }, - "admin": "एडमिन", - "@admin": { - "description": "Label for the Admin user role in a room." - }, - "moderator": "मॉडरेटर", - "@moderator": { - "description": "Label for the Moderator user role in a room." - }, - "speaker": "स्पीकर", - "@speaker": { - "description": "Label for the Speaker user role in a room." - }, - "listener": "लिस्नर", - "@listener": { - "description": "Label for the Listener user role in a room." - }, - "removeModerator": "मॉडरेटर हटाओ", - "@removeModerator": { - "description": "Menu item text to revoke moderator privileges from a user." - }, - "kickOut": "किक आउट करो", - "@kickOut": { - "description": "Menu item text to remove a user from a room." - }, - "addModerator": "मॉडरेटर जोड़ो", - "@addModerator": { - "description": "Menu item text to grant moderator privileges to a user." - }, - "addSpeaker": "स्पीकर जोड़ो", - "@addSpeaker": { - "description": "Menu item text to grant speaker privileges to a listener." - }, - "makeListener": "लिस्नर बनाओ", - "@makeListener": { - "description": "Menu item text to change a speaker's role to listener." - }, - "pairChat": "पेयर चैट", - "@pairChat": { - "description": "A feature name for one-on-one random chat." - }, - "chooseIdentity": "पहचान चुनें", - "@chooseIdentity": { - "description": "Prompt for the user to choose their identity, e.g., anonymous or public." - }, - "selectLanguage": "भाषा चुनें", - "@selectLanguage": { - "description": "Label for the language selection setting." - }, - "noConnection": "कोई कनेक्शन नहीं", - "@noConnection": { - "description": "Title indicating that there is no internet connection." - }, - "loadingDialog": "लोड हो रहा है...", - "@loadingDialog": { - "description": "Accessibility text for a loading indicator or spinner." - }, - "createAccount": "अकाउंट बनाओ", - "@createAccount": { - "description": "Button text or page title for the account creation screen." - }, - "enterValidEmailAddress": "मान्य ईमेल पता डालो", - "@enterValidEmailAddress": { - "description": "Error message shown for an invalid email format." - }, - "email": "ईमेल", - "@email": { - "description": "Label for the email input field." - }, - "passwordRequirements": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए", - "@passwordRequirements": { - "description": "Instructional text listing the requirements for a valid password, part 1." - }, - "includeNumericDigit": "कम से कम 1 संख्या शामिल करें", - "@includeNumericDigit": { - "description": "Instructional text listing the requirements for a valid password, part 2." - }, - "includeUppercase": "कम से कम 1 कैपिटल लेटर शामिल करें", - "@includeUppercase": { - "description": "Instructional text listing the requirements for a valid password, part 3." - }, - "includeLowercase": "कम से कम 1 लोअरकेस लेटर शामिल करें", - "@includeLowercase": { - "description": "Instructional text listing the requirements for a valid password, part 4." - }, - "includeSymbol": "कम से कम 1 सिम्बल शामिल करें", - "@includeSymbol": { - "description": "Instructional text listing the requirements for a valid password, part 5." - }, - "signedUpSuccessfully": "सफलतापूर्वक साइन अप हो गया!", - "@signedUpSuccessfully": { - "description": "Title for a success message after account creation." - }, - "newAccountCreated": "आपका नया अकाउंट सफलतापूर्वक बन गया है", - "@newAccountCreated": { - "description": "Success message confirming that a new account has been created." - }, - "signUp": "साइन अप", - "@signUp": { - "description": "Button text to submit the sign-up form." - }, - "login": "लॉगिन", - "@login": { - "description": "Button text to submit the login form." - }, - "settings": "सेटिंग्स", - "@settings": { - "description": "Title for the settings page." - }, - "accountSettings": "अकाउंट सेटिंग्स", - "@accountSettings": { - "description": "Sub-header for account-related settings." - }, - "account": "अकाउंट", - "@account": { - "description": "Label for the account settings section." - }, - "appSettings": "ऐप की सेटिंग्स", - "@appSettings": { - "description": "Sub-header for application-related settings." - }, - "themes": "थीम्स", - "@themes": { - "description": "Label for the theme selection setting." - }, - "about": "रेज़ोनेट के बारे में", - "@about": { - "description": "Label for the 'About' section or page of the app or a story." - }, - "other": "अन्य", - "@other": { - "description": "A generic category label for miscellaneous settings or items." - }, - "contribute": "योगदान करें", - "@contribute": { - "description": "Label for a section encouraging users to contribute to the project." - }, - "appPreferences": "ऐप प्राथमिकताएं", - "@appPreferences": { - "description": "Label for the app preferences settings page." - }, - "transcriptionModel": "ट्रांसक्रिप्शन मॉडल", - "@transcriptionModel": { - "description": "Section title for choosing AI transcription model." - }, - "transcriptionModelDescription": "वॉयस ट्रांसक्रिप्शन के लिए AI मॉडल चुनें। बड़े मॉडल अधिक सटीक हैं लेकिन धीमे हैं और अधिक स्टोरेज की आवश्यकता होती है।", - "@transcriptionModelDescription": { - "description": "Description text explaining transcription model choices." - }, - "whisperModelTiny": "टाइनी", - "@whisperModelTiny": { - "description": "Name of the smallest Whisper AI model." - }, - "whisperModelTinyDescription": "सबसे तेज़, कम सटीक (~39 MB)", - "@whisperModelTinyDescription": { - "description": "Description of the Tiny Whisper model performance and size." - }, - "whisperModelBase": "बेस", - "@whisperModelBase": { - "description": "Name of the base Whisper AI model." - }, - "whisperModelBaseDescription": "संतुलित गति और सटीकता (~74 MB)", - "@whisperModelBaseDescription": { - "description": "Description of the Base Whisper model performance and size." - }, - "whisperModelSmall": "स्मॉल", - "@whisperModelSmall": { - "description": "Name of the small Whisper AI model." - }, - "whisperModelSmallDescription": "अच्छी सटीकता, धीमा (~244 MB)", - "@whisperModelSmallDescription": { - "description": "Description of the Small Whisper model performance and size." - }, - "whisperModelMedium": "मीडियम", - "@whisperModelMedium": { - "description": "Name of the medium Whisper AI model." - }, - "whisperModelMediumDescription": "उच्च सटीकता, धीमा (~769 MB)", - "@whisperModelMediumDescription": { - "description": "Description of the Medium Whisper model performance and size." - }, - "whisperModelLargeV1": "लार्ज V1", - "@whisperModelLargeV1": { - "description": "Name of the large V1 Whisper AI model." - }, - "whisperModelLargeV1Description": "सबसे अधिक सटीक, सबसे धीमा (~1.55 GB)", - "@whisperModelLargeV1Description": { - "description": "Description of the Large V1 Whisper model performance and size." - }, - "whisperModelLargeV2": "लार्ज V2", - "@whisperModelLargeV2": { - "description": "Name of the large V2 Whisper AI model." - }, - "whisperModelLargeV2Description": "उच्च सटीकता के साथ बेहतर बड़ा मॉडल (~1.55 GB)", - "@whisperModelLargeV2Description": { - "description": "Description of the Large V2 Whisper model performance and size." - }, - "modelDownloadInfo": "मॉडल पहली बार उपयोग करने पर डाउनलोड हो जाते हैं। हम बेस, स्मॉल या मीडियम का उपयोग करने की सिफारिश करते हैं। बड़े मॉडल के लिए बहुत उच्च अंत उपकरणों की आवश्यकता होती है।", - "@modelDownloadInfo": { - "description": "Information message about model download." - }, - "logOut": "लॉग आउट", - "@logOut": { - "description": "Button text to log the user out of their account." - }, - "participants": "पार्टिसिपेंट्स", - "@participants": { - "description": "Label or title for the list of participants in a room." - }, - "delete": "डिलीट", - "@delete": { - "description": "Generic button text for a delete action. Parameter for toRoomAction." - }, - "leave": "छोड़ो", - "@leave": { - "description": "Generic button text for a leave action. Parameter for toRoomAction." - }, - "leaveButton": "छोड़ो", - "@leaveButton": { - "description": "Button text to leave a room or conversation." - }, - "findingRandomPartner": "आपके लिए एक रैंडम पार्टनर ढूंढ रहे हैं", - "@findingRandomPartner": { - "description": "Status message shown while the system is searching for a random chat partner." - }, - "quickFact": "त्वरित तथ्य", - "@quickFact": { - "description": "Header for a quick fact or tip, often shown during loading." - }, - "cancel": "कैंसल", - "@cancel": { - "description": "Generic button text for a cancel action." - }, - "completeYourProfile": "अपनी प्रोफ़ाइल पूरी करो", - "@completeYourProfile": { - "description": "Page title or prompt for the user to finish setting up their profile." - }, - "uploadProfilePicture": "प्रोफ़ाइल पिक्चर अपलोड करो", - "@uploadProfilePicture": { - "description": "Instructional text or button to upload a profile picture." - }, - "enterValidName": "मान्य नाम डालो", - "@enterValidName": { - "description": "Error message for an invalid name format." - }, - "name": "नाम", - "@name": { - "description": "Label for the name input field." - }, - "username": "यूज़रनेम", - "@username": { - "description": "Label for the username input field." - }, - "enterValidDOB": "सही तारीख़ डालो", - "@enterValidDOB": { - "description": "Error message for an invalid date of birth." - }, - "dateOfBirth": "जन्म तिथि", - "@dateOfBirth": { - "description": "Label for the date of birth input field." - }, - "forgotPassword": "पासवर्ड भूल गए?", - "@forgotPassword": { - "description": "Link text for users who have forgotten their password." - }, - "next": "आगे", - "@next": { - "description": "Button text to proceed to the next step in a process." - }, - "noStoriesExist": "कोई कहानी अभी मौजूद नहीं है", - "@noStoriesExist": { - "description": "Message displayed when there are no stories available in a list." - }, - "enterVerificationCode": "अपना\nवेरिफिकेशन कोड दर्ज करें", - "@enterVerificationCode": { - "description": "Prompt for the user to enter the verification code sent to their email." - }, - "verificationCodeSent": "हमने 6-अंकों का कोड भेजा है\n", - "@verificationCodeSent": { - "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." - }, - "verificationComplete": "वेरिफिकेशन पूरा हो गया", - "@verificationComplete": { - "description": "Title for a success message after email verification." - }, - "verificationCompleteMessage": "बधाई हो! आपका ईमेल वेरिफाई हो गया है", - "@verificationCompleteMessage": { - "description": "Success message confirming email verification." - }, - "verificationFailed": "वेरिफिकेशन फेल हो गया", - "@verificationFailed": { - "description": "Title for an error message when verification fails." - }, - "otpMismatch": "OTP मेल नहीं खा रहा, कृपया फिर से प्रयास करें", - "@otpMismatch": { - "description": "Error message when the entered OTP is incorrect." - }, - "otpResent": "OTP दोबारा भेजा गया", - "@otpResent": { - "description": "Confirmation message that the OTP has been resent." - }, - "requestNewCode": "नया कोड माँगें", - "@requestNewCode": { - "description": "Button text to request a new verification code." - }, - "requestNewCodeIn": "नया कोड माँगने के लिए प्रतीक्षा करें", - "@requestNewCodeIn": { - "description": "Text displayed before a countdown timer for requesting a new code." - }, - "clickPictureCamera": "कैमरा से फोटो लें", - "@clickPictureCamera": { - "description": "Option to take a new photo using the device camera." - }, - "pickImageGallery": "गैलरी से इमेज चुनें", - "@pickImageGallery": { - "description": "Option to choose an existing image from the device gallery." - }, - "deleteMyAccount": "मेरा अकाउंट डिलीट करें", - "@deleteMyAccount": { - "description": "Button text for the account deletion feature." - }, - "createNewRoom": "नया रूम बनाएं", - "@createNewRoom": { - "description": "Button text or page title for creating a new audio room." - }, - "pleaseEnterScheduledDateTime": "कृपया निर्धारित तारीख और समय डालें", - "@pleaseEnterScheduledDateTime": { - "description": "Error message when the scheduled date and time for a room is not provided." - }, - "scheduleDateTimeLabel": "शेड्यूल तारीख व समय", - "@scheduleDateTimeLabel": { - "description": "Label for the input field to schedule a room's date and time." - }, - "enterTags": "टैग डालें", - "@enterTags": { - "description": "Placeholder or label for the input field for adding tags to a room or story." - }, - "joinCommunity": "कम्युनिटी से जुड़ें", - "@joinCommunity": { - "description": "Button text or header for the community section." - }, - "followUsOnX": "X पर हमें फॉलो करें", - "@followUsOnX": { - "description": "Link text to the company's profile on X (formerly Twitter)." - }, - "joinDiscordServer": "Discord सर्वर से जुड़ें", - "@joinDiscordServer": { - "description": "Link text to join the project's Discord server." - }, - "noLyrics": "कोई लिरिक्स उपलब्ध नहीं", - "@noLyrics": { - "description": "Message displayed when lyrics for an audio file are not available." - }, - "noStoriesInCategory": "{categoryName} श्रेणी में कोई कहानी नहीं है", - "@noStoriesInCategory": { - "description": "Message when no stories exist in a specific category.", - "placeholders": { - "categoryName": { - "type": "String", - "example": "drama" - } - } - }, - "newChapters": "नए चैप्टर्स जोड़ें", - "@newChapters": { - "description": "Header or label for the section to add or view new chapters of a story." - }, - "helpToGrow": "साथ मिलकर बढ़ाएं", - "@helpToGrow": { - "description": "A call to action asking users to help the platform grow, often a header for sharing/rating." - }, - "share": "शेयर करें", - "@share": { - "description": "Button text for sharing content." - }, - "rate": "रेटिंग दें", - "@rate": { - "description": "Button text for rating the app or content." - }, - "aboutResonate": "रेज़ोनेट के बारे में", - "@aboutResonate": { - "description": "Title for the 'About' page of the Resonate application." - }, - "description": "विवरण", - "@description": { - "description": "Label for a description field." - }, - "confirm": "पुष्टि करें", - "@confirm": { - "description": "Generic button text for a confirm action." - }, - "classic": "क्लासिक", - "@classic": { - "description": "Name of a theme option." - }, - "time": "टाइम", - "@time": { - "description": "Name of a theme option." - }, - "vintage": "विंटेज", - "@vintage": { - "description": "Name of a theme option." - }, - "amber": "अंबर", - "@amber": { - "description": "Name of a theme option." - }, - "forest": "फॉरेस्ट", - "@forest": { - "description": "Name of a theme option." - }, - "cream": "क्रीम", - "@cream": { - "description": "Name of a theme option." - }, - "none": "कोई नहीं", - "@none": { - "description": "Text representing no selection or absence of a value." - }, - "checkOutGitHub": "हमारा GitHub रेपो देखें: {url}", - "@checkOutGitHub": { - "description": "Share message for the GitHub repository, including a placeholder for the URL.", - "placeholders": { - "url": { - "type": "String", - "example": "https://github.com/AOSSIE-Org/Resonate" - } - } - }, - "aossie": "AOSSIE", - "@aossie": { - "description": "The name of the organization." - }, - "aossieLogo": "AOSSIE लोगो", - "@aossieLogo": { - "description": "Accessibility text for the AOSSIE organization logo." - }, - "errorLoadPackageInfo": "पैकेज जानकारी लोड नहीं हो सकी", - "@errorLoadPackageInfo": { - "description": "Error message when the application fails to load its package information (e.g., version)." - }, - "searchFailed": "रूम खोजने में विफल। कृपया पुनः प्रयास करें।", - "@searchFailed": { - "description": "Error message when searching for rooms fails." - }, - "updateAvailable": "अपडेट उपलब्ध है", - "@updateAvailable": { - "description": "Title indicating that a new version of the app is available." - }, - "newVersionAvailable": "एक नया वर्शन उपलब्ध है!", - "@newVersionAvailable": { - "description": "Message informing the user about an available update." - }, - "upToDate": "आपका ऐप लेटेस्ट है", - "@upToDate": { - "description": "Title indicating the application is on the latest version." - }, - "latestVersion": "आप रेज़ोनेट का लेटेस्ट वर्शन इस्तेमाल कर रहे हैं", - "@latestVersion": { - "description": "Message confirming the user has the latest version of the app." - }, - "profileCreatedSuccessfully": "प्रोफ़ाइल सफलतापूर्वक बनाई गई", - "@profileCreatedSuccessfully": { - "description": "Success message after a user's profile is created." - }, - "invalidScheduledDateTime": "अमान्य तारीख और समय", - "@invalidScheduledDateTime": { - "description": "Error message for an invalid date-time format." - }, - "scheduledDateTimePast": "शेड्यूल तारीख व समय पहले का नहीं हो सकता", - "@scheduledDateTimePast": { - "description": "Error message when the user selects a past date-time for a scheduled event." - }, - "joinRoom": "रूम में शामिल हों", - "@joinRoom": { - "description": "Button text to join an audio room." - }, - "unknownUser": "अज्ञात यूज़र", - "@unknownUser": { - "description": "Placeholder text for a user whose name is not available." - }, - "canceled": "रद्द किया गया", - "@canceled": { - "description": "A status label indicating that an action was canceled." - }, - "english": "en", - "@english": { - "description": "The language code for English." - }, - "emailVerificationRequired": "ईमेल वेरिफिकेशन ज़रूरी है", - "@emailVerificationRequired": { - "description": "A title or message indicating that email verification is needed to proceed." - }, - "verify": "वेरिफ़ाई करें", - "@verify": { - "description": "Button text to initiate a verification process." - }, - "audioRoom": "ऑडियो रूम", - "@audioRoom": { - "description": "Generic title for an audio room." - }, - "toRoomAction": "रूम को {action} करने के लिए", - "@toRoomAction": { - "description": "A dynamic message for confirming an action on a room, like deleting or leaving.", - "placeholders": { - "action": { - "type": "String", - "example": "delete" - } - } - }, - "mailSentMessage": "मेल भेज दी गई है", - "@mailSentMessage": { - "description": "A confirmation message indicating an email has been sent." - }, - "disconnected": "कनेक्शन टूट गया", - "@disconnected": { - "description": "A status label indicating a loss of connection." - }, - "micOn": "माइक", - "@micOn": { - "description": "Accessibility label for the microphone button when it is on." - }, - "speakerOn": "स्पीकर", - "@speakerOn": { - "description": "Accessibility label for the speakerphone button when it is on." - }, - "endChat": "चैट समाप्त करें", - "@endChat": { - "description": "Accessibility label for the end chat/call button." - }, - "monthJan": "जनवरी", - "@monthJan": { - "description": "Abbreviation for January." - }, - "monthFeb": "फरवरी", - "@monthFeb": { - "description": "Abbreviation for February." - }, - "monthMar": "मार्च", - "@monthMar": { - "description": "Full name for March." - }, - "monthApr": "अप्रैल", - "@monthApr": { - "description": "Full name for April." - }, - "monthMay": "मई", - "@monthMay": { - "description": "Full name for May." - }, - "monthJun": "जून", - "@monthJun": { - "description": "Full name for June." - }, - "monthJul": "जुलाई", - "@monthJul": { - "description": "Full name for July." - }, - "monthAug": "अगस्त", - "@monthAug": { - "description": "Abbreviation for August." - }, - "monthSep": "सितंबर", - "@monthSep": { - "description": "Abbreviation for September." - }, - "monthOct": "अक्टूबर", - "@monthOct": { - "description": "Abbreviation for October." - }, - "monthNov": "नवंबर", - "@monthNov": { - "description": "Abbreviation for November." - }, - "monthDec": "दिसंबर", - "@monthDec": { - "description": "Abbreviation for December." - }, - "register": "रजिस्टर करें", - "@register": { - "description": "Button text to register a new account." - }, - "newToResonate": "रेज़ोनेट पर नए हैं? ", - "@newToResonate": { - "description": "Text preceding the 'Create new account' link on the login screen." - }, - "alreadyHaveAccount": "पहले से अकाउंट है? ", - "@alreadyHaveAccount": { - "description": "Text preceding the 'Login' link on the sign-up screen." - }, - "checking": "जाँच हो रही है...", - "@checking": { - "description": "A temporary status message indicating a check is in progress." - }, - "forgotPasswordMessage": "पासवर्ड रीसेट करने के लिए अपना रजिस्टर्ड ईमेल दर्ज करें।", - "@forgotPasswordMessage": { - "description": "Instructional text on the forgot password screen." - }, - "usernameUnavailable": "यूज़रनेम उपलब्ध नहीं है!", - "@usernameUnavailable": { - "description": "Title for the error when a chosen username is taken." - }, - "usernameInvalidOrTaken": "यह यूज़रनेम अमान्य है या पहले से लिया जा चुका है।", - "@usernameInvalidOrTaken": { - "description": "Error message explaining why a username cannot be used." - }, - "otpResentMessage": "कृपया अपनी ईमेल जांचें, नया OTP भेजा गया है।", - "@otpResentMessage": { - "description": "Message informing the user to check their email for a new OTP." - }, - "connectionError": "कनेक्शन में समस्या है। कृपया इंटरनेट चेक करें और दोबारा प्रयास करें।", - "@connectionError": { - "description": "Generic error message for network connection issues." - }, - "seconds": "सेकंड", - "@seconds": { - "description": "The word 'seconds', often used after a countdown number." - }, - "unsavedChangesWarning": "यदि आप बिना सेव किए आगे बढ़ते हैं, तो आपके सभी बदलाव खो जाएंगे।", - "@unsavedChangesWarning": { - "description": "A warning message shown when a user tries to leave a screen with unsaved changes." - }, - "deleteAccountPermanent": "यह प्रक्रिया आपके अकाउंट को हमेशा के लिए डिलीट कर देगी। आपका यूज़रनेम, ईमेल और सारा डेटा हटा दिया जाएगा। यह वापस नहीं लिया जा सकता।", - "@deleteAccountPermanent": { - "description": "A detailed warning message explaining the consequences of deleting an account." - }, - "giveGreatName": "एक अच्छा नाम सोचें...", - "@giveGreatName": { - "description": "Placeholder text for a name input field, encouraging a creative name." - }, - "joinCommunityDescription": "कम्युनिटी से जुड़कर आप सवाल पूछ सकते हैं, नए फीचर्स का सुझाव दे सकते हैं, और अपने अनुभव साझा कर सकते हैं।", - "@joinCommunityDescription": { - "description": "A description of the benefits of joining the community." - }, - "resonateDescription": "रेज़ोनेट एक सोशल मीडिया प्लेटफॉर्म है जहाँ हर आवाज़ की कद्र की जाती है। अपने विचार, कहानियाँ और अनुभव साझा करें। ऑडियो जर्नी अभी शुरू करें और उन टॉपिक्स में हिस्सा लें जो आपसे जुड़ते हैं।", - "@resonateDescription": { - "description": "A short, user-facing description of the Resonate application." - }, - "resonateFullDescription": "रेज़ोनेट एक क्रांतिकारी वॉयस-बेस्ड सोशल मीडिया प्लेटफॉर्म है जहाँ हर आवाज़ मायने रखती है।\nरियल-टाइम ऑडियो बातचीत में भाग लें, और अपने जैसे विचारों वाले लोगों से जुड़ें। हमारा प्लेटफॉर्म आपको देता है:\n- लाइव ऑडियो रूम्स\n- वॉयस के ज़रिए सोशल नेटवर्किंग\n- कम्युनिटी-ड्रिवन कंटेंट मॉडरेशन\n- क्रॉस-प्लेटफॉर्म सपोर्ट\n- एन्ड-टू-एन्ड एन्क्रिप्टेड प्राइवेट चैट्स\n\nयह ऐप AOSSIE ओपन-सोर्स कम्युनिटी द्वारा बनाया गया है, जो आपकी प्राइवेसी और पारदर्शी विकास को प्राथमिकता देती है।", - "@resonateFullDescription": { - "description": "A comprehensive, detailed description of the Resonate application, its features, and its mission." - }, - "stable": "स्थिर", - "@stable": { - "description": "A label indicating a stable, non-beta version of the app." - }, - "usernameCharacterLimit": "यूज़रनेम में कम से कम 6 अक्षर होने चाहिए।", - "@usernameCharacterLimit": { - "description": "Error message when a chosen username is too short." - }, - "submit": "सबमिट करें", - "@submit": { - "description": "Generic button text for submitting a form." - }, - "anonymous": "गुमनाम", - "@anonymous": { - "description": "Label for an anonymous user or identity." - }, - "noSearchResults": "कोई रिज़ल्ट नहीं मिला", - "@noSearchResults": { - "description": "Message displayed when a search returns no results." - }, - "searchRooms": "रूम खोजें...", - "@searchRooms": { - "description": "Placeholder text for room search input field." - }, - "searchingRooms": "रूम खोजे जा रहे हैं...", - "@searchingRooms": { - "description": "Loading message shown while searching for rooms." - }, - "clearSearch": "खोज साफ़ करें", - "@clearSearch": { - "description": "Text for button to clear search results." - }, - "searchError": "खोज त्रुटि", - "@searchError": { - "description": "Title for search error messages." - }, - "searchRoomsError": "कमरों की खोज असफल हुई। कृपया दोबारा कोशिश करें।", - "@searchRoomsError": { - "description": "Error message when room search fails." - }, - "searchUpcomingRoomsError": "आगामी कमरों की खोज असफल हुई। कृपया दोबारा कोशिश करें।", - "@searchUpcomingRoomsError": { - "description": "Error message when upcoming room search fails." - }, - "search": "खोजें", - "@search": { - "description": "Tooltip text for search button." - }, - "clear": "साफ़ करें", - "@clear": { - "description": "Tooltip text for clear button." - }, - "shareRoomMessage": "🚀 इस शानदार रूम को देखें: {roomName}!\n\n📖 विवरण: {description}\n👥 अभी {participants} लोग जुड़ चुके हैं!", - "@shareRoomMessage": { - "description": "The default message template used when sharing a room.", - "placeholders": { - "roomName": { - "type": "String", - "example": "Discussion Room" - }, - "description": { - "type": "String", - "example": "A great place to discuss" - }, - "participants": { - "type": "int", - "example": "5" - } - } - }, - "participantsCount": "{count} प्रतिभागी", - "@participantsCount": { - "description": "Displays the number of participants in a room.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "join": "शामिल हों", - "@join": { - "description": "Generic button text to join an activity or group." - }, - "invalidTags": "अमान्य टैग:", - "@invalidTags": { - "description": "Error message prefix for an invalid tag." - }, - "cropImage": "इमेज क्रॉप करें", - "@cropImage": { - "description": "Title or button text for the image cropping tool." - }, - "profileSavedSuccessfully": "प्रोफ़ाइल सेव हो गई", - "@profileSavedSuccessfully": { - "description": "Short success message indicating profile changes have been saved." - }, - "profileUpdatedSuccessfully": "आपके सभी बदलाव सफलतापूर्वक सेव हो गए हैं।", - "@profileUpdatedSuccessfully": { - "description": "Detailed success message confirming profile update." - }, - "profileUpToDate": "आपकी प्रोफ़ाइल पूरी तरह अपडेट है", - "@profileUpToDate": { - "description": "Title for the message when there are no changes to save on the profile." - }, - "noChangesToSave": "कोई नया बदलाव नहीं है, सेव करने की ज़रूरत नहीं।", - "@noChangesToSave": { - "description": "Message displayed when the user tries to save their profile without making any changes." - }, - "connectionFailed": "कनेक्शन फेल हो गया", - "@connectionFailed": { - "description": "Generic title for connection-related errors." - }, - "unableToJoinRoom": "रूम से कनेक्ट नहीं हो सका। कृपया नेटवर्क चेक करें और फिर से कोशिश करें।", - "@unableToJoinRoom": { - "description": "Error message when a user fails to join a room due to connection issues." - }, - "connectionLost": "कनेक्शन टूट गया", - "@connectionLost": { - "description": "Title indicating that the connection to a service was lost." - }, - "unableToReconnect": "रूम से दोबारा कनेक्ट नहीं हो सका। कृपया फिर से जुड़ने का प्रयास करें।", - "@unableToReconnect": { - "description": "Error message when the app fails to reconnect the user to a room." - }, - "invalidFormat": "अमान्य फ़ॉर्मेट!", - "@invalidFormat": { - "description": "Generic error message for data with an incorrect format." - }, - "usernameAlphanumeric": "यूज़रनेम केवल अक्षर और अंक होने चाहिए, कोई स्पेशल कैरेक्टर नहीं।", - "@usernameAlphanumeric": { - "description": "Error message detailing the character requirements for a valid username." - }, - "userProfileCreatedSuccessfully": "आपकी यूज़र प्रोफ़ाइल सफलतापूर्वक बनाई गई है।", - "@userProfileCreatedSuccessfully": { - "description": "Success message after a user completes their profile setup." - }, - "emailVerificationMessage": "आगे बढ़ने के लिए कृपया अपना ईमेल वेरिफाई करें।", - "@emailVerificationMessage": { - "description": "An instructional message prompting the user to verify their email." - }, - "addNewChaptersToStory": "{storyName} में नए चैप्टर्स जोड़ें", - "@addNewChaptersToStory": { - "description": "Title for the screen where new chapters are added to an existing story.", - "placeholders": { - "storyName": { - "type": "String", - "example": "My Story" - } - } - }, - "currentChapters": "मौजूदा चैप्टर्स", - "@currentChapters": { - "description": "Header for the list of existing chapters in a story." - }, - "sourceCodeOnGitHub": "GitHub पर सोर्स कोड", - "@sourceCodeOnGitHub": { - "description": "Link text to the project's source code on GitHub." - }, - "createAChapter": "नया चैप्टर बनाएं", - "@createAChapter": { - "description": "Title for the screen where a new chapter is created." - }, - "chapterTitle": "चैप्टर का टाइटल *", - "@chapterTitle": { - "description": "Label for the chapter title input field, indicating it is required." - }, - "aboutRequired": "कहानी का परिचय *", - "@aboutRequired": { - "description": "Label for the 'About' or description input field, indicating it is required." - }, - "changeCoverImage": "कवर इमेज बदलें", - "@changeCoverImage": { - "description": "Button text to change the cover image of a story or chapter." - }, - "uploadAudioFile": "ऑडियो फाइल अपलोड करें", - "@uploadAudioFile": { - "description": "Button text to upload an audio file." - }, - "uploadLyricsFile": "लिरिक्स फाइल अपलोड करें", - "@uploadLyricsFile": { - "description": "Button text to upload a lyrics file." - }, - "createChapter": "चैप्टर बनाएं", - "@createChapter": { - "description": "Button text to finalize and create a new chapter." - }, - "audioFileSelected": "ऑडियो फाइल चुनी गई: {fileName}", - "@audioFileSelected": { - "description": "Message shown after an audio file has been selected for upload.", - "placeholders": { - "fileName": { - "type": "String", - "example": "audio.mp3" - } - } - }, - "lyricsFileSelected": "लिरिक्स फाइल चुनी गई: {fileName}", - "@lyricsFileSelected": { - "description": "Message shown after a lyrics file has been selected for upload.", - "placeholders": { - "fileName": { - "type": "String", - "example": "lyrics.txt" - } - } - }, - "fillAllRequiredFields": "कृपया सभी जरूरी फ़ील्ड भरें और अपनी ऑडियो व लिरिक्स फाइल अपलोड करें", - "@fillAllRequiredFields": { - "description": "Error message when required fields or files are missing from a form." - }, - "scheduled": "शेड्यूल्ड", - "@scheduled": { - "description": "A status label indicating an event is scheduled for the future." - }, - "ok": "ठीक है", - "@ok": { - "description": "Generic confirmation button text, often for closing a dialog." - }, - "roomDescriptionOptional": "रूम का विवरण (वैकल्पिक)", - "@roomDescriptionOptional": { - "description": "Label for the room description input field, indicating it is not required." - }, - "deleteAccount": "अकाउंट हटाएं", - "@deleteAccount": { - "description": "Button text to initiate the account deletion process." - }, - "createYourStory": "अपनी कहानी बनाएं", - "@createYourStory": { - "description": "Title for the screen where a new story is created." - }, - "titleRequired": "टाइटल *", - "@titleRequired": { - "description": "Label for the title input field, indicating it is required." - }, - "category": "कैटेगरी *", - "@category": { - "description": "Label for the category selection, indicating it is required." - }, - "addChapter": "चैप्टर जोड़ें", - "@addChapter": { - "description": "Button text to add a chapter to a story." - }, - "createStory": "कहानी बनाएं", - "@createStory": { - "description": "Button text to finalize and create a new story." - }, - "fillAllRequiredFieldsAndChapter": "कृपया सभी ज़रूरी फ़ील्ड भरें, कम से कम एक चैप्टर जोड़ें और कवर इमेज चुनें।", - "@fillAllRequiredFieldsAndChapter": { - "description": "Error message for the story creation form when requirements are not met." - }, - "toConfirmType": "पुष्टि करने के लिए टाइप करें", - "@toConfirmType": { - "description": "Instructional text in a confirmation dialog, preceding the text to be typed." - }, - "inTheBoxBelow": "नीचे दिए गए बॉक्स में", - "@inTheBoxBelow": { - "description": "Instructional text in a confirmation dialog, following the text to be typed." - }, - "iUnderstandDeleteMyAccount": "मैं समझता/समझती हूँ, मेरा अकाउंट डिलीट करें", - "@iUnderstandDeleteMyAccount": { - "description": "The specific phrase a user must type to confirm account deletion." - }, - "whatDoYouWantToListenTo": "आप क्या सुनना चाहते हैं?", - "@whatDoYouWantToListenTo": { - "description": "A prompt on the main screen, encouraging user engagement." - }, - "categories": "श्रेणियाँ", - "@categories": { - "description": "Header for the list of categories." - }, - "stories": "कहानियाँ", - "@stories": { - "description": "Header for the list of stories." - }, - "someSuggestions": "कुछ सुझाव", - "@someSuggestions": { - "description": "Header for a list of suggested content." - }, - "getStarted": "शुरू करें", - "@getStarted": { - "description": "Button text on an onboarding screen to begin using the app." - }, - "skip": "स्किप करें", - "@skip": { - "description": "Button text to skip an optional step, like onboarding." - }, - "welcomeToResonate": "रेज़ोनेट में आपका स्वागत है", - "@welcomeToResonate": { - "description": "Onboarding screen title." - }, - "exploreDiverseConversations": "विविध बातचीत का अनुभव लें", - "@exploreDiverseConversations": { - "description": "Feature highlight on an onboarding screen." - }, - "yourVoiceMatters": "आपकी आवाज़ मायने रखती है", - "@yourVoiceMatters": { - "description": "Feature highlight on an onboarding screen." - }, - "joinConversationExploreRooms": "बातचीत में शामिल हों! रूम्स एक्सप्लोर करें, दोस्तों से जुड़ें और अपनी आवाज़ दुनिया तक पहुँचाएं।", - "@joinConversationExploreRooms": { - "description": "Descriptive text on an onboarding screen." - }, - "diveIntoDiverseDiscussions": "विभिन्न चर्चाओं में शामिल हों।\nऐसे रूम्स खोजें जो आपसे जुड़ते हों और कम्युनिटी का हिस्सा बनें।", - "@diveIntoDiverseDiscussions": { - "description": "Descriptive text on an onboarding screen." - }, - "atResonateEveryVoiceValued": "रेज़ोनेट में हर आवाज़ की अहमियत है। अपने विचार, कहानियाँ और अनुभव साझा करें। अपनी ऑडियो जर्नी अभी शुरू करें।", - "@atResonateEveryVoiceValued": { - "description": "Descriptive text on an onboarding screen." - }, - "notifications": "नोटिफिकेशन", - "@notifications": { - "description": "Title for the notifications screen." - }, - "taggedYouInUpcomingRoom": "{username} ने आपको एक अपकमिंग रूम में टैग किया: {subject}", - "@taggedYouInUpcomingRoom": { - "description": "Notification message when tagged in an upcoming room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "taggedYouInRoom": "{username} ने आपको एक रूम में टैग किया: {subject}", - "@taggedYouInRoom": { - "description": "Notification message when tagged in a live room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "likedYourStory": "{username} ने आपकी कहानी को पसंद किया: {subject}", - "@likedYourStory": { - "description": "Notification message when someone likes your story.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Story" - } - } - }, - "subscribedToYourRoom": "{username} ने आपके रूम को सब्सक्राइब किया: {subject}", - "@subscribedToYourRoom": { - "description": "Notification message when someone subscribes to your room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Room" - } - } - }, - "startedFollowingYou": "{username} ने आपको फॉलो करना शुरू किया", - "@startedFollowingYou": { - "description": "Notification message when someone starts following you.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "youHaveNewNotification": "आपके लिए एक नया नोटिफिकेशन है", - "@youHaveNewNotification": { - "description": "Generic title for a new notification." - }, - "hangOnGoodThingsTakeTime": "रुकिए — अच्छी चीज़ों में समय लगता है 🔍", - "@hangOnGoodThingsTakeTime": { - "description": "A friendly message displayed during a long loading process." - }, - "resonateOpenSourceProject": "रेज़ोनेट एक ओपन-सोर्स प्रोजेक्ट है जिसे AOSSIE बनाए रखता है। योगदान देने के लिए हमारा GitHub देखें।", - "@resonateOpenSourceProject": { - "description": "Informational text about the open-source nature of the project." - }, - "mute": "म्यूट करें", - "@mute": { - "description": "Button text to mute the microphone." - }, - "speakerLabel": "स्पीकर", - "@speakerLabel": { - "description": "Label for the speakerphone toggle." - }, - "audioOptions": "ऑडियो विकल्प", - "@audioOptions": { - "description": "Label for the audio options/settings button." - }, - "end": "समाप्त करें", - "@end": { - "description": "Button text to end a call or session." - }, - "saveChanges": "बदलाव सेव करें", - "@saveChanges": { - "description": "Button text to save modified settings or profile information." - }, - "discard": "रद्द करें", - "@discard": { - "description": "Button text to discard changes." - }, - "save": "सेव करें", - "@save": { - "description": "Button text to save changes." - }, - "changeProfilePicture": "प्रोफ़ाइल पिक्चर बदलें", - "@changeProfilePicture": { - "description": "Button text to open the profile picture selection options." - }, - "camera": "कैमरा", - "@camera": { - "description": "Option to use the camera to take a picture." - }, - "gallery": "गैलरी", - "@gallery": { - "description": "Option to choose a picture from the gallery." - }, - "remove": "हटाएं", - "@remove": { - "description": "Button text to remove an item, like a profile picture." - }, - "created": "बनाई गई: {date}", - "@created": { - "description": "Displays the creation date of a story or content.", - "placeholders": { - "date": { - "type": "String", - "example": "Jan 15, 2023" - } - } - }, - "chapters": "चैप्टर", - "@chapters": { - "description": "Header for the list of chapters in a story." - }, - "deleteStory": "कहानी हटाएं", - "@deleteStory": { - "description": "Button text to delete a story." - }, - "createdBy": "{creatorName} द्वारा बनाई गई", - "@createdBy": { - "description": "Displays the name of the content creator.", - "placeholders": { - "creatorName": { - "type": "String", - "example": "John Doe" - } - } - }, - "start": "शुरू करें", - "@start": { - "description": "Button text to start an activity or session." - }, - "unsubscribe": "सब्सक्रिप्शन हटाएं", - "@unsubscribe": { - "description": "Button text to unsubscribe from a room or notification." - }, - "subscribe": "सब्सक्राइब करें", - "@subscribe": { - "description": "Button text to subscribe to a room or notification." - }, - "storyCategory": "{category, select, drama{नाटक} comedy{हास्य} horror{डरावनी} romance{प्रेम कथा} thriller{रोमांच} spiritual{आध्यात्मिक} other{अन्य}}", - "@storyCategory": { - "description": "Selects the appropriate category name for a story based on a key.", - "placeholders": { - "category": { - "type": "String", - "example": "drama" - } - } - }, - "chooseTheme": "{category, select, classicTheme{क्लासिक} timeTheme{टाइम} vintageTheme{विंटेज} amberTheme{अंबर} forestTheme{फॉरेस्ट} creamTheme{क्रीम} other{अन्य}}", - "@chooseTheme": { - "description": "Selects the appropriate theme name based on a key.", - "placeholders": { - "category": { - "type": "String", - "example": "classic" - } - } - }, - "minutesAgo": "{count, plural, =1{1 मिनट पहले} other{{count} मिनट पहले}}", - "@minutesAgo": { - "description": "Displays time in a relative format for minutes.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "hoursAgo": "{count, plural, =1{1 घंटा पहले} other{{count} घंटे पहले}}", - "@hoursAgo": { - "description": "Displays time in a relative format for hours.", - "placeholders": { - "count": { - "type": "int", - "example": "2" - } - } - }, - "daysAgo": "{count, plural, =1{1 दिन पहले} other{{count} दिन पहले}}", - "@daysAgo": { - "description": "Displays time in a relative format for days.", - "placeholders": { - "count": { - "type": "int", - "example": "3" - } - } - }, - "by": "प्रस्तुतकर्ता:", - "@by": { - "description": "A preposition, typically used between a content title and the author's name." - }, - "likes": "लाइक्स", - "@likes": { - "description": "Label for the number of likes." - }, - "lengthMinutes": "मिनट", - "@lengthMinutes": { - "description": "Abbreviation for 'minutes', used to display audio length." - }, - "requiredField": "आवश्यक फील्ड", - "@requiredField": { - "description": "Error message for a mandatory field that was left empty." - }, - "onlineUsers": "ऑनलाइन यूज़र्स", - "@onlineUsers": { - "description": "Header for the list of users who are currently online." - }, - "noOnlineUsers": "कोई यूज़र अभी ऑनलाइन नहीं है", - "@noOnlineUsers": { - "description": "Message displayed when no users are online." - }, - "chooseUser": "चैट करने के लिए यूज़र चुनें", - "@chooseUser": { - "description": "Instructional text to select a user to start a chat." - }, - "quickMatch": "झटपट मैच", - "@quickMatch": { - "description": "Button text for a feature that quickly matches the user with a random chat partner." - }, - "story": "कहानी", - "@story": { - "description": "Label for a story." - }, - "user": "यूज़र", - "@user": { - "description": "Label for a user." - }, - "following": "फॉलो कर रहे हैं", - "@following": { - "description": "Label for the list of users that the current user is following." - }, - "followers": "फॉलोअर्स", - "@followers": { - "description": "Label for the list of users who are following the current user." - }, - "friendRequests": "फ्रेंड रिक्वेस्ट", - "@friendRequests": { - "description": "Header for the list of incoming friend requests." - }, - "friendRequestSent": "मित्रता अनुरोध भेजा गया", - "@friendRequestSent": { - "description": "Title for the confirmation message after sending a friend request." - }, - "friendRequestSentTo": "आपका मित्रता अनुरोध {username} को भेजा गया है।", - "@friendRequestSentTo": { - "description": "Confirmation message after sending a friend request to a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestCancelled": "मित्रता अनुरोध रद्द कर दिया गया", - "@friendRequestCancelled": { - "description": "Title for the confirmation message after cancelling a friend request." - }, - "friendRequestCancelledTo": "{username} को आपका मित्रता अनुरोध रद्द कर दिया गया है।", - "@friendRequestCancelledTo": { - "description": "Confirmation message after cancelling a friend request to a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "requested": "अनुरोध किया है", - "@requested": { - "description": "A status label on a button indicating a friend request has been sent." - }, - "friends": "मित्र", - "@friends": { - "description": "A status label or header for the list of friends." - }, - "addFriend": "मित्र जोड़ें", - "@addFriend": { - "description": "Button text to send a friend request." - }, - "friendRequestAccepted": "मित्रता अनुरोध स्वीकार कर लिया गया", - "@friendRequestAccepted": { - "description": "Title for the confirmation message after accepting a friend request." - }, - "friendRequestAcceptedTo": "आपने {username} के मित्रता अनुरोध को स्वीकार कर लिया है।", - "@friendRequestAcceptedTo": { - "description": "Confirmation message after accepting a friend request from a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestDeclined": "मित्रता अनुरोध अस्वीकार कर दिया गया", - "@friendRequestDeclined": { - "description": "Title for the confirmation message after declining a friend request." - }, - "friendRequestDeclinedTo": "आपने {username} के मित्रता अनुरोध को अस्वीकार कर दिया है।", - "@friendRequestDeclinedTo": { - "description": "Confirmation message after declining a friend request from a specific user.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "accept": "स्वीकार करें", - "@accept": { - "description": "Button text to accept a request or invitation." - }, - "callDeclined": "कॉल अस्वीकार कर दिया गया", - "@callDeclined": { - "description": "Title for the message when a call is declined." - }, - "callDeclinedTo": "{username} ने आपकी कॉल को अस्वीकार कर दिया है।", - "@callDeclinedTo": { - "description": "Message indicating that a specific user declined a call.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "checkForUpdates": "अपडेट चेक करें", - "@checkForUpdates": { - "description": "Button text to manually check for application updates." - }, - "updateNow": "अभी अपडेट करें", - "@updateNow": { - "description": "Button text to start the update process." - }, - "updateLater": "बाद में", - "@updateLater": { - "description": "Button text to postpone an update." - }, - "updateSuccessful": "अपडेट सफल", - "@updateSuccessful": { - "description": "Title for the success message after an update." - }, - "updateSuccessfulMessage": "रेज़ोनेट सफलतापूर्वक अपडेट हो गया है!", - "@updateSuccessfulMessage": { - "description": "Success message confirming the application has been updated." - }, - "updateCancelled": "अपडेट रद्द किया गया", - "@updateCancelled": { - "description": "Title for the message when an update is cancelled." - }, - "updateCancelledMessage": "अपडेट यूज़र द्वारा रद्द किया गया", - "@updateCancelledMessage": { - "description": "Message indicating the user cancelled the update process." - }, - "updateFailed": "अपडेट फेल", - "@updateFailed": { - "description": "Title for the message when an update fails." - }, - "updateFailedMessage": "अपडेट फेल हो गया। कृपया Play Store से मैन्युअली अपडेट करने का प्रयास करें।", - "@updateFailedMessage": { - "description": "Error message when an in-app update fails, suggesting a manual update." - }, - "updateError": "अपडेट एरर", - "@updateError": { - "description": "Generic title for an update-related error." - }, - "updateErrorMessage": "अपडेट करने में कोई समस्या आई। कृपया फिर से प्रयास करें।", - "@updateErrorMessage": { - "description": "Generic error message for an update that failed due to an unknown error." - }, - "platformNotSupported": "प्लेटफॉर्म सपोर्टेड नहीं", - "@platformNotSupported": { - "description": "Title for an error when a feature is not supported on the current platform." - }, - "platformNotSupportedMessage": "अपडेट चेक करना केवल Android डिवाइस पर उपलब्ध है", - "@platformNotSupportedMessage": { - "description": "Message explaining that the in-app update feature is platform-specific." - }, - "updateCheckFailed": "अपडेट चेक फेल", - "@updateCheckFailed": { - "description": "Title for an error when the app fails to check for updates." - }, - "updateCheckFailedMessage": "अपडेट चेक नहीं हो सका। कृपया बाद में प्रयास करें।", - "@updateCheckFailedMessage": { - "description": "Error message when the check for updates process fails." - }, - "upToDateTitle": "आप अप टू डेट हैं!", - "@upToDateTitle": { - "description": "Title for the message when the app is already up to date." - }, - "upToDateMessage": "आप रेज़ोनेट का लेटेस्ट वर्शन इस्तेमाल कर रहे हैं", - "@upToDateMessage": { - "description": "Message confirming that no update is needed." - }, - "updateAvailableTitle": "अपडेट उपलब्ध है!", - "@updateAvailableTitle": { - "description": "Title for the dialog informing the user about a new update." - }, - "updateAvailableMessage": "रेज़ोनेट का नया वर्शन Play Store पर उपलब्ध है", - "@updateAvailableMessage": { - "description": "Message prompting the user to update the app from the Play Store." - }, - "updateFeaturesImprovement": "नवीनतम सुविधाएं और सुधार प्राप्त करें!", - "@updateFeaturesImprovement": { - "description": "A message highlighting the benefit of updating the application." - }, - "hide": "छुपाएं", - "@hide": { - "description": "Button text to hide an item from view." - }, - "removeRoom": "रूम छुपाएं", - "@removeRoom": { - "description": "Dialog title for hiding an upcoming room." - }, - "removeRoomFromList": "सूची से छुपाएं", - "@removeRoomFromList": { - "description": "Tooltip text for the hide room button." - }, - "removeRoomConfirmation": "क्या आप वाकई इस आने वाले रूम को अपनी सूची से छुपाना चाहते हैं?", - "@removeRoomConfirmation": { - "description": "Confirmation message asking if the user wants to hide an upcoming room from their list." - }, - "failedToRemoveRoom": "रूम हटाने में विफल", - "@failedToRemoveRoom": { - "description": "Error message when unable to remove a room from the list" - }, - "roomRemovedSuccessfully": "रूम आपकी सूची से सफलतापूर्वक हटा दिया गया", - "@roomRemovedSuccessfully": { - "description": "Success message when a room is successfully removed from the user's list" - }, - "alert": "अलर्ट", - "@alert": { - "description": "Title for an alert dialog." - }, - "removedFromRoom": "आपको कमरे से रिपोर्ट किया गया है या हटा दिया गया है", - "@removedFromRoom": { - "description": "Message shown when a user is removed or reported from a room." - }, - "reportType": "{type, select, harassment{हिंसा / नफरत भरा भाषण} abuse{दुरुपयोग सामग्री / हिंसा} spam{स्पैम / धोखाधड़ी} impersonation{नकली खाते} illegal{गैरकानूनी गतिविधियाँ} selfharm{आत्म-हानि / आत्महत्या / मानसिक स्वास्थ्य} misuse{प्लेटफ़ॉर्म का दुरुपयोग} other{अन्य}}", - "@reportType": { - "description": "Selects the appropriate report type label based on a key.", - "placeholders": { - "type": { - "type": "String", - "example": "harassment" - } - } - }, - "userBlockedFromResonate": "आपको कई उपयोगकर्ताओं द्वारा रिपोर्ट किया गया है और आपको रेज़ोनेट का उपयोग करने से ब्लॉक कर दिया गया है। यदि आपको लगता है कि यह गलती है, तो कृपया AOSSIE से संपर्क करें।", - "@userBlockedFromResonate": { - "description": "Message shown when a user is blocked from using Resonate." - }, - "reportParticipant": "प्रतिभागी की रिपोर्ट करें", - "@reportParticipant": { - "description": "Label for the button to report a participant." - }, - "selectReportType": "रिपोर्ट का प्रकार चुनें", - "@selectReportType": { - "description": "Prompt for the user to select a report type." - }, - "reportSubmitted": "रिपोर्ट सफलतापूर्वक सबमिट की गई", - "@reportSubmitted": { - "description": "Message shown when a report is submitted successfully." - }, - "reportFailed": "रिपोर्ट सबमिशन फेल", - "@reportFailed": { - "description": "Message shown when a report submission fails." - }, - "additionalDetailsOptional": "अतिरिक्त विवरण (वैकल्पिक)", - "@additionalDetailsOptional": { - "description": "Label for an optional input field for additional details in a report." - }, - "submitReport": "रिपोर्ट सबमिट करें", - "@submitReport": { - "description": "Button text to submit a report." - }, - "actionBlocked": "कार्रवाई अवरुद्ध", - "@actionBlocked": { - "description": "Title for a message indicating that a user action is blocked." - }, - "cannotStopRecording": "आप रिकॉर्डिंग को मैन्युअल रूप से रोक नहीं सकते, रिकॉर्डिंग तब रोकी जाएगी जब कमरा बंद होगा।", - "@cannotStopRecording": { - "description": "Message explaining why a user cannot stop a recording manually." - }, - "liveChapter": "लाइव चैप्टर", - "@liveChapter": { - "description": "Label indicating that a chapter is currently live." - }, - "viewOrEditLyrics": "गीत देखें या संपादित करें", - "@viewOrEditLyrics": { - "description": "Button text to view or edit the lyrics of a chapter." - }, - "close": "बंद करें", - "@close": { - "description": "Label for the button to close a dialog." - }, - "verifyChapterDetails": "चैप्टर विवरण सत्यापित करें", - "@verifyChapterDetails": { - "description": "Title for the screen where Live chapter details are verified before publishing." - }, - "author": "लेखक", - "@author": { - "description": "Label for the author of a story or chapter." - }, - "startLiveChapter": "लाइव चैप्टर शुरू करें", - "@startLiveChapter": { - "description": "Title for the screen where a live chapter is initiated." - }, - "fillAllFields": "कृपया सभी आवश्यक फ़ील्ड भरें", - "@fillAllFields": { - "description": "Error message when required fields are not filled in a form." - }, - "noRecordingError": "आपके पास कोई रिकॉर्डिंग नहीं है। लाइव चैप्टर रूम से बाहर निकलने के लिए, कृपया पहले रिकॉर्डिंग शुरू करें।", - "@noRecordingError": { - "description": "Error message when trying to exit a live chapter room without any recording." - }, - "audioOutput": "ऑडियो आउटपुट", - "@audioOutput": { - "description": "Title for audio output device selector." - }, - "selectPreferredSpeaker": "अपना पसंदीदा स्पीकर चुनें", - "@selectPreferredSpeaker": { - "description": "Subtitle for audio device selector dialog." - }, - "noAudioOutputDevices": "कोई ऑडियो आउटपुट डिवाइस नहीं मिला", - "@noAudioOutputDevices": { - "description": "Message shown when no audio output devices are available." - }, - "refresh": "रीफ़्रेश करें", - "@refresh": { - "description": "Button text to refresh audio device list." - }, - "done": "हो गया", - "@done": { - "description": "Button text to close audio device selector." - }, - "deleteMessageTitle": "संदेश हटाएँ", -"@deleteMessageTitle": { - "description": "Title shown in the delete message confirmation dialog." -}, -"deleteMessageContent": "क्या आप वाकई इस संदेश को हटाना चाहते हैं?", -"@deleteMessageContent": { - "description": "Confirmation text asking the user if they want to delete a message." -}, -"thisMessageWasDeleted": "यह संदेश हटा दिया गया है", -"failedToDeleteMessage": "संदेश हटाने में विफल रहा", - -"noFriendsYet": "अभी तक कोई दोस्त नहीं", -"@noFriendsYet": { - "description": "Title shown when user has no friends." -}, -"noFriendsDescription": "आपकी दोस्तों की सूची खाली है। लोगों से जुड़ना शुरू करें और अपना नेटवर्क बढ़ाएं!", -"@noFriendsDescription": { - "description": "Description shown when user has no friends." -}, -"findFriends": "दोस्त खोजें", -"@findFriends": { - "description": "Button text to navigate to find friends screen." -}, -"inviteFriend": "दोस्त को आमंत्रित करें", -"@inviteFriend": { - "description": "Button text to invite friends to the app." -}, -"noFriendRequestsYet": "कोई फ्रेंड रिक्वेस्ट नहीं", -"@noFriendRequestsYet": { - "description": "Title shown when user has no friend requests." -}, -"noFriendRequestsDescription": "आपके पास कोई पेंडिंग फ्रेंड रिक्वेस्ट नहीं है। जुड़ने के लिए अपने दोस्तों को आमंत्रित करें!", -"@noFriendRequestsDescription": { - "description": "Description shown when user has no friend requests." -}, -"inviteToResonate": "अरे! Resonate पर मेरे साथ जुड़ो - एक सोशल ऑडियो प्लेटफॉर्म जहां हर आवाज़ की कद्र होती है। अभी डाउनलोड करें: {url}", -"@inviteToResonate": { - "description": "Text used when inviting friends to the app.", - "placeholders": { - "url": { - "type": "String" - } - } -} - - -} \ No newline at end of file +{ + "@@locale": "hi", + "title": "रेज़ोनेट", + "@title": { + "description": "The title of the application." + }, + "roomDescription": "विनम्र रहें और दूसरे व्यक्ति की राय का सम्मान करें। अभद्र टिप्पणियों से बचें।", + "@roomDescription": { + "description": "Guideline message for users in a chat room." + }, + "hidePassword": "पासवर्ड छिपाएं", + "@hidePassword": { + "description": "Button text to conceal the password field." + }, + "showPassword": "पासवर्ड दिखाएं", + "@showPassword": { + "description": "Button text to reveal the password field." + }, + "passwordEmpty": "पासवर्ड खाली नहीं हो सकता", + "@passwordEmpty": { + "description": "Error message when the password field is left blank." + }, + "password": "पासवर्ड", + "@password": { + "description": "Label for the password input field." + }, + "confirmPassword": "पासवर्ड की पुष्टि करें", + "@confirmPassword": { + "description": "Label for the confirm password input field." + }, + "passwordsNotMatch": "पासवर्ड मेल नहीं खा रहे हैं", + "@passwordsNotMatch": { + "description": "Error message when password and confirmation password do not match." + }, + "userCreatedStories": "यूज़र की बनाई कहानियाँ", + "@userCreatedStories": { + "description": "Header for the section showing stories created by another user." + }, + "yourStories": "तुम्हारी कहानियाँ", + "@yourStories": { + "description": "Header for the section showing stories created by the current user." + }, + "userNoStories": "यूज़र ने अभी तक कोई कहानी नहीं बनाई", + "@userNoStories": { + "description": "Message displayed when viewing another user's profile and they have no stories." + }, + "youNoStories": "तुमने अभी तक कोई कहानी नहीं बनाई", + "@youNoStories": { + "description": "Message displayed on the current user's profile when they have no stories." + }, + "follow": "फॉलो करो", + "@follow": { + "description": "Button text to follow a user." + }, + "editProfile": "प्रोफाइल एडिट करो", + "@editProfile": { + "description": "Button text to navigate to the profile editing screen." + }, + "verifyEmail": "ईमेल वेरीफाई करो", + "@verifyEmail": { + "description": "Button or link text to start the email verification process." + }, + "verified": "वेरीफाइड", + "@verified": { + "description": "A status label indicating that the user's email has been verified." + }, + "profile": "प्रोफ़ाइल", + "@profile": { + "description": "Label for the user profile section or page." + }, + "userLikedStories": "यूज़र को पसंद आई कहानियाँ", + "@userLikedStories": { + "description": "Header for the section showing stories liked by another user." + }, + "yourLikedStories": "तुम्हें पसंद आई कहानियाँ", + "@yourLikedStories": { + "description": "Header for the section showing stories liked by the current user." + }, + "userNoLikedStories": "यूज़र ने अभी तक कोई कहानी लाइक नहीं की", + "@userNoLikedStories": { + "description": "Message displayed when viewing another user's profile and they have no liked stories." + }, + "youNoLikedStories": "तुमने अभी तक कोई कहानी लाइक नहीं की", + "@youNoLikedStories": { + "description": "Message displayed on the current user's profile when they have no liked stories." + }, + "live": "लाइव", + "@live": { + "description": "Tab or label for live audio rooms." + }, + "upcoming": "आने वाला", + "@upcoming": { + "description": "Tab or label for scheduled/upcoming audio rooms." + }, + "noAvailableRoom": "{isRoom, select, true{कोई रूम उपलब्ध नहीं है} false{कोई आने वाला रूम उपलब्ध नहीं है} other{रूम की जानकारी उपलब्ध नहीं है}}\nनीचे से एक रूम बनाकर शुरुआत करें!", + "@noAvailableRoom": { + "description": "Message when no room or upcoming room is available, with a call to action.", + "placeholders": { + "isRoom": { + "type": "String", + "example": "true" + } + } + }, + "user1": "यूज़र 1", + "@user1": { + "description": "A generic placeholder name for a user." + }, + "user2": "यूज़र 2", + "@user2": { + "description": "A second generic placeholder name for a user." + }, + "you": "तुम", + "@you": { + "description": "Label to identify the current user in a list or chat." + }, + "areYouSure": "क्या आप पक्के हैं?", + "@areYouSure": { + "description": "Confirmation prompt title." + }, + "loggingOut": "आप रेज़ोनेट से लॉग आउट हो रहे हैं।", + "@loggingOut": { + "description": "Confirmation prompt message for logging out." + }, + "yes": "हाँ", + "@yes": { + "description": "Generic confirmation button text." + }, + "no": "नहीं", + "@no": { + "description": "Generic cancellation button text." + }, + "incorrectEmailOrPassword": "ईमेल या पासवर्ड गलत है", + "@incorrectEmailOrPassword": { + "description": "Error message for failed login attempt." + }, + "passwordShort": "पासवर्ड 8 अक्षर से छोटा है", + "@passwordShort": { + "description": "Error message for a password that is too short." + }, + "tryAgain": "फिर कोशिश करो!", + "@tryAgain": { + "description": "Button text to retry a failed action." + }, + "success": "सफलता", + "@success": { + "description": "Generic title for a successful operation." + }, + "passwordResetSent": "पासवर्ड रीसेट ईमेल भेजा गया!", + "@passwordResetSent": { + "description": "Success message after a password reset email has been dispatched." + }, + "error": "गलती", + "@error": { + "description": "Generic title for a failed operation." + }, + "resetPassword": "पासवर्ड रीसेट करो", + "@resetPassword": { + "description": "Title or button text for the reset password feature." + }, + "enterNewPassword": "नया पासवर्ड डालो", + "@enterNewPassword": { + "description": "Instructional text on the reset password screen." + }, + "newPassword": "नया पासवर्ड", + "@newPassword": { + "description": "Label for the new password input field." + }, + "setNewPassword": "नया पासवर्ड सेट करो", + "@setNewPassword": { + "description": "Button text to confirm setting a new password." + }, + "emailChanged": "ईमेल बदल दी गई", + "@emailChanged": { + "description": "Title for the success dialog after an email change." + }, + "emailChangeSuccess": "ईमेल सफलतापूर्वक बदल गई!", + "@emailChangeSuccess": { + "description": "Success message after changing the user's email." + }, + "failed": "नाकाम", + "@failed": { + "description": "Generic title for a failed operation." + }, + "emailChangeFailed": "ईमेल बदलने में नाकाम", + "@emailChangeFailed": { + "description": "Error message when an email change operation fails." + }, + "oops": "उफ़!", + "@oops": { + "description": "An informal title for an error or warning message." + }, + "emailExists": "ईमेल पहले से मौजूद है", + "@emailExists": { + "description": "Error message when a user tries to register or change to an email that is already in use." + }, + "changeEmail": "ईमेल बदलो", + "@changeEmail": { + "description": "Title or button text for the change email feature." + }, + "enterValidEmail": "कृपया एक मान्य ईमेल डालो", + "@enterValidEmail": { + "description": "Error message for an invalid email format." + }, + "newEmail": "नया ईमेल", + "@newEmail": { + "description": "Label for the new email input field." + }, + "currentPassword": "मौजूदा पासवर्ड", + "@currentPassword": { + "description": "Label for the current password input field, used for verification." + }, + "emailChangeInfo": "सुरक्षा के लिए, ईमेल बदलने के लिए मौजूदा पासवर्ड डालें। बदलने के बाद, नए ईमेल से लॉगिन करें।", + "@emailChangeInfo": { + "description": "Informational text explaining the process and security requirements for changing an email address for standard users." + }, + "oauthUsersMessage": "(केवल Google या Github से लॉगिन करने वाले यूज़र्स के लिए)", + "@oauthUsersMessage": { + "description": "A message to specify that the following instructions are for OAuth users." + }, + "oauthUsersEmailChangeInfo": "ईमेल बदलने के लिए, \"मौजूदा पासवर्ड\" फील्ड में नया पासवर्ड डालें। इसे याद रखें—आगे केवल Google/GitHub या नए पासवर्ड से लॉगिन होगा।", + "@oauthUsersEmailChangeInfo": { + "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." + }, + "resonateTagline": "बातों की एक अनंत दुनिया में कदम रखें", + "@resonateTagline": { + "description": "The application's tagline, used on splash or login screens." + }, + "signInWithEmail": "ईमेल से साइन इन करो", + "@signInWithEmail": { + "description": "Button text for signing in using email and password." + }, + "or": "या", + "@or": { + "description": "A separator text, typically used between different login options." + }, + "continueWith": "इनमें से किसी एक से लॉगिन करें", + "@continueWith": { + "description": "Informational text preceding a list of third-party login providers." + }, + "continueWithGoogle": "Google से लॉगिन करें", + "@continueWithGoogle": { + "description": "Button text for signing in with a Google account." + }, + "continueWithGitHub": "GitHub से लॉगिन करें", + "@continueWithGitHub": { + "description": "Button text for signing in with a GitHub account." + }, + "resonateLogo": "रेज़ोनेट लोगो", + "@resonateLogo": { + "description": "Accessibility text for the Resonate application logo image." + }, + "iAlreadyHaveAnAccount": "मेरे पास पहले से एक अकाउंट है", + "@iAlreadyHaveAnAccount": { + "description": "Text for a link or button to navigate to the login screen from the sign-up screen." + }, + "createNewAccount": "नया अकाउंट बनाओ", + "@createNewAccount": { + "description": "Text for a link or button to navigate to the sign-up screen from the login screen." + }, + "userProfile": "यूज़र प्रोफ़ाइल", + "@userProfile": { + "description": "Accessibility text for a user's profile picture or avatar." + }, + "passwordIsStrong": "पासवर्ड मजबूत है", + "@passwordIsStrong": { + "description": "Validation message indicating that the entered password meets all strength requirements." + }, + "admin": "एडमिन", + "@admin": { + "description": "Label for the Admin user role in a room." + }, + "moderator": "मॉडरेटर", + "@moderator": { + "description": "Label for the Moderator user role in a room." + }, + "speaker": "स्पीकर", + "@speaker": { + "description": "Label for the Speaker user role in a room." + }, + "listener": "लिस्नर", + "@listener": { + "description": "Label for the Listener user role in a room." + }, + "removeModerator": "मॉडरेटर हटाओ", + "@removeModerator": { + "description": "Menu item text to revoke moderator privileges from a user." + }, + "kickOut": "किक आउट करो", + "@kickOut": { + "description": "Menu item text to remove a user from a room." + }, + "addModerator": "मॉडरेटर जोड़ो", + "@addModerator": { + "description": "Menu item text to grant moderator privileges to a user." + }, + "addSpeaker": "स्पीकर जोड़ो", + "@addSpeaker": { + "description": "Menu item text to grant speaker privileges to a listener." + }, + "makeListener": "लिस्नर बनाओ", + "@makeListener": { + "description": "Menu item text to change a speaker's role to listener." + }, + "pairChat": "पेयर चैट", + "@pairChat": { + "description": "A feature name for one-on-one random chat." + }, + "chooseIdentity": "पहचान चुनें", + "@chooseIdentity": { + "description": "Prompt for the user to choose their identity, e.g., anonymous or public." + }, + "selectLanguage": "भाषा चुनें", + "@selectLanguage": { + "description": "Label for the language selection setting." + }, + "noConnection": "कोई कनेक्शन नहीं", + "@noConnection": { + "description": "Title indicating that there is no internet connection." + }, + "loadingDialog": "लोड हो रहा है...", + "@loadingDialog": { + "description": "Accessibility text for a loading indicator or spinner." + }, + "createAccount": "अकाउंट बनाओ", + "@createAccount": { + "description": "Button text or page title for the account creation screen." + }, + "enterValidEmailAddress": "मान्य ईमेल पता डालो", + "@enterValidEmailAddress": { + "description": "Error message shown for an invalid email format." + }, + "email": "ईमेल", + "@email": { + "description": "Label for the email input field." + }, + "passwordRequirements": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए", + "@passwordRequirements": { + "description": "Instructional text listing the requirements for a valid password, part 1." + }, + "includeNumericDigit": "कम से कम 1 संख्या शामिल करें", + "@includeNumericDigit": { + "description": "Instructional text listing the requirements for a valid password, part 2." + }, + "includeUppercase": "कम से कम 1 कैपिटल लेटर शामिल करें", + "@includeUppercase": { + "description": "Instructional text listing the requirements for a valid password, part 3." + }, + "includeLowercase": "कम से कम 1 लोअरकेस लेटर शामिल करें", + "@includeLowercase": { + "description": "Instructional text listing the requirements for a valid password, part 4." + }, + "includeSymbol": "कम से कम 1 सिम्बल शामिल करें", + "@includeSymbol": { + "description": "Instructional text listing the requirements for a valid password, part 5." + }, + "signedUpSuccessfully": "सफलतापूर्वक साइन अप हो गया!", + "@signedUpSuccessfully": { + "description": "Title for a success message after account creation." + }, + "newAccountCreated": "आपका नया अकाउंट सफलतापूर्वक बन गया है", + "@newAccountCreated": { + "description": "Success message confirming that a new account has been created." + }, + "signUp": "साइन अप", + "@signUp": { + "description": "Button text to submit the sign-up form." + }, + "login": "लॉगिन", + "@login": { + "description": "Button text to submit the login form." + }, + "settings": "सेटिंग्स", + "@settings": { + "description": "Title for the settings page." + }, + "accountSettings": "अकाउंट सेटिंग्स", + "@accountSettings": { + "description": "Sub-header for account-related settings." + }, + "account": "अकाउंट", + "@account": { + "description": "Label for the account settings section." + }, + "appSettings": "ऐप की सेटिंग्स", + "@appSettings": { + "description": "Sub-header for application-related settings." + }, + "themes": "थीम्स", + "@themes": { + "description": "Label for the theme selection setting." + }, + "about": "रेज़ोनेट के बारे में", + "@about": { + "description": "Label for the 'About' section or page of the app or a story." + }, + "other": "अन्य", + "@other": { + "description": "A generic category label for miscellaneous settings or items." + }, + "contribute": "योगदान करें", + "@contribute": { + "description": "Label for a section encouraging users to contribute to the project." + }, + "appPreferences": "ऐप प्राथमिकताएं", + "@appPreferences": { + "description": "Label for the app preferences settings page." + }, + "transcriptionModel": "ट्रांसक्रिप्शन मॉडल", + "@transcriptionModel": { + "description": "Section title for choosing AI transcription model." + }, + "transcriptionModelDescription": "वॉयस ट्रांसक्रिप्शन के लिए AI मॉडल चुनें। बड़े मॉडल अधिक सटीक हैं लेकिन धीमे हैं और अधिक स्टोरेज की आवश्यकता होती है।", + "@transcriptionModelDescription": { + "description": "Description text explaining transcription model choices." + }, + "whisperModelTiny": "टाइनी", + "@whisperModelTiny": { + "description": "Name of the smallest Whisper AI model." + }, + "whisperModelTinyDescription": "सबसे तेज़, कम सटीक (~39 MB)", + "@whisperModelTinyDescription": { + "description": "Description of the Tiny Whisper model performance and size." + }, + "whisperModelBase": "बेस", + "@whisperModelBase": { + "description": "Name of the base Whisper AI model." + }, + "whisperModelBaseDescription": "संतुलित गति और सटीकता (~74 MB)", + "@whisperModelBaseDescription": { + "description": "Description of the Base Whisper model performance and size." + }, + "whisperModelSmall": "स्मॉल", + "@whisperModelSmall": { + "description": "Name of the small Whisper AI model." + }, + "whisperModelSmallDescription": "अच्छी सटीकता, धीमा (~244 MB)", + "@whisperModelSmallDescription": { + "description": "Description of the Small Whisper model performance and size." + }, + "whisperModelMedium": "मीडियम", + "@whisperModelMedium": { + "description": "Name of the medium Whisper AI model." + }, + "whisperModelMediumDescription": "उच्च सटीकता, धीमा (~769 MB)", + "@whisperModelMediumDescription": { + "description": "Description of the Medium Whisper model performance and size." + }, + "whisperModelLargeV1": "लार्ज V1", + "@whisperModelLargeV1": { + "description": "Name of the large V1 Whisper AI model." + }, + "whisperModelLargeV1Description": "सबसे अधिक सटीक, सबसे धीमा (~1.55 GB)", + "@whisperModelLargeV1Description": { + "description": "Description of the Large V1 Whisper model performance and size." + }, + "whisperModelLargeV2": "लार्ज V2", + "@whisperModelLargeV2": { + "description": "Name of the large V2 Whisper AI model." + }, + "whisperModelLargeV2Description": "उच्च सटीकता के साथ बेहतर बड़ा मॉडल (~1.55 GB)", + "@whisperModelLargeV2Description": { + "description": "Description of the Large V2 Whisper model performance and size." + }, + "modelDownloadInfo": "मॉडल पहली बार उपयोग करने पर डाउनलोड हो जाते हैं। हम बेस, स्मॉल या मीडियम का उपयोग करने की सिफारिश करते हैं। बड़े मॉडल के लिए बहुत उच्च अंत उपकरणों की आवश्यकता होती है।", + "@modelDownloadInfo": { + "description": "Information message about model download." + }, + "logOut": "लॉग आउट", + "@logOut": { + "description": "Button text to log the user out of their account." + }, + "participants": "पार्टिसिपेंट्स", + "@participants": { + "description": "Label or title for the list of participants in a room." + }, + "delete": "डिलीट", + "@delete": { + "description": "Generic button text for a delete action. Parameter for toRoomAction." + }, + "leave": "छोड़ो", + "@leave": { + "description": "Generic button text for a leave action. Parameter for toRoomAction." + }, + "leaveButton": "छोड़ो", + "@leaveButton": { + "description": "Button text to leave a room or conversation." + }, + "findingRandomPartner": "आपके लिए एक रैंडम पार्टनर ढूंढ रहे हैं", + "@findingRandomPartner": { + "description": "Status message shown while the system is searching for a random chat partner." + }, + "quickFact": "त्वरित तथ्य", + "@quickFact": { + "description": "Header for a quick fact or tip, often shown during loading." + }, + "cancel": "कैंसल", + "@cancel": { + "description": "Generic button text for a cancel action." + }, + "completeYourProfile": "अपनी प्रोफ़ाइल पूरी करो", + "@completeYourProfile": { + "description": "Page title or prompt for the user to finish setting up their profile." + }, + "uploadProfilePicture": "प्रोफ़ाइल पिक्चर अपलोड करो", + "@uploadProfilePicture": { + "description": "Instructional text or button to upload a profile picture." + }, + "enterValidName": "मान्य नाम डालो", + "@enterValidName": { + "description": "Error message for an invalid name format." + }, + "name": "नाम", + "@name": { + "description": "Label for the name input field." + }, + "username": "यूज़रनेम", + "@username": { + "description": "Label for the username input field." + }, + "enterValidDOB": "सही तारीख़ डालो", + "@enterValidDOB": { + "description": "Error message for an invalid date of birth." + }, + "dateOfBirth": "जन्म तिथि", + "@dateOfBirth": { + "description": "Label for the date of birth input field." + }, + "forgotPassword": "पासवर्ड भूल गए?", + "@forgotPassword": { + "description": "Link text for users who have forgotten their password." + }, + "next": "आगे", + "@next": { + "description": "Button text to proceed to the next step in a process." + }, + "noStoriesExist": "कोई कहानी अभी मौजूद नहीं है", + "@noStoriesExist": { + "description": "Message displayed when there are no stories available in a list." + }, + "enterVerificationCode": "अपना\nवेरिफिकेशन कोड दर्ज करें", + "@enterVerificationCode": { + "description": "Prompt for the user to enter the verification code sent to their email." + }, + "verificationCodeSent": "हमने 6-अंकों का कोड भेजा है\n", + "@verificationCodeSent": { + "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." + }, + "verificationComplete": "वेरिफिकेशन पूरा हो गया", + "@verificationComplete": { + "description": "Title for a success message after email verification." + }, + "verificationCompleteMessage": "बधाई हो! आपका ईमेल वेरिफाई हो गया है", + "@verificationCompleteMessage": { + "description": "Success message confirming email verification." + }, + "verificationFailed": "वेरिफिकेशन फेल हो गया", + "@verificationFailed": { + "description": "Title for an error message when verification fails." + }, + "otpMismatch": "OTP मेल नहीं खा रहा, कृपया फिर से प्रयास करें", + "@otpMismatch": { + "description": "Error message when the entered OTP is incorrect." + }, + "otpResent": "OTP दोबारा भेजा गया", + "@otpResent": { + "description": "Confirmation message that the OTP has been resent." + }, + "requestNewCode": "नया कोड माँगें", + "@requestNewCode": { + "description": "Button text to request a new verification code." + }, + "requestNewCodeIn": "नया कोड माँगने के लिए प्रतीक्षा करें", + "@requestNewCodeIn": { + "description": "Text displayed before a countdown timer for requesting a new code." + }, + "clickPictureCamera": "कैमरा से फोटो लें", + "@clickPictureCamera": { + "description": "Option to take a new photo using the device camera." + }, + "pickImageGallery": "गैलरी से इमेज चुनें", + "@pickImageGallery": { + "description": "Option to choose an existing image from the device gallery." + }, + "deleteMyAccount": "मेरा अकाउंट डिलीट करें", + "@deleteMyAccount": { + "description": "Button text for the account deletion feature." + }, + "createNewRoom": "नया रूम बनाएं", + "@createNewRoom": { + "description": "Button text or page title for creating a new audio room." + }, + "pleaseEnterScheduledDateTime": "कृपया निर्धारित तारीख और समय डालें", + "@pleaseEnterScheduledDateTime": { + "description": "Error message when the scheduled date and time for a room is not provided." + }, + "scheduleDateTimeLabel": "शेड्यूल तारीख व समय", + "@scheduleDateTimeLabel": { + "description": "Label for the input field to schedule a room's date and time." + }, + "enterTags": "टैग डालें", + "@enterTags": { + "description": "Placeholder or label for the input field for adding tags to a room or story." + }, + "joinCommunity": "कम्युनिटी से जुड़ें", + "@joinCommunity": { + "description": "Button text or header for the community section." + }, + "followUsOnX": "X पर हमें फॉलो करें", + "@followUsOnX": { + "description": "Link text to the company's profile on X (formerly Twitter)." + }, + "joinDiscordServer": "Discord सर्वर से जुड़ें", + "@joinDiscordServer": { + "description": "Link text to join the project's Discord server." + }, + "noLyrics": "कोई लिरिक्स उपलब्ध नहीं", + "@noLyrics": { + "description": "Message displayed when lyrics for an audio file are not available." + }, + "noStoriesInCategory": "{categoryName} श्रेणी में कोई कहानी नहीं है", + "@noStoriesInCategory": { + "description": "Message when no stories exist in a specific category.", + "placeholders": { + "categoryName": { + "type": "String", + "example": "drama" + } + } + }, + "newChapters": "नए चैप्टर्स जोड़ें", + "@newChapters": { + "description": "Header or label for the section to add or view new chapters of a story." + }, + "helpToGrow": "साथ मिलकर बढ़ाएं", + "@helpToGrow": { + "description": "A call to action asking users to help the platform grow, often a header for sharing/rating." + }, + "share": "शेयर करें", + "@share": { + "description": "Button text for sharing content." + }, + "rate": "रेटिंग दें", + "@rate": { + "description": "Button text for rating the app or content." + }, + "aboutResonate": "रेज़ोनेट के बारे में", + "@aboutResonate": { + "description": "Title for the 'About' page of the Resonate application." + }, + "description": "विवरण", + "@description": { + "description": "Label for a description field." + }, + "confirm": "पुष्टि करें", + "@confirm": { + "description": "Generic button text for a confirm action." + }, + "classic": "क्लासिक", + "@classic": { + "description": "Name of a theme option." + }, + "time": "टाइम", + "@time": { + "description": "Name of a theme option." + }, + "vintage": "विंटेज", + "@vintage": { + "description": "Name of a theme option." + }, + "amber": "अंबर", + "@amber": { + "description": "Name of a theme option." + }, + "forest": "फॉरेस्ट", + "@forest": { + "description": "Name of a theme option." + }, + "cream": "क्रीम", + "@cream": { + "description": "Name of a theme option." + }, + "none": "कोई नहीं", + "@none": { + "description": "Text representing no selection or absence of a value." + }, + "checkOutGitHub": "हमारा GitHub रेपो देखें: {url}", + "@checkOutGitHub": { + "description": "Share message for the GitHub repository, including a placeholder for the URL.", + "placeholders": { + "url": { + "type": "String", + "example": "https://github.com/AOSSIE-Org/Resonate" + } + } + }, + "aossie": "AOSSIE", + "@aossie": { + "description": "The name of the organization." + }, + "aossieLogo": "AOSSIE लोगो", + "@aossieLogo": { + "description": "Accessibility text for the AOSSIE organization logo." + }, + "errorLoadPackageInfo": "पैकेज जानकारी लोड नहीं हो सकी", + "@errorLoadPackageInfo": { + "description": "Error message when the application fails to load its package information (e.g., version)." + }, + "searchFailed": "रूम खोजने में विफल। कृपया पुनः प्रयास करें।", + "@searchFailed": { + "description": "Error message when searching for rooms fails." + }, + "updateAvailable": "अपडेट उपलब्ध है", + "@updateAvailable": { + "description": "Title indicating that a new version of the app is available." + }, + "newVersionAvailable": "एक नया वर्शन उपलब्ध है!", + "@newVersionAvailable": { + "description": "Message informing the user about an available update." + }, + "upToDate": "आपका ऐप लेटेस्ट है", + "@upToDate": { + "description": "Title indicating the application is on the latest version." + }, + "latestVersion": "आप रेज़ोनेट का लेटेस्ट वर्शन इस्तेमाल कर रहे हैं", + "@latestVersion": { + "description": "Message confirming the user has the latest version of the app." + }, + "profileCreatedSuccessfully": "प्रोफ़ाइल सफलतापूर्वक बनाई गई", + "@profileCreatedSuccessfully": { + "description": "Success message after a user's profile is created." + }, + "invalidScheduledDateTime": "अमान्य तारीख और समय", + "@invalidScheduledDateTime": { + "description": "Error message for an invalid date-time format." + }, + "scheduledDateTimePast": "शेड्यूल तारीख व समय पहले का नहीं हो सकता", + "@scheduledDateTimePast": { + "description": "Error message when the user selects a past date-time for a scheduled event." + }, + "joinRoom": "रूम में शामिल हों", + "@joinRoom": { + "description": "Button text to join an audio room." + }, + "unknownUser": "अज्ञात यूज़र", + "@unknownUser": { + "description": "Placeholder text for a user whose name is not available." + }, + "canceled": "रद्द किया गया", + "@canceled": { + "description": "A status label indicating that an action was canceled." + }, + "english": "en", + "@english": { + "description": "The language code for English." + }, + "emailVerificationRequired": "ईमेल वेरिफिकेशन ज़रूरी है", + "@emailVerificationRequired": { + "description": "A title or message indicating that email verification is needed to proceed." + }, + "verify": "वेरिफ़ाई करें", + "@verify": { + "description": "Button text to initiate a verification process." + }, + "audioRoom": "ऑडियो रूम", + "@audioRoom": { + "description": "Generic title for an audio room." + }, + "toRoomAction": "रूम को {action} करने के लिए", + "@toRoomAction": { + "description": "A dynamic message for confirming an action on a room, like deleting or leaving.", + "placeholders": { + "action": { + "type": "String", + "example": "delete" + } + } + }, + "mailSentMessage": "मेल भेज दी गई है", + "@mailSentMessage": { + "description": "A confirmation message indicating an email has been sent." + }, + "disconnected": "कनेक्शन टूट गया", + "@disconnected": { + "description": "A status label indicating a loss of connection." + }, + "micOn": "माइक", + "@micOn": { + "description": "Accessibility label for the microphone button when it is on." + }, + "speakerOn": "स्पीकर", + "@speakerOn": { + "description": "Accessibility label for the speakerphone button when it is on." + }, + "endChat": "चैट समाप्त करें", + "@endChat": { + "description": "Accessibility label for the end chat/call button." + }, + "monthJan": "जनवरी", + "@monthJan": { + "description": "Abbreviation for January." + }, + "monthFeb": "फरवरी", + "@monthFeb": { + "description": "Abbreviation for February." + }, + "monthMar": "मार्च", + "@monthMar": { + "description": "Full name for March." + }, + "monthApr": "अप्रैल", + "@monthApr": { + "description": "Full name for April." + }, + "monthMay": "मई", + "@monthMay": { + "description": "Full name for May." + }, + "monthJun": "जून", + "@monthJun": { + "description": "Full name for June." + }, + "monthJul": "जुलाई", + "@monthJul": { + "description": "Full name for July." + }, + "monthAug": "अगस्त", + "@monthAug": { + "description": "Abbreviation for August." + }, + "monthSep": "सितंबर", + "@monthSep": { + "description": "Abbreviation for September." + }, + "monthOct": "अक्टूबर", + "@monthOct": { + "description": "Abbreviation for October." + }, + "monthNov": "नवंबर", + "@monthNov": { + "description": "Abbreviation for November." + }, + "monthDec": "दिसंबर", + "@monthDec": { + "description": "Abbreviation for December." + }, + "register": "रजिस्टर करें", + "@register": { + "description": "Button text to register a new account." + }, + "newToResonate": "रेज़ोनेट पर नए हैं? ", + "@newToResonate": { + "description": "Text preceding the 'Create new account' link on the login screen." + }, + "alreadyHaveAccount": "पहले से अकाउंट है? ", + "@alreadyHaveAccount": { + "description": "Text preceding the 'Login' link on the sign-up screen." + }, + "checking": "जाँच हो रही है...", + "@checking": { + "description": "A temporary status message indicating a check is in progress." + }, + "forgotPasswordMessage": "पासवर्ड रीसेट करने के लिए अपना रजिस्टर्ड ईमेल दर्ज करें।", + "@forgotPasswordMessage": { + "description": "Instructional text on the forgot password screen." + }, + "usernameUnavailable": "यूज़रनेम उपलब्ध नहीं है!", + "@usernameUnavailable": { + "description": "Title for the error when a chosen username is taken." + }, + "usernameInvalidOrTaken": "यह यूज़रनेम अमान्य है या पहले से लिया जा चुका है।", + "@usernameInvalidOrTaken": { + "description": "Error message explaining why a username cannot be used." + }, + "otpResentMessage": "कृपया अपनी ईमेल जांचें, नया OTP भेजा गया है।", + "@otpResentMessage": { + "description": "Message informing the user to check their email for a new OTP." + }, + "connectionError": "कनेक्शन में समस्या है। कृपया इंटरनेट चेक करें और दोबारा प्रयास करें।", + "@connectionError": { + "description": "Generic error message for network connection issues." + }, + "seconds": "सेकंड", + "@seconds": { + "description": "The word 'seconds', often used after a countdown number." + }, + "unsavedChangesWarning": "यदि आप बिना सेव किए आगे बढ़ते हैं, तो आपके सभी बदलाव खो जाएंगे।", + "@unsavedChangesWarning": { + "description": "A warning message shown when a user tries to leave a screen with unsaved changes." + }, + "deleteAccountPermanent": "यह प्रक्रिया आपके अकाउंट को हमेशा के लिए डिलीट कर देगी। आपका यूज़रनेम, ईमेल और सारा डेटा हटा दिया जाएगा। यह वापस नहीं लिया जा सकता।", + "@deleteAccountPermanent": { + "description": "A detailed warning message explaining the consequences of deleting an account." + }, + "giveGreatName": "एक अच्छा नाम सोचें...", + "@giveGreatName": { + "description": "Placeholder text for a name input field, encouraging a creative name." + }, + "joinCommunityDescription": "कम्युनिटी से जुड़कर आप सवाल पूछ सकते हैं, नए फीचर्स का सुझाव दे सकते हैं, और अपने अनुभव साझा कर सकते हैं।", + "@joinCommunityDescription": { + "description": "A description of the benefits of joining the community." + }, + "resonateDescription": "रेज़ोनेट एक सोशल मीडिया प्लेटफॉर्म है जहाँ हर आवाज़ की कद्र की जाती है। अपने विचार, कहानियाँ और अनुभव साझा करें। ऑडियो जर्नी अभी शुरू करें और उन टॉपिक्स में हिस्सा लें जो आपसे जुड़ते हैं।", + "@resonateDescription": { + "description": "A short, user-facing description of the Resonate application." + }, + "resonateFullDescription": "रेज़ोनेट एक क्रांतिकारी वॉयस-बेस्ड सोशल मीडिया प्लेटफॉर्म है जहाँ हर आवाज़ मायने रखती है।\nरियल-टाइम ऑडियो बातचीत में भाग लें, और अपने जैसे विचारों वाले लोगों से जुड़ें। हमारा प्लेटफॉर्म आपको देता है:\n- लाइव ऑडियो रूम्स\n- वॉयस के ज़रिए सोशल नेटवर्किंग\n- कम्युनिटी-ड्रिवन कंटेंट मॉडरेशन\n- क्रॉस-प्लेटफॉर्म सपोर्ट\n- एन्ड-टू-एन्ड एन्क्रिप्टेड प्राइवेट चैट्स\n\nयह ऐप AOSSIE ओपन-सोर्स कम्युनिटी द्वारा बनाया गया है, जो आपकी प्राइवेसी और पारदर्शी विकास को प्राथमिकता देती है।", + "@resonateFullDescription": { + "description": "A comprehensive, detailed description of the Resonate application, its features, and its mission." + }, + "stable": "स्थिर", + "@stable": { + "description": "A label indicating a stable, non-beta version of the app." + }, + "usernameCharacterLimit": "यूज़रनेम में कम से कम 6 अक्षर होने चाहिए।", + "@usernameCharacterLimit": { + "description": "Error message when a chosen username is too short." + }, + "submit": "सबमिट करें", + "@submit": { + "description": "Generic button text for submitting a form." + }, + "anonymous": "गुमनाम", + "@anonymous": { + "description": "Label for an anonymous user or identity." + }, + "noSearchResults": "कोई रिज़ल्ट नहीं मिला", + "@noSearchResults": { + "description": "Message displayed when a search returns no results." + }, + "searchRooms": "रूम खोजें...", + "@searchRooms": { + "description": "Placeholder text for room search input field." + }, + "searchingRooms": "रूम खोजे जा रहे हैं...", + "@searchingRooms": { + "description": "Loading message shown while searching for rooms." + }, + "clearSearch": "खोज साफ़ करें", + "@clearSearch": { + "description": "Text for button to clear search results." + }, + "searchError": "खोज त्रुटि", + "@searchError": { + "description": "Title for search error messages." + }, + "searchRoomsError": "कमरों की खोज असफल हुई। कृपया दोबारा कोशिश करें।", + "@searchRoomsError": { + "description": "Error message when room search fails." + }, + "searchUpcomingRoomsError": "आगामी कमरों की खोज असफल हुई। कृपया दोबारा कोशिश करें।", + "@searchUpcomingRoomsError": { + "description": "Error message when upcoming room search fails." + }, + "search": "खोजें", + "@search": { + "description": "Tooltip text for search button." + }, + "clear": "साफ़ करें", + "@clear": { + "description": "Tooltip text for clear button." + }, + "shareRoomMessage": "🚀 इस शानदार रूम को देखें: {roomName}!\n\n📖 विवरण: {description}\n👥 अभी {participants} लोग जुड़ चुके हैं!", + "@shareRoomMessage": { + "description": "The default message template used when sharing a room.", + "placeholders": { + "roomName": { + "type": "String", + "example": "Discussion Room" + }, + "description": { + "type": "String", + "example": "A great place to discuss" + }, + "participants": { + "type": "int", + "example": "5" + } + } + }, + "participantsCount": "{count} प्रतिभागी", + "@participantsCount": { + "description": "Displays the number of participants in a room.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "join": "शामिल हों", + "@join": { + "description": "Generic button text to join an activity or group." + }, + "invalidTags": "अमान्य टैग:", + "@invalidTags": { + "description": "Error message prefix for an invalid tag." + }, + "cropImage": "इमेज क्रॉप करें", + "@cropImage": { + "description": "Title or button text for the image cropping tool." + }, + "profileSavedSuccessfully": "प्रोफ़ाइल सेव हो गई", + "@profileSavedSuccessfully": { + "description": "Short success message indicating profile changes have been saved." + }, + "profileUpdatedSuccessfully": "आपके सभी बदलाव सफलतापूर्वक सेव हो गए हैं।", + "@profileUpdatedSuccessfully": { + "description": "Detailed success message confirming profile update." + }, + "profileUpToDate": "आपकी प्रोफ़ाइल पूरी तरह अपडेट है", + "@profileUpToDate": { + "description": "Title for the message when there are no changes to save on the profile." + }, + "noChangesToSave": "कोई नया बदलाव नहीं है, सेव करने की ज़रूरत नहीं।", + "@noChangesToSave": { + "description": "Message displayed when the user tries to save their profile without making any changes." + }, + "connectionFailed": "कनेक्शन फेल हो गया", + "@connectionFailed": { + "description": "Generic title for connection-related errors." + }, + "unableToJoinRoom": "रूम से कनेक्ट नहीं हो सका। कृपया नेटवर्क चेक करें और फिर से कोशिश करें।", + "@unableToJoinRoom": { + "description": "Error message when a user fails to join a room due to connection issues." + }, + "connectionLost": "कनेक्शन टूट गया", + "@connectionLost": { + "description": "Title indicating that the connection to a service was lost." + }, + "unableToReconnect": "रूम से दोबारा कनेक्ट नहीं हो सका। कृपया फिर से जुड़ने का प्रयास करें।", + "@unableToReconnect": { + "description": "Error message when the app fails to reconnect the user to a room." + }, + "invalidFormat": "अमान्य फ़ॉर्मेट!", + "@invalidFormat": { + "description": "Generic error message for data with an incorrect format." + }, + "usernameAlphanumeric": "यूज़रनेम केवल अक्षर और अंक होने चाहिए, कोई स्पेशल कैरेक्टर नहीं।", + "@usernameAlphanumeric": { + "description": "Error message detailing the character requirements for a valid username." + }, + "userProfileCreatedSuccessfully": "आपकी यूज़र प्रोफ़ाइल सफलतापूर्वक बनाई गई है।", + "@userProfileCreatedSuccessfully": { + "description": "Success message after a user completes their profile setup." + }, + "emailVerificationMessage": "आगे बढ़ने के लिए कृपया अपना ईमेल वेरिफाई करें।", + "@emailVerificationMessage": { + "description": "An instructional message prompting the user to verify their email." + }, + "addNewChaptersToStory": "{storyName} में नए चैप्टर्स जोड़ें", + "@addNewChaptersToStory": { + "description": "Title for the screen where new chapters are added to an existing story.", + "placeholders": { + "storyName": { + "type": "String", + "example": "My Story" + } + } + }, + "currentChapters": "मौजूदा चैप्टर्स", + "@currentChapters": { + "description": "Header for the list of existing chapters in a story." + }, + "sourceCodeOnGitHub": "GitHub पर सोर्स कोड", + "@sourceCodeOnGitHub": { + "description": "Link text to the project's source code on GitHub." + }, + "createAChapter": "नया चैप्टर बनाएं", + "@createAChapter": { + "description": "Title for the screen where a new chapter is created." + }, + "chapterTitle": "चैप्टर का टाइटल *", + "@chapterTitle": { + "description": "Label for the chapter title input field, indicating it is required." + }, + "aboutRequired": "कहानी का परिचय *", + "@aboutRequired": { + "description": "Label for the 'About' or description input field, indicating it is required." + }, + "changeCoverImage": "कवर इमेज बदलें", + "@changeCoverImage": { + "description": "Button text to change the cover image of a story or chapter." + }, + "uploadAudioFile": "ऑडियो फाइल अपलोड करें", + "@uploadAudioFile": { + "description": "Button text to upload an audio file." + }, + "uploadLyricsFile": "लिरिक्स फाइल अपलोड करें", + "@uploadLyricsFile": { + "description": "Button text to upload a lyrics file." + }, + "createChapter": "चैप्टर बनाएं", + "@createChapter": { + "description": "Button text to finalize and create a new chapter." + }, + "audioFileSelected": "ऑडियो फाइल चुनी गई: {fileName}", + "@audioFileSelected": { + "description": "Message shown after an audio file has been selected for upload.", + "placeholders": { + "fileName": { + "type": "String", + "example": "audio.mp3" + } + } + }, + "lyricsFileSelected": "लिरिक्स फाइल चुनी गई: {fileName}", + "@lyricsFileSelected": { + "description": "Message shown after a lyrics file has been selected for upload.", + "placeholders": { + "fileName": { + "type": "String", + "example": "lyrics.txt" + } + } + }, + "fillAllRequiredFields": "कृपया सभी जरूरी फ़ील्ड भरें और अपनी ऑडियो व लिरिक्स फाइल अपलोड करें", + "@fillAllRequiredFields": { + "description": "Error message when required fields or files are missing from a form." + }, + "scheduled": "शेड्यूल्ड", + "@scheduled": { + "description": "A status label indicating an event is scheduled for the future." + }, + "ok": "ठीक है", + "@ok": { + "description": "Generic confirmation button text, often for closing a dialog." + }, + "roomDescriptionOptional": "रूम का विवरण (वैकल्पिक)", + "@roomDescriptionOptional": { + "description": "Label for the room description input field, indicating it is not required." + }, + "deleteAccount": "अकाउंट हटाएं", + "@deleteAccount": { + "description": "Button text to initiate the account deletion process." + }, + "createYourStory": "अपनी कहानी बनाएं", + "@createYourStory": { + "description": "Title for the screen where a new story is created." + }, + "titleRequired": "टाइटल *", + "@titleRequired": { + "description": "Label for the title input field, indicating it is required." + }, + "category": "कैटेगरी *", + "@category": { + "description": "Label for the category selection, indicating it is required." + }, + "addChapter": "चैप्टर जोड़ें", + "@addChapter": { + "description": "Button text to add a chapter to a story." + }, + "createStory": "कहानी बनाएं", + "@createStory": { + "description": "Button text to finalize and create a new story." + }, + "fillAllRequiredFieldsAndChapter": "कृपया सभी ज़रूरी फ़ील्ड भरें, कम से कम एक चैप्टर जोड़ें और कवर इमेज चुनें।", + "@fillAllRequiredFieldsAndChapter": { + "description": "Error message for the story creation form when requirements are not met." + }, + "toConfirmType": "पुष्टि करने के लिए टाइप करें", + "@toConfirmType": { + "description": "Instructional text in a confirmation dialog, preceding the text to be typed." + }, + "inTheBoxBelow": "नीचे दिए गए बॉक्स में", + "@inTheBoxBelow": { + "description": "Instructional text in a confirmation dialog, following the text to be typed." + }, + "iUnderstandDeleteMyAccount": "मैं समझता/समझती हूँ, मेरा अकाउंट डिलीट करें", + "@iUnderstandDeleteMyAccount": { + "description": "The specific phrase a user must type to confirm account deletion." + }, + "whatDoYouWantToListenTo": "आप क्या सुनना चाहते हैं?", + "@whatDoYouWantToListenTo": { + "description": "A prompt on the main screen, encouraging user engagement." + }, + "categories": "श्रेणियाँ", + "@categories": { + "description": "Header for the list of categories." + }, + "stories": "कहानियाँ", + "@stories": { + "description": "Header for the list of stories." + }, + "someSuggestions": "कुछ सुझाव", + "@someSuggestions": { + "description": "Header for a list of suggested content." + }, + "getStarted": "शुरू करें", + "@getStarted": { + "description": "Button text on an onboarding screen to begin using the app." + }, + "skip": "स्किप करें", + "@skip": { + "description": "Button text to skip an optional step, like onboarding." + }, + "welcomeToResonate": "रेज़ोनेट में आपका स्वागत है", + "@welcomeToResonate": { + "description": "Onboarding screen title." + }, + "exploreDiverseConversations": "विविध बातचीत का अनुभव लें", + "@exploreDiverseConversations": { + "description": "Feature highlight on an onboarding screen." + }, + "yourVoiceMatters": "आपकी आवाज़ मायने रखती है", + "@yourVoiceMatters": { + "description": "Feature highlight on an onboarding screen." + }, + "joinConversationExploreRooms": "बातचीत में शामिल हों! रूम्स एक्सप्लोर करें, दोस्तों से जुड़ें और अपनी आवाज़ दुनिया तक पहुँचाएं।", + "@joinConversationExploreRooms": { + "description": "Descriptive text on an onboarding screen." + }, + "diveIntoDiverseDiscussions": "विभिन्न चर्चाओं में शामिल हों।\nऐसे रूम्स खोजें जो आपसे जुड़ते हों और कम्युनिटी का हिस्सा बनें।", + "@diveIntoDiverseDiscussions": { + "description": "Descriptive text on an onboarding screen." + }, + "atResonateEveryVoiceValued": "रेज़ोनेट में हर आवाज़ की अहमियत है। अपने विचार, कहानियाँ और अनुभव साझा करें। अपनी ऑडियो जर्नी अभी शुरू करें।", + "@atResonateEveryVoiceValued": { + "description": "Descriptive text on an onboarding screen." + }, + "notifications": "नोटिफिकेशन", + "@notifications": { + "description": "Title for the notifications screen." + }, + "taggedYouInUpcomingRoom": "{username} ने आपको एक अपकमिंग रूम में टैग किया: {subject}", + "@taggedYouInUpcomingRoom": { + "description": "Notification message when tagged in an upcoming room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "taggedYouInRoom": "{username} ने आपको एक रूम में टैग किया: {subject}", + "@taggedYouInRoom": { + "description": "Notification message when tagged in a live room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "likedYourStory": "{username} ने आपकी कहानी को पसंद किया: {subject}", + "@likedYourStory": { + "description": "Notification message when someone likes your story.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Story" + } + } + }, + "subscribedToYourRoom": "{username} ने आपके रूम को सब्सक्राइब किया: {subject}", + "@subscribedToYourRoom": { + "description": "Notification message when someone subscribes to your room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Room" + } + } + }, + "startedFollowingYou": "{username} ने आपको फॉलो करना शुरू किया", + "@startedFollowingYou": { + "description": "Notification message when someone starts following you.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "youHaveNewNotification": "आपके लिए एक नया नोटिफिकेशन है", + "@youHaveNewNotification": { + "description": "Generic title for a new notification." + }, + "hangOnGoodThingsTakeTime": "रुकिए — अच्छी चीज़ों में समय लगता है 🔍", + "@hangOnGoodThingsTakeTime": { + "description": "A friendly message displayed during a long loading process." + }, + "resonateOpenSourceProject": "रेज़ोनेट एक ओपन-सोर्स प्रोजेक्ट है जिसे AOSSIE बनाए रखता है। योगदान देने के लिए हमारा GitHub देखें।", + "@resonateOpenSourceProject": { + "description": "Informational text about the open-source nature of the project." + }, + "mute": "म्यूट करें", + "@mute": { + "description": "Button text to mute the microphone." + }, + "speakerLabel": "स्पीकर", + "@speakerLabel": { + "description": "Label for the speakerphone toggle." + }, + "audioOptions": "ऑडियो विकल्प", + "@audioOptions": { + "description": "Label for the audio options/settings button." + }, + "end": "समाप्त करें", + "@end": { + "description": "Button text to end a call or session." + }, + "saveChanges": "बदलाव सेव करें", + "@saveChanges": { + "description": "Button text to save modified settings or profile information." + }, + "discard": "रद्द करें", + "@discard": { + "description": "Button text to discard changes." + }, + "save": "सेव करें", + "@save": { + "description": "Button text to save changes." + }, + "changeProfilePicture": "प्रोफ़ाइल पिक्चर बदलें", + "@changeProfilePicture": { + "description": "Button text to open the profile picture selection options." + }, + "camera": "कैमरा", + "@camera": { + "description": "Option to use the camera to take a picture." + }, + "gallery": "गैलरी", + "@gallery": { + "description": "Option to choose a picture from the gallery." + }, + "remove": "हटाएं", + "@remove": { + "description": "Button text to remove an item, like a profile picture." + }, + "created": "बनाई गई: {date}", + "@created": { + "description": "Displays the creation date of a story or content.", + "placeholders": { + "date": { + "type": "String", + "example": "Jan 15, 2023" + } + } + }, + "chapters": "चैप्टर", + "@chapters": { + "description": "Header for the list of chapters in a story." + }, + "deleteStory": "कहानी हटाएं", + "@deleteStory": { + "description": "Button text to delete a story." + }, + "createdBy": "{creatorName} द्वारा बनाई गई", + "@createdBy": { + "description": "Displays the name of the content creator.", + "placeholders": { + "creatorName": { + "type": "String", + "example": "John Doe" + } + } + }, + "start": "शुरू करें", + "@start": { + "description": "Button text to start an activity or session." + }, + "unsubscribe": "सब्सक्रिप्शन हटाएं", + "@unsubscribe": { + "description": "Button text to unsubscribe from a room or notification." + }, + "subscribe": "सब्सक्राइब करें", + "@subscribe": { + "description": "Button text to subscribe to a room or notification." + }, + "storyCategory": "{category, select, drama{नाटक} comedy{हास्य} horror{डरावनी} romance{प्रेम कथा} thriller{रोमांच} spiritual{आध्यात्मिक} other{अन्य}}", + "@storyCategory": { + "description": "Selects the appropriate category name for a story based on a key.", + "placeholders": { + "category": { + "type": "String", + "example": "drama" + } + } + }, + "chooseTheme": "{category, select, classicTheme{क्लासिक} timeTheme{टाइम} vintageTheme{विंटेज} amberTheme{अंबर} forestTheme{फॉरेस्ट} creamTheme{क्रीम} other{अन्य}}", + "@chooseTheme": { + "description": "Selects the appropriate theme name based on a key.", + "placeholders": { + "category": { + "type": "String", + "example": "classic" + } + } + }, + "minutesAgo": "{count, plural, =1{1 मिनट पहले} other{{count} मिनट पहले}}", + "@minutesAgo": { + "description": "Displays time in a relative format for minutes.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "hoursAgo": "{count, plural, =1{1 घंटा पहले} other{{count} घंटे पहले}}", + "@hoursAgo": { + "description": "Displays time in a relative format for hours.", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "daysAgo": "{count, plural, =1{1 दिन पहले} other{{count} दिन पहले}}", + "@daysAgo": { + "description": "Displays time in a relative format for days.", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "by": "प्रस्तुतकर्ता:", + "@by": { + "description": "A preposition, typically used between a content title and the author's name." + }, + "likes": "लाइक्स", + "@likes": { + "description": "Label for the number of likes." + }, + "lengthMinutes": "मिनट", + "@lengthMinutes": { + "description": "Abbreviation for 'minutes', used to display audio length." + }, + "requiredField": "आवश्यक फील्ड", + "@requiredField": { + "description": "Error message for a mandatory field that was left empty." + }, + "onlineUsers": "ऑनलाइन यूज़र्स", + "@onlineUsers": { + "description": "Header for the list of users who are currently online." + }, + "noOnlineUsers": "कोई यूज़र अभी ऑनलाइन नहीं है", + "@noOnlineUsers": { + "description": "Message displayed when no users are online." + }, + "chooseUser": "चैट करने के लिए यूज़र चुनें", + "@chooseUser": { + "description": "Instructional text to select a user to start a chat." + }, + "quickMatch": "झटपट मैच", + "@quickMatch": { + "description": "Button text for a feature that quickly matches the user with a random chat partner." + }, + "story": "कहानी", + "@story": { + "description": "Label for a story." + }, + "user": "यूज़र", + "@user": { + "description": "Label for a user." + }, + "following": "फॉलो कर रहे हैं", + "@following": { + "description": "Label for the list of users that the current user is following." + }, + "followers": "फॉलोअर्स", + "@followers": { + "description": "Label for the list of users who are following the current user." + }, + "friendRequests": "फ्रेंड रिक्वेस्ट", + "@friendRequests": { + "description": "Header for the list of incoming friend requests." + }, + "friendRequestSent": "मित्रता अनुरोध भेजा गया", + "@friendRequestSent": { + "description": "Title for the confirmation message after sending a friend request." + }, + "friendRequestSentTo": "आपका मित्रता अनुरोध {username} को भेजा गया है।", + "@friendRequestSentTo": { + "description": "Confirmation message after sending a friend request to a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestCancelled": "मित्रता अनुरोध रद्द कर दिया गया", + "@friendRequestCancelled": { + "description": "Title for the confirmation message after cancelling a friend request." + }, + "friendRequestCancelledTo": "{username} को आपका मित्रता अनुरोध रद्द कर दिया गया है।", + "@friendRequestCancelledTo": { + "description": "Confirmation message after cancelling a friend request to a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "requested": "अनुरोध किया है", + "@requested": { + "description": "A status label on a button indicating a friend request has been sent." + }, + "friends": "मित्र", + "@friends": { + "description": "A status label or header for the list of friends." + }, + "addFriend": "मित्र जोड़ें", + "@addFriend": { + "description": "Button text to send a friend request." + }, + "friendRequestAccepted": "मित्रता अनुरोध स्वीकार कर लिया गया", + "@friendRequestAccepted": { + "description": "Title for the confirmation message after accepting a friend request." + }, + "friendRequestAcceptedTo": "आपने {username} के मित्रता अनुरोध को स्वीकार कर लिया है।", + "@friendRequestAcceptedTo": { + "description": "Confirmation message after accepting a friend request from a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestDeclined": "मित्रता अनुरोध अस्वीकार कर दिया गया", + "@friendRequestDeclined": { + "description": "Title for the confirmation message after declining a friend request." + }, + "friendRequestDeclinedTo": "आपने {username} के मित्रता अनुरोध को अस्वीकार कर दिया है।", + "@friendRequestDeclinedTo": { + "description": "Confirmation message after declining a friend request from a specific user.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "accept": "स्वीकार करें", + "@accept": { + "description": "Button text to accept a request or invitation." + }, + "callDeclined": "कॉल अस्वीकार कर दिया गया", + "@callDeclined": { + "description": "Title for the message when a call is declined." + }, + "callDeclinedTo": "{username} ने आपकी कॉल को अस्वीकार कर दिया है।", + "@callDeclinedTo": { + "description": "Message indicating that a specific user declined a call.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "checkForUpdates": "अपडेट चेक करें", + "@checkForUpdates": { + "description": "Button text to manually check for application updates." + }, + "updateNow": "अभी अपडेट करें", + "@updateNow": { + "description": "Button text to start the update process." + }, + "updateLater": "बाद में", + "@updateLater": { + "description": "Button text to postpone an update." + }, + "updateSuccessful": "अपडेट सफल", + "@updateSuccessful": { + "description": "Title for the success message after an update." + }, + "updateSuccessfulMessage": "रेज़ोनेट सफलतापूर्वक अपडेट हो गया है!", + "@updateSuccessfulMessage": { + "description": "Success message confirming the application has been updated." + }, + "updateCancelled": "अपडेट रद्द किया गया", + "@updateCancelled": { + "description": "Title for the message when an update is cancelled." + }, + "updateCancelledMessage": "अपडेट यूज़र द्वारा रद्द किया गया", + "@updateCancelledMessage": { + "description": "Message indicating the user cancelled the update process." + }, + "updateFailed": "अपडेट फेल", + "@updateFailed": { + "description": "Title for the message when an update fails." + }, + "updateFailedMessage": "अपडेट फेल हो गया। कृपया Play Store से मैन्युअली अपडेट करने का प्रयास करें।", + "@updateFailedMessage": { + "description": "Error message when an in-app update fails, suggesting a manual update." + }, + "updateError": "अपडेट एरर", + "@updateError": { + "description": "Generic title for an update-related error." + }, + "updateErrorMessage": "अपडेट करने में कोई समस्या आई। कृपया फिर से प्रयास करें।", + "@updateErrorMessage": { + "description": "Generic error message for an update that failed due to an unknown error." + }, + "platformNotSupported": "प्लेटफॉर्म सपोर्टेड नहीं", + "@platformNotSupported": { + "description": "Title for an error when a feature is not supported on the current platform." + }, + "platformNotSupportedMessage": "अपडेट चेक करना केवल Android डिवाइस पर उपलब्ध है", + "@platformNotSupportedMessage": { + "description": "Message explaining that the in-app update feature is platform-specific." + }, + "updateCheckFailed": "अपडेट चेक फेल", + "@updateCheckFailed": { + "description": "Title for an error when the app fails to check for updates." + }, + "updateCheckFailedMessage": "अपडेट चेक नहीं हो सका। कृपया बाद में प्रयास करें।", + "@updateCheckFailedMessage": { + "description": "Error message when the check for updates process fails." + }, + "upToDateTitle": "आप अप टू डेट हैं!", + "@upToDateTitle": { + "description": "Title for the message when the app is already up to date." + }, + "upToDateMessage": "आप रेज़ोनेट का लेटेस्ट वर्शन इस्तेमाल कर रहे हैं", + "@upToDateMessage": { + "description": "Message confirming that no update is needed." + }, + "updateAvailableTitle": "अपडेट उपलब्ध है!", + "@updateAvailableTitle": { + "description": "Title for the dialog informing the user about a new update." + }, + "updateAvailableMessage": "रेज़ोनेट का नया वर्शन Play Store पर उपलब्ध है", + "@updateAvailableMessage": { + "description": "Message prompting the user to update the app from the Play Store." + }, + "updateFeaturesImprovement": "नवीनतम सुविधाएं और सुधार प्राप्त करें!", + "@updateFeaturesImprovement": { + "description": "A message highlighting the benefit of updating the application." + }, + "hide": "छुपाएं", + "@hide": { + "description": "Button text to hide an item from view." + }, + "removeRoom": "रूम छुपाएं", + "@removeRoom": { + "description": "Dialog title for hiding an upcoming room." + }, + "removeRoomFromList": "सूची से छुपाएं", + "@removeRoomFromList": { + "description": "Tooltip text for the hide room button." + }, + "removeRoomConfirmation": "क्या आप वाकई इस आने वाले रूम को अपनी सूची से छुपाना चाहते हैं?", + "@removeRoomConfirmation": { + "description": "Confirmation message asking if the user wants to hide an upcoming room from their list." + }, + "failedToRemoveRoom": "रूम हटाने में विफल", + "@failedToRemoveRoom": { + "description": "Error message when unable to remove a room from the list" + }, + "failedToCreateRoom": "रूम बनाने में असफल", + "@failedToCreateRoom": { + "description": "Error message when unable to create a room" + }, + "roomChat": "रूम चैट", + "@roomChat": { + "description": "Title of the in-room chat screen" + }, + "failedToResend": "पुनः भेजने में असफल", + "@failedToResend": { + "description": "Snackbar shown when retrying a failed chat message fails again" + }, + "edited": " (संपादित)", + "@edited": { + "description": "Inline suffix appended to an edited chat message" + }, + "failedToSendTapRetry": "भेजने में विफल। पुनः प्रयास के लिए संदेश पर टैप करें।", + "@failedToSendTapRetry": { + "description": "Snackbar shown when a chat message fails to send" + }, + "saySomething": "कुछ कहें", + "@saySomething": { + "description": "Hint text in the chat message input field" + }, + "tapToRetry": "पुनः प्रयास के लिए टैप करें", + "@tapToRetry": { + "description": "Tooltip on the retry affordance of a failed chat message" + }, + "retry": "पुनः प्रयास करें", + "@retry": { + "description": "Label of the retry action on a failed chat message" + }, + "roomRemovedSuccessfully": "रूम आपकी सूची से सफलतापूर्वक हटा दिया गया", + "@roomRemovedSuccessfully": { + "description": "Success message when a room is successfully removed from the user's list" + }, + "alert": "अलर्ट", + "@alert": { + "description": "Title for an alert dialog." + }, + "removedFromRoom": "आपको कमरे से रिपोर्ट किया गया है या हटा दिया गया है", + "@removedFromRoom": { + "description": "Message shown when a user is removed or reported from a room." + }, + "reportType": "{type, select, harassment{हिंसा / नफरत भरा भाषण} abuse{दुरुपयोग सामग्री / हिंसा} spam{स्पैम / धोखाधड़ी} impersonation{नकली खाते} illegal{गैरकानूनी गतिविधियाँ} selfharm{आत्म-हानि / आत्महत्या / मानसिक स्वास्थ्य} misuse{प्लेटफ़ॉर्म का दुरुपयोग} other{अन्य}}", + "@reportType": { + "description": "Selects the appropriate report type label based on a key.", + "placeholders": { + "type": { + "type": "String", + "example": "harassment" + } + } + }, + "userBlockedFromResonate": "आपको कई उपयोगकर्ताओं द्वारा रिपोर्ट किया गया है और आपको रेज़ोनेट का उपयोग करने से ब्लॉक कर दिया गया है। यदि आपको लगता है कि यह गलती है, तो कृपया AOSSIE से संपर्क करें।", + "@userBlockedFromResonate": { + "description": "Message shown when a user is blocked from using Resonate." + }, + "reportParticipant": "प्रतिभागी की रिपोर्ट करें", + "@reportParticipant": { + "description": "Label for the button to report a participant." + }, + "selectReportType": "रिपोर्ट का प्रकार चुनें", + "@selectReportType": { + "description": "Prompt for the user to select a report type." + }, + "reportSubmitted": "रिपोर्ट सफलतापूर्वक सबमिट की गई", + "@reportSubmitted": { + "description": "Message shown when a report is submitted successfully." + }, + "reportFailed": "रिपोर्ट सबमिशन फेल", + "@reportFailed": { + "description": "Message shown when a report submission fails." + }, + "additionalDetailsOptional": "अतिरिक्त विवरण (वैकल्पिक)", + "@additionalDetailsOptional": { + "description": "Label for an optional input field for additional details in a report." + }, + "submitReport": "रिपोर्ट सबमिट करें", + "@submitReport": { + "description": "Button text to submit a report." + }, + "actionBlocked": "कार्रवाई अवरुद्ध", + "@actionBlocked": { + "description": "Title for a message indicating that a user action is blocked." + }, + "cannotStopRecording": "आप रिकॉर्डिंग को मैन्युअल रूप से रोक नहीं सकते, रिकॉर्डिंग तब रोकी जाएगी जब कमरा बंद होगा।", + "@cannotStopRecording": { + "description": "Message explaining why a user cannot stop a recording manually." + }, + "liveChapter": "लाइव चैप्टर", + "@liveChapter": { + "description": "Label indicating that a chapter is currently live." + }, + "viewOrEditLyrics": "गीत देखें या संपादित करें", + "@viewOrEditLyrics": { + "description": "Button text to view or edit the lyrics of a chapter." + }, + "close": "बंद करें", + "@close": { + "description": "Label for the button to close a dialog." + }, + "verifyChapterDetails": "चैप्टर विवरण सत्यापित करें", + "@verifyChapterDetails": { + "description": "Title for the screen where Live chapter details are verified before publishing." + }, + "author": "लेखक", + "@author": { + "description": "Label for the author of a story or chapter." + }, + "startLiveChapter": "लाइव चैप्टर शुरू करें", + "@startLiveChapter": { + "description": "Title for the screen where a live chapter is initiated." + }, + "fillAllFields": "कृपया सभी आवश्यक फ़ील्ड भरें", + "@fillAllFields": { + "description": "Error message when required fields are not filled in a form." + }, + "noRecordingError": "आपके पास कोई रिकॉर्डिंग नहीं है। लाइव चैप्टर रूम से बाहर निकलने के लिए, कृपया पहले रिकॉर्डिंग शुरू करें।", + "@noRecordingError": { + "description": "Error message when trying to exit a live chapter room without any recording." + }, + "audioOutput": "ऑडियो आउटपुट", + "@audioOutput": { + "description": "Title for audio output device selector." + }, + "selectPreferredSpeaker": "अपना पसंदीदा स्पीकर चुनें", + "@selectPreferredSpeaker": { + "description": "Subtitle for audio device selector dialog." + }, + "noAudioOutputDevices": "कोई ऑडियो आउटपुट डिवाइस नहीं मिला", + "@noAudioOutputDevices": { + "description": "Message shown when no audio output devices are available." + }, + "refresh": "रीफ़्रेश करें", + "@refresh": { + "description": "Button text to refresh audio device list." + }, + "done": "हो गया", + "@done": { + "description": "Button text to close audio device selector." + }, + "deleteMessageTitle": "संदेश हटाएँ", +"@deleteMessageTitle": { + "description": "Title shown in the delete message confirmation dialog." +}, +"deleteMessageContent": "क्या आप वाकई इस संदेश को हटाना चाहते हैं?", +"@deleteMessageContent": { + "description": "Confirmation text asking the user if they want to delete a message." +}, +"thisMessageWasDeleted": "यह संदेश हटा दिया गया है", +"failedToDeleteMessage": "संदेश हटाने में विफल रहा", + +"noFriendsYet": "अभी तक कोई दोस्त नहीं", +"@noFriendsYet": { + "description": "Title shown when user has no friends." +}, +"noFriendsDescription": "आपकी दोस्तों की सूची खाली है। लोगों से जुड़ना शुरू करें और अपना नेटवर्क बढ़ाएं!", +"@noFriendsDescription": { + "description": "Description shown when user has no friends." +}, +"findFriends": "दोस्त खोजें", +"@findFriends": { + "description": "Button text to navigate to find friends screen." +}, +"inviteFriend": "दोस्त को आमंत्रित करें", +"@inviteFriend": { + "description": "Button text to invite friends to the app." +}, +"noFriendRequestsYet": "कोई फ्रेंड रिक्वेस्ट नहीं", +"@noFriendRequestsYet": { + "description": "Title shown when user has no friend requests." +}, +"noFriendRequestsDescription": "आपके पास कोई पेंडिंग फ्रेंड रिक्वेस्ट नहीं है। जुड़ने के लिए अपने दोस्तों को आमंत्रित करें!", +"@noFriendRequestsDescription": { + "description": "Description shown when user has no friend requests." +}, +"inviteToResonate": "अरे! Resonate पर मेरे साथ जुड़ो - एक सोशल ऑडियो प्लेटफॉर्म जहां हर आवाज़ की कद्र होती है। अभी डाउनलोड करें: {url}", +"@inviteToResonate": { + "description": "Text used when inviting friends to the app.", + "placeholders": { + "url": { + "type": "String" + } + } +} +, +"usernameInvalidFormat": "उपयोगकर्ता नाम अमान्य है। कृपया केवल अक्षर, अंक और अंडरस्कोर का उपयोग करें।", +"@usernameInvalidFormat": { + "description": "Error shown when username contains invalid characters or has an invalid format." +}, +"usernameAlreadyTaken": "यह उपयोगकर्ता नाम पहले से लिया जा चुका है। कृपया कोई और चुनें।", +"@usernameAlreadyTaken": { + "description": "Error shown when the chosen username is already in use." +} +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1113abdc..7e1a9add 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2356,6 +2356,54 @@ abstract class AppLocalizations { /// **'Failed to remove room'** String get failedToRemoveRoom; + /// Error message when unable to create a room + /// + /// In en, this message translates to: + /// **'Failed to create room'** + String get failedToCreateRoom; + + /// Title of the in-room chat screen + /// + /// In en, this message translates to: + /// **'Room Chat'** + String get roomChat; + + /// Snackbar shown when retrying a failed chat message fails again + /// + /// In en, this message translates to: + /// **'Failed to resend'** + String get failedToResend; + + /// Inline suffix appended to an edited chat message + /// + /// In en, this message translates to: + /// **' (edited)'** + String get edited; + + /// Snackbar shown when a chat message fails to send + /// + /// In en, this message translates to: + /// **'Failed to send. Tap the message to retry.'** + String get failedToSendTapRetry; + + /// Hint text in the chat message input field + /// + /// In en, this message translates to: + /// **'Say Something'** + String get saySomething; + + /// Tooltip on the retry affordance of a failed chat message + /// + /// In en, this message translates to: + /// **'Tap to retry'** + String get tapToRetry; + + /// Label of the retry action on a failed chat message + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + /// Success message when a room is successfully removed from the user's list /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bn.dart b/lib/l10n/app_localizations_bn.dart index 56bd8752..5dee6819 100644 --- a/lib/l10n/app_localizations_bn.dart +++ b/lib/l10n/app_localizations_bn.dart @@ -1275,6 +1275,31 @@ class AppLocalizationsBn extends AppLocalizations { @override String get failedToRemoveRoom => 'রুম সরানো যায়নি'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'আপনার তালিকা থেকে রুমটি সফলভাবে সরানো হয়েছে'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9d8bc742..1c0b3ba6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1266,6 +1266,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get failedToRemoveRoom => 'Failed to remove room'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'Room removed from your list successfully'; diff --git a/lib/l10n/app_localizations_gu.dart b/lib/l10n/app_localizations_gu.dart index 2f9fd91a..a0fbaeaa 100644 --- a/lib/l10n/app_localizations_gu.dart +++ b/lib/l10n/app_localizations_gu.dart @@ -1266,6 +1266,31 @@ class AppLocalizationsGu extends AppLocalizations { @override String get failedToRemoveRoom => 'Failed to remove room'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'Room removed from your list successfully'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 9e3cbacb..64847ef4 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1270,6 +1270,31 @@ class AppLocalizationsHi extends AppLocalizations { @override String get failedToRemoveRoom => 'रूम हटाने में विफल'; + @override + String get failedToCreateRoom => 'रूम बनाने में विफल'; + + @override + String get roomChat => 'रूम चैट'; + + @override + String get failedToResend => 'पुनः भेजने में विफल'; + + @override + String get edited => ' (संपादित)'; + + @override + String get failedToSendTapRetry => + 'भेजने में विफल। पुनः प्रयास के लिए संदेश पर टैप करें।'; + + @override + String get saySomething => 'कुछ कहें'; + + @override + String get tapToRetry => 'पुनः प्रयास के लिए टैप करें'; + + @override + String get retry => 'पुनः प्रयास करें'; + @override String get roomRemovedSuccessfully => 'रूम आपकी सूची से सफलतापूर्वक हटा दिया गया'; diff --git a/lib/l10n/app_localizations_kn.dart b/lib/l10n/app_localizations_kn.dart index 5e13b73e..5f41da69 100644 --- a/lib/l10n/app_localizations_kn.dart +++ b/lib/l10n/app_localizations_kn.dart @@ -1273,6 +1273,31 @@ class AppLocalizationsKn extends AppLocalizations { @override String get failedToRemoveRoom => 'Failed to remove room'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'Room removed from your list successfully'; diff --git a/lib/l10n/app_localizations_ml.dart b/lib/l10n/app_localizations_ml.dart index 257e95e4..477265d9 100644 --- a/lib/l10n/app_localizations_ml.dart +++ b/lib/l10n/app_localizations_ml.dart @@ -1281,6 +1281,31 @@ class AppLocalizationsMl extends AppLocalizations { @override String get failedToRemoveRoom => 'മുറി നീക്കം ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'മുറി നിങ്ങളുടെ ലിസ്റ്റിൽ നിന്ന് വിജയകരമായി നീക്കം ചെയ്തു'; diff --git a/lib/l10n/app_localizations_mr.dart b/lib/l10n/app_localizations_mr.dart index 01a9cfbd..8ad03a48 100644 --- a/lib/l10n/app_localizations_mr.dart +++ b/lib/l10n/app_localizations_mr.dart @@ -1269,6 +1269,31 @@ class AppLocalizationsMr extends AppLocalizations { @override String get failedToRemoveRoom => 'कक्ष काढून टाकण्यात अयशस्वी'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'कक्ष आपल्या यादीतून यशस्वीरित्या काढून टाकला'; diff --git a/lib/l10n/app_localizations_pa.dart b/lib/l10n/app_localizations_pa.dart index 01b95aaf..0e07accf 100644 --- a/lib/l10n/app_localizations_pa.dart +++ b/lib/l10n/app_localizations_pa.dart @@ -1233,6 +1233,31 @@ class AppLocalizationsPa extends AppLocalizations { @override String get failedToRemoveRoom => 'ਰੂਮ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ'; + @override + String get failedToCreateRoom => 'ਰੂਮ ਬਣਾਉਣ ਵਿੱਚ ਅਸਫਲ'; + + @override + String get roomChat => 'ਰੂਮ ਚੈਟ'; + + @override + String get failedToResend => 'ਮੁੜ ਭੇਜਣ ਵਿੱਚ ਅਸਫਲ'; + + @override + String get edited => ' (ਸੰਪਾਦਿਤ)'; + + @override + String get failedToSendTapRetry => + 'ਭੇਜਣ ਵਿੱਚ ਅਸਫਲ। ਮੁੜ ਕੋਸ਼ਿਸ਼ ਲਈ ਸੁਨੇਹੇ ਉੱਤੇ ਟੈਪ ਕਰੋ।'; + + @override + String get saySomething => 'ਕੁਝ ਕਹੋ'; + + @override + String get tapToRetry => 'ਮੁੜ ਕੋਸ਼ਿਸ਼ ਲਈ ਟੈਪ ਕਰੋ'; + + @override + String get retry => 'ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ'; + @override String get roomRemovedSuccessfully => 'ਰੂਮ ਸਫਲਤਾਪੂਰਵਕ ਹਟਾਇਆ ਗਿਆ'; diff --git a/lib/l10n/app_localizations_raj.dart b/lib/l10n/app_localizations_raj.dart index ea3a8e3e..511ed33f 100644 --- a/lib/l10n/app_localizations_raj.dart +++ b/lib/l10n/app_localizations_raj.dart @@ -1258,6 +1258,31 @@ class AppLocalizationsRaj extends AppLocalizations { @override String get failedToRemoveRoom => 'रूम हटावां मांय नाकाम'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'रूम अपनी सूची मांय सूं सफलतापूर्वक हटायो गयो'; diff --git a/lib/l10n/app_localizations_ta.dart b/lib/l10n/app_localizations_ta.dart index f8a84388..a6c7b156 100644 --- a/lib/l10n/app_localizations_ta.dart +++ b/lib/l10n/app_localizations_ta.dart @@ -1284,6 +1284,31 @@ class AppLocalizationsTa extends AppLocalizations { @override String get failedToRemoveRoom => 'அறையை நீக்க முடியவில்லை'; + @override + String get failedToCreateRoom => 'Failed to create room'; + + @override + String get roomChat => 'Room Chat'; + + @override + String get failedToResend => 'Failed to resend'; + + @override + String get edited => ' (edited)'; + + @override + String get failedToSendTapRetry => + 'Failed to send. Tap the message to retry.'; + + @override + String get saySomething => 'Say Something'; + + @override + String get tapToRetry => 'Tap to retry'; + + @override + String get retry => 'Retry'; + @override String get roomRemovedSuccessfully => 'அறை உங்கள் பட்டியலிலிருந்து வெற்றிகரமாக நீக்கப்பட்டது'; diff --git a/lib/l10n/app_pa.arb b/lib/l10n/app_pa.arb index a53bad20..fe0a52d8 100644 --- a/lib/l10n/app_pa.arb +++ b/lib/l10n/app_pa.arb @@ -1,1824 +1,1856 @@ -{ - "@@locale": "pa", - "title": "ਰੇਜ਼ੋਨੇਟ", - "@title": { - "description": "The title of the application." - }, - "roomDescription": "ਵਿਨਮ੍ਰ ਰਹੋ ਅਤੇ ਦੂਜੇ ਵਿਅਕਤੀ ਦੀ ਰਾਏ ਦਾ ਆਦਰ ਕਰੋ। ਅਪਮਾਨਜਨਕ ਟਿੱਪਣੀਆਂ ਤੋਂ ਬਚੋ।", - "@roomDescription": { - "description": "Guideline message for users in a chat room." - }, - "hidePassword": "ਪਾਸਵਰਡ ਲੁਕਾਓ", - "@hidePassword": { - "description": "Button text to conceal the password field." - }, - "showPassword": "ਪਾਸਵਰਡ ਦਿਖਾਓ", - "@showPassword": { - "description": "Button text to reveal the password field." - }, - "passwordEmpty": "ਪਾਸਵਰਡ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ", - "@passwordEmpty": { - "description": "Error message when the password field is left blank." - }, - "password": "ਪਾਸਵਰਡ", - "@password": { - "description": "Label for the password input field." - }, - "confirmPassword": "ਪਾਸਵਰਡ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ", - "@confirmPassword": { - "description": "Label for the confirm password input field." - }, - "passwordsNotMatch": "ਪਾਸਵਰਡ ਮਿਲਦੇ ਨਹੀਂ ਹਨ", - "@passwordsNotMatch": { - "description": "Error message when password and confirmation password do not match." - }, - "userCreatedStories": "ਯੂਜ਼ਰ ਵਲੋਂ ਬਣਾਈਆਂ ਕਹਾਣੀਆਂ", - "@userCreatedStories": { - "description": "Header for the section showing stories created by another user." - }, - "yourStories": "ਤੁਹਾਡੀਆਂ ਕਹਾਣੀਆਂ", - "@yourStories": { - "description": "Header for the section showing stories created by the current user." - }, - "userNoStories": "ਯੂਜ਼ਰ ਨੇ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ ਬਣਾਈ", - "@userNoStories": { - "description": "Message displayed when viewing another user's profile and they have no stories." - }, - "youNoStories": "ਤੁਸੀਂ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ ਬਣਾਈ", - "@youNoStories": { - "description": "Message displayed on the current user's profile when they have no stories." - }, - "follow": "ਫਾਲो ਕਰੋ", - "@follow": { - "description": "Button text to follow a user." - }, - "editProfile": "ਪ੍ਰੋਫਾਈਲ ਐਡਿਟ ਕਰੋ", - "@editProfile": { - "description": "Button text to navigate to the profile editing screen." - }, - "verifyEmail": "ਈਮੇਲ ਵੇਰੀਫਾਈ ਕਰੋ", - "@verifyEmail": { - "description": "Button or link text to start the email verification process." - }, - "verified": "ਵੇਰੀਫਾਈਡ", - "@verified": { - "description": "A status label indicating that the user's email has been verified." - }, - "profile": "ਪ੍ਰੋਫਾਈਲ", - "@profile": { - "description": "Label for the user profile section or page." - }, - "userLikedStories": "ਯੂਜ਼ਰ ਨੂੰ ਪਸੰਦ ਆਈਆਂ ਕਹਾਣੀਆਂ", - "@userLikedStories": { - "description": "Header for the section showing stories liked by another user." - }, - "yourLikedStories": "ਤੁਹਾਨੂੰ ਪਸੰਦ ਆਈਆਂ ਕਹਾਣੀਆਂ", - "@yourLikedStories": { - "description": "Header for the section showing stories liked by the current user." - }, - "userNoLikedStories": "ਯੂਜ਼ਰ ਨੇ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਲਾਈਕ ਨਹੀਂ ਕੀਤੀ", - "@userNoLikedStories": { - "description": "Message displayed when viewing another user's profile and they have no liked stories." - }, - "youNoLikedStories": "ਤੁਸੀਂ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਲਾਈਕ ਨਹੀਂ ਕੀਤੀ", - "@youNoLikedStories": { - "description": "Message displayed on the current user's profile when they have no liked stories." - }, - "live": "ਲਾਈਵ", - "@live": { - "description": "Tab or label for live audio rooms." - }, - "upcoming": "ਆਉਣ ਵਾਲਾ", - "@upcoming": { - "description": "Tab or label for scheduled/upcoming audio rooms." - }, - "noAvailableRoom": "{isRoom, select, true{ਕੋਈ ਰੂਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ} false{ਕੋਈ ਆਉਣ ਵਾਲਾ ਰੂਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ} other{ਰੂਮ ਦੀ ਜਾਣਕਾਰੀ ਉਪਲਬਧ ਨਹੀਂ ਹੈ}}\nਹੇਠਾਂ ਤੋਂ ਇੱਕ ਰੂਮ ਬਣਾਕੇ ਸ਼ੁਰੂਆਤ ਕਰੋ!", - "@noAvailableRoom": { - "description": "Message shown when no rooms are available. Uses ICU select for room type. The placeholder is 'isRoom'.", - "placeholders": { - "isRoom": { - "type": "String", - "example": "true" - } - } - }, - "user1": "ਯੂਜ਼ਰ 1", - "@user1": { - "description": "A generic placeholder name for a user." - }, - "user2": "ਯੂਜ਼ਰ 2", - "@user2": { - "description": "A second generic placeholder name for a user." - }, - "you": "ਤੁਸੀਂ", - "@you": { - "description": "Label to identify the current user in a list or chat." - }, - "areYouSure": "ਕੀ ਤੁਸੀਂ ਪੱਕੇ ਹੋ?", - "@areYouSure": { - "description": "Confirmation prompt title." - }, - "loggingOut": "ਤੁਸੀਂ ਰੇਜ਼ੋਨੇਟ ਤੋਂ ਲੌਗ ਆਉਟ ਹੋ ਰਹੇ ਹੋ।", - "@loggingOut": { - "description": "Confirmation prompt message for logging out." - }, - "yes": "ਹਾਂ", - "@yes": { - "description": "Generic confirmation button text." - }, - "no": "ਨਹੀਂ", - "@no": { - "description": "Generic cancellation button text." - }, - "incorrectEmailOrPassword": "ਈਮੇਲ ਜਾਂ ਪਾਸਵਰਡ ਗਲਤ ਹੈ", - "@incorrectEmailOrPassword": { - "description": "Error message for failed login attempt." - }, - "passwordShort": "ਪਾਸਵਰਡ 8 ਅੱਖਰ ਤੋਂ ਛੋਟਾ ਹੈ", - "@passwordShort": { - "description": "Error message for a password that is too short." - }, - "tryAgain": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ!", - "@tryAgain": { - "description": "Button text to retry a failed action." - }, - "success": "ਸਫਲਤਾ", - "@success": { - "description": "Generic title for a successful operation." - }, - "passwordResetSent": "ਪਾਸਵਰਡ ਰੀਸੈਟ ਈਮੇਲ ਭੇਜੀ ਗਈ!", - "@passwordResetSent": { - "description": "Success message after a password reset email has been dispatched." - }, - "error": "ਗਲਤੀ", - "@error": { - "description": "Generic title for a failed operation." - }, - "resetPassword": "ਪਾਸਵਰਡ ਰੀਸੈਟ ਕਰੋ", - "@resetPassword": { - "description": "Title or button text for the reset password feature." - }, - "enterNewPassword": "ਨਵਾਂ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ", - "@enterNewPassword": { - "description": "Instructional text on the reset password screen." - }, - "newPassword": "ਨਵਾਂ ਪਾਸਵਰਡ", - "@newPassword": { - "description": "Label for the new password input field." - }, - "setNewPassword": "ਨਵਾਂ ਪਾਸਵਰਡ ਸੈੱਟ ਕਰੋ", - "@setNewPassword": { - "description": "Button text to confirm setting a new password." - }, - "emailChanged": "ਈਮੇਲ ਬਦਲ ਦਿੱਤੀ ਗਈ", - "@emailChanged": { - "description": "Title for the success dialog after an email change." - }, - "emailChangeSuccess": "ਈਮੇਲ ਸਫਲਤਾਪੂਰਵਕ ਬਦਲ ਗਈ!", - "@emailChangeSuccess": { - "description": "Success message after changing the user's email." - }, - "failed": "ਅਸਫਲ", - "@failed": { - "description": "Generic title for a failed operation." - }, - "emailChangeFailed": "ਈਮੇਲ ਬਦਲਣ ਵਿੱਚ ਅਸਫਲ", - "@emailChangeFailed": { - "description": "Error message when an email change operation fails." - }, - "oops": "ਉਫ਼!", - "@oops": { - "description": "An informal title for an error or warning message." - }, - "emailExists": "ਈਮੇਲ ਪਹਿਲਾਂ ਤੋਂ ਮੌਜੂਦ ਹੈ", - "@emailExists": { - "description": "Error message when a user tries to register or change to an email that is already in use." - }, - "changeEmail": "ਈਮੇਲ ਬਦਲੋ", - "@changeEmail": { - "description": "Title or button text for the change email feature." - }, - "enterValidEmail": "ਕਿਰਪਾ ਕਰਕੇ ਇੱਕ ਵੈਧ ਈਮੇਲ ਦਾਖਲ ਕਰੋ", - "@enterValidEmail": { - "description": "Error message for an invalid email format." - }, - "newEmail": "ਨਵੀਂ ਈਮੇਲ", - "@newEmail": { - "description": "Label for the new email input field." - }, - "currentPassword": "ਮੌਜੂਦਾ ਪਾਸਵਰਡ", - "@currentPassword": { - "description": "Label for the current password input field, used for verification." - }, - "emailChangeInfo": "ਸੁਰੱਖਿਆ ਲਈ, ਈਮੇਲ ਬਦਲਣ ਲਈ ਮੌਜੂਦਾ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ। ਬਦਲਣ ਤੋਂ ਬਾਅਦ, ਨਵੀਂ ਈਮੇਲ ਨਾਲ ਲੌਗਿਨ ਕਰੋ।", - "@emailChangeInfo": { - "description": "Informational text explaining the process and security requirements for changing an email address for standard users." - }, - "oauthUsersMessage": "(ਕੇਵਲ ਗੂਗਲ ਜਾਂ ਗਿਥੁਬ ਨਾਲ ਲੌਗਿਨ ਕਰਨ ਵਾਲੇ ਯੂਜ਼ਰਾਂ ਲਈ)", - "@oauthUsersMessage": { - "description": "A message to specify that the following instructions are for OAuth users." - }, - "oauthUsersEmailChangeInfo": "ਈਮੇਲ ਬਦਲਣ ਲਈ, \"ਮੌਜੂਦਾ ਪਾਸਵਰਡ\" ਫੀਲਡ ਵਿੱਚ ਨਵਾਂ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ। ਇਸ ਨੂੰ ਯਾਦ ਰੱਖੋ—ਅੱਗੇ ਕੇਵਲ ਗੂਗਲ/ਗਿਥੁਬ ਜਾਂ ਨਵੇਂ ਪਾਸਵਰਡ ਨਾਲ ਲੌਗਿਨ ਹੋਵੇਗਾ।", - "@oauthUsersEmailChangeInfo": { - "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." - }, - "resonateTagline": "ਗੱਲਾਂ ਦੀ ਇੱਕ ਅਨੰਤ ਦੁਨੀਆ ਵਿੱਚ ਕਦਮ ਰੱਖੋ", - "@resonateTagline": { - "description": "The application's tagline, used on splash or login screens." - }, - "signInWithEmail": "ਈਮੇਲ ਨਾਲ ਸਾਈਨ ਇਨ ਕਰੋ", - "@signInWithEmail": { - "description": "Button text for signing in using email and password." - }, - "or": "ਜਾਂ", - "@or": { - "description": "A separator text, typically used between different login options." - }, - "continueWith": "ਇਨ੍ਹਾਂ ਵਿੱਚੋਂ ਕਿਸੇ ਇੱਕ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", - "@continueWith": { - "description": "Informational text preceding a list of third-party login providers." - }, - "continueWithGoogle": "ਗੂਗਲ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", - "@continueWithGoogle": { - "description": "Button text for signing in with a Google account." - }, - "continueWithGitHub": "ਗਿਥੁਬ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", - "@continueWithGitHub": { - "description": "Button text for signing in with a GitHub account." - }, - "resonateLogo": "ਰੇਜ਼ੋਨੇਟ ਲੋਗੋ", - "@resonateLogo": { - "description": "Accessibility text for the Resonate application logo image." - }, - "iAlreadyHaveAnAccount": "ਮੇਰੇ ਕੋਲ ਪਹਿਲਾਂ ਤੋਂ ਇੱਕ ਅਕਾਊਂਟ ਹੈ", - "@iAlreadyHaveAnAccount": { - "description": "Text for a link or button to navigate to the login screen from the sign-up screen." - }, - "createNewAccount": "ਨਵਾਂ ਅਕਾਊਂਟ ਬਣਾਓ", - "@createNewAccount": { - "description": "Text for a link or button to navigate to the sign-up screen from the login screen." - }, - "userProfile": "ਯੂਜ਼ਰ ਪ੍ਰੋਫਾਈਲ", - "@userProfile": { - "description": "Accessibility text for a user's profile picture or avatar." - }, - "passwordIsStrong": "ਪਾਸਵਰਡ ਮਜ਼ਬੂਤ ਹੈ", - "@passwordIsStrong": { - "description": "Validation message indicating that the entered password meets all strength requirements." - }, - "admin": "ਐਡਮਿਨ", - "@admin": { - "description": "Label for the Admin user role in a room." - }, - "moderator": "ਮੋਡਰੇਟਰ", - "@moderator": { - "description": "Label for the Moderator user role in a room." - }, - "speaker": "ਸਪੀਕਰ", - "@speaker": { - "description": "Label for the Speaker user role in a room." - }, - "listener": "ਲਿਸਨਰ", - "@listener": { - "description": "Label for the Listener user role in a room." - }, - "removeModerator": "ਮੋਡਰੇਟਰ ਹਟਾਓ", - "@removeModerator": { - "description": "Menu item text to revoke moderator privileges from a user." - }, - "kickOut": "ਕਿਕ ਆਉਟ ਕਰੋ", - "@kickOut": { - "description": "Menu item text to remove a user from a room." - }, - "addModerator": "ਮੋਡਰੇਟਰ ਜੋੜੋ", - "@addModerator": { - "description": "Menu item text to grant moderator privileges to a user." - }, - "addSpeaker": "ਸਪੀਕਰ ਜੋੜੋ", - "@addSpeaker": { - "description": "Menu item text to grant speaker privileges to a listener." - }, - "makeListener": "ਲਿਸਨਰ ਬਣਾਓ", - "@makeListener": { - "description": "Menu item text to change a speaker's role to listener." - }, - "pairChat": "ਪੇਅਰ ਚੈਟ", - "@pairChat": { - "description": "A feature name for one-on-one random chat." - }, - "chooseIdentity": "ਪਛਾਣ ਚੁਣੋ", - "@chooseIdentity": { - "description": "Prompt for the user to choose their identity, e.g., anonymous or public." - }, - "selectLanguage": "ਭਾਸ਼ਾ ਚੁਣੋ", - "@selectLanguage": { - "description": "Label for the language selection setting." - }, - "noConnection": "ਕੋਈ ਕਨੈਕਸ਼ਨ ਨਹੀਂ", - "@noConnection": { - "description": "Title indicating that there is no internet connection." - }, - "loadingDialog": "ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ...", - "@loadingDialog": { - "description": "Accessibility text for a loading indicator or spinner." - }, - "createAccount": "ਅਕਾਊਂਟ ਬਣਾਓ", - "@createAccount": { - "description": "Button text or page title for the account creation screen." - }, - "enterValidEmailAddress": "ਵੈਧ ਈਮੇਲ ਪਤਾ ਦਾਖਲ ਕਰੋ", - "@enterValidEmailAddress": { - "description": "Error message shown for an invalid email format." - }, - "email": "ਈਮੇਲ", - "@email": { - "description": "Label for the email input field." - }, - "passwordRequirements": "ਪਾਸਵਰਡ ਘੱਟੋ-ਘੱਟ 8 ਅੱਖਰ ਦਾ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ", - "@passwordRequirements": { - "description": "Instructional text listing the requirements for a valid password, part 1." - }, - "includeNumericDigit": "ਘੱਟੋ-ਘੱਟ 1 ਸੰਖਿਆ ਸ਼ਾਮਲ ਕਰੋ", - "@includeNumericDigit": { - "description": "Instructional text listing the requirements for a valid password, part 2." - }, - "includeUppercase": "ਘੱਟੋ-ਘੱਟ 1 ਵੱਡਾ ਅੱਖਰ ਸ਼ਾਮਲ ਕਰੋ", - "@includeUppercase": { - "description": "Instructional text listing the requirements for a valid password, part 3." - }, - "includeLowercase": "ਘੱਟੋ-ਘੱਟ 1 ਛੋਟਾ ਅੱਖਰ ਸ਼ਾਮਲ ਕਰੋ", - "@includeLowercase": { - "description": "Instructional text listing the requirements for a valid password, part 4." - }, - "includeSymbol": "ਘੱਟੋ-ਘੱਟ 1 ਚਿੰਨ੍ਹ ਸ਼ਾਮਲ ਕਰੋ", - "@includeSymbol": { - "description": "Instructional text listing the requirements for a valid password, part 5." - }, - "signedUpSuccessfully": "ਸਫਲਤਾਪੂਰਵਕ ਸਾਈਨ ਅੱਪ ਹੋ ਗਿਆ!", - "@signedUpSuccessfully": { - "description": "Title for a success message after account creation." - }, - "newAccountCreated": "ਤੁਹਾਡਾ ਨਵਾਂ ਅਕਾਊਂਟ ਸਫਲਤਾਪੂਰਵਕ ਬਣ ਗਿਆ ਹੈ", - "@newAccountCreated": { - "description": "Success message confirming that a new account has been created." - }, - "signUp": "ਸਾਈਨ ਅੱਪ", - "@signUp": { - "description": "Button text to submit the sign-up form." - }, - "login": "ਲੌਗਿਨ", - "@login": { - "description": "Button text to submit the login form." - }, - "settings": "ਸੈਟਿੰਗਜ਼", - "@settings": { - "description": "Title for the settings page." - }, - "accountSettings": "ਅਕਾਊਂਟ ਸੈਟਿੰਗਜ਼", - "@accountSettings": { - "description": "Sub-header for account-related settings." - }, - "account": "ਅਕਾਊਂਟ", - "@account": { - "description": "Label for the account settings section." - }, - "appSettings": "ਐਪ ਸੈਟਿੰਗਜ਼", - "@appSettings": { - "description": "Sub-header for application-related settings." - }, - "themes": "ਥੀਮਜ਼", - "@themes": { - "description": "Label for the theme selection setting." - }, - "about": "ਰੇਜ਼ੋਨੇਟ ਬਾਰੇ", - "@about": { - "description": "Label for the 'About' section or page of the app or a story." - }, - "other": "ਹੋਰ", - "@other": { - "description": "A generic category label for miscellaneous settings or items." - }, - "contribute": "ਯੋਗਦਾਨ ਕਰੋ", - "@contribute": { - "description": "Label for a section encouraging users to contribute to the project." - }, - "appPreferences": "ਐਪ ਪਸੰਦ", - "@appPreferences": { - "description": "Label for the app preferences settings page." - }, - "transcriptionModel": "ਟ੍ਰਾਂਸਕ੍ਰਿਪਸ਼ਨ ਮਾਡਲ", - "@transcriptionModel": { - "description": "Section title for choosing AI transcription model." - }, - "transcriptionModelDescription": "ਵਾਇਸ ਟ੍ਰਾਂਸਕ੍ਰਿਪਸ਼ਨ ਲਈ AI ਮਾਡਲ ਚੁਣੋ। ਵੱਡੇ ਮਾਡਲ ਵਧੇਰੇ ਸਟੀਕ ਹਨ ਪਰ ਹੌਲੇ ਹਨ ਅਤੇ ਵਧੇਰੇ ਸਟੋਰੇਜ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ।", - "@transcriptionModelDescription": { - "description": "Description text explaining transcription model choices." - }, - "whisperModelTiny": "ਟਾਈਨੀ", - "@whisperModelTiny": { - "description": "Name of the smallest Whisper AI model." - }, - "whisperModelTinyDescription": "ਸਭ ਤੋਂ ਤੇਜ਼, ਘੱਟ ਸਟੀਕ (~39 MB)", - "@whisperModelTinyDescription": { - "description": "Description of the Tiny Whisper model performance and size." - }, - "whisperModelBase": "ਬੇਸ", - "@whisperModelBase": { - "description": "Name of the base Whisper AI model." - }, - "whisperModelBaseDescription": "ਸੰਤੁਲਿਤ ਗਤੀ ਅਤੇ ਸਟੀਕਤਾ (~74 MB)", - "@whisperModelBaseDescription": { - "description": "Description of the Base Whisper model performance and size." - }, - "whisperModelSmall": "ਸਮਾਲ", - "@whisperModelSmall": { - "description": "Name of the small Whisper AI model." - }, - "whisperModelSmallDescription": "ਚੰਗੀ ਸਟੀਕਤਾ, ਹੌਲਾ (~244 MB)", - "@whisperModelSmallDescription": { - "description": "Description of the Small Whisper model performance and size." - }, - "whisperModelMedium": "ਮੀਡੀਅਮ", - "@whisperModelMedium": { - "description": "Name of the medium Whisper AI model." - }, - "whisperModelMediumDescription": "ਉੱਚ ਸਟੀਕਤਾ, ਹੌਲਾ (~769 MB)", - "@whisperModelMediumDescription": { - "description": "Description of the Medium Whisper model performance and size." - }, - "whisperModelLargeV1": "ਲਾਰਜ V1", - "@whisperModelLargeV1": { - "description": "Name of the large V1 Whisper AI model." - }, - "whisperModelLargeV1Description": "ਸਭ ਤੋਂ ਵਧੇਰੇ ਸਟੀਕ, ਸਭ ਤੋਂ ਹੌਲਾ (~1.55 GB)", - "@whisperModelLargeV1Description": { - "description": "Description of the Large V1 Whisper model performance and size." - }, - "whisperModelLargeV2": "ਲਾਰਜ V2", - "@whisperModelLargeV2": { - "description": "Name of the large V2 Whisper AI model." - }, - "whisperModelLargeV2Description": "ਉੱਚ ਸਟੀਕਤਾ ਨਾਲ ਵਧੀਆ ਵੱਡਾ ਮਾਡਲ (~1.55 GB)", - "@whisperModelLargeV2Description": { - "description": "Description of the Large V2 Whisper model performance and size." - }, - "modelDownloadInfo": "ਮਾਡਲ ਪਹਿਲੀ ਵਾਰ ਵਰਤਣ 'ਤੇ ਡਾਊਨਲੋਡ ਹੋ ਜਾਂਦੇ ਹਨ। ਅਸੀਂ ਬੇਸ, ਸਮਾਲ ਜਾਂ ਮੀਡੀਅਮ ਵਰਤਣ ਦੀ ਸਿਫਾਰਸ਼ ਕਰਦੇ ਹਾਂ। ਵੱਡੇ ਮਾਡਲ ਲਈ ਬਹੁਤ ਉੱਚ-ਅੰਤ ਡਿਵਾਈਸ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ।", - "@modelDownloadInfo": { - "description": "Information message about model download." - }, - "logOut": "ਲੌਗ ਆਉਟ", - "@logOut": { - "description": "Button text to log the user out of their account." - }, - "participants": "ਭਾਗੀਦਾਰ", - "@participants": { - "description": "Label or title for the list of participants in a room." - }, - "delete": "ਡਿਲੀਟ", - "@delete": { - "description": "Generic button text for a delete action. Parameter for toRoomAction." - }, - "leave": "ਛੱਡੋ", - "@leave": { - "description": "Generic button text for a leave action. Parameter for toRoomAction." - }, - "leaveButton": "ਛੱਡੋ", - "@leaveButton": { - "description": "Button text to leave a room or conversation." - }, - "findingRandomPartner": "ਤੁਹਾਡੇ ਲਈ ਇੱਕ ਰੈਂਡਮ ਭਾਗੀਦਾਰ ਲੱਭ ਰਹੇ ਹਾਂ", - "@findingRandomPartner": { - "description": "Status message shown while the system is searching for a random chat partner." - }, - "quickFact": "ਤੁਰੰਤ ਤੱਥ", - "@quickFact": { - "description": "Header for a quick fact or tip, often shown during loading." - }, - "cancel": "ਕੈਂਸਲ", - "@cancel": { - "description": "Generic button text for a cancel action." - }, - "completeYourProfile": "ਆਪਣੀ ਪ੍ਰੋਫਾਈਲ ਪੂਰੀ ਕਰੋ", - "@completeYourProfile": { - "description": "Page title or prompt for the user to finish setting up their profile." - }, - "uploadProfilePicture": "ਪ੍ਰੋਫਾਈਲ ਪਿਕਚਰ ਅੱਪਲੋਡ ਕਰੋ", - "@uploadProfilePicture": { - "description": "Instructional text or button to upload a profile picture." - }, - "enterValidName": "ਵੈਧ ਨਾਮ ਦਾਖਲ ਕਰੋ", - "@enterValidName": { - "description": "Error message for an invalid name format." - }, - "name": "ਨਾਮ", - "@name": { - "description": "Label for the name input field." - }, - "username": "ਯੂਜ਼ਰਨੇਮ", - "@username": { - "description": "Label for the username input field." - }, - "enterValidDOB": "ਠੀक ਜਨਮ ਤਾਰੀਖ ਦਾਖਲ ਕਰੋ", - "@enterValidDOB": { - "description": "Error message for an invalid date of birth." - }, - "dateOfBirth": "ਜਨਮ ਤਾਰੀਖ", - "@dateOfBirth": { - "description": "Label for the date of birth input field." - }, - "forgotPassword": "ਪਾਸਵਰਡ ਭੁੱਲ ਗਏ?", - "@forgotPassword": { - "description": "Link text for users who have forgotten their password." - }, - "next": "ਅੱਗੇ", - "@next": { - "description": "Button text to proceed to the next step in a process." - }, - "noStoriesExist": "ਕੋਈ ਕਹਾਣੀ ਹਾਲੇ ਮੌਜੂਦ ਨਹੀਂ ਹੈ", - "@noStoriesExist": { - "description": "Message displayed when there are no stories available in a list." - }, - "enterVerificationCode": "ਆਪਣਾ\nਵੇਰੀਫਿਕੇਸ਼ਨ ਕੋਡ ਦਾਖਲ ਕਰੋ", - "@enterVerificationCode": { - "description": "Prompt for the user to enter the verification code sent to their email." - }, - "verificationCodeSent": "ਅਸੀਂ 6-ਅੰਕਾਂ ਦਾ ਕੋਡ ਭੇਜਿਆ ਹੈ\n", - "@verificationCodeSent": { - "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." - }, - "verificationComplete": "ਵੇਰੀਫਿਕੇਸ਼ਨ ਪੂਰਾ ਹੋ ਗਿਆ", - "@verificationComplete": { - "description": "Title for a success message after email verification." - }, - "verificationCompleteMessage": "ਵਧਾਈ ਹੋ! ਤੁਹਾਡੀ ਈਮੇਲ ਵੇਰੀਫਾਈ ਹੋ ਗਈ ਹੈ", - "@verificationCompleteMessage": { - "description": "Success message confirming email verification." - }, - "verificationFailed": "ਵੇਰੀਫਿਕੇਸ਼ਨ ਅਸਫਲ ਹੋ ਗਿਆ", - "@verificationFailed": { - "description": "Title for an error message when verification fails." - }, - "otpMismatch": "OTP ਮਿਲਦਾ ਨਹੀਂ, ਕਿਰਪਾ ਕਰਕੇ ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ", - "@otpMismatch": { - "description": "Error message when the entered OTP is incorrect." - }, - "otpResent": "OTP ਮੁੜ ਭੇਜਿਆ ਗਿਆ", - "@otpResent": { - "description": "Confirmation message that the OTP has been resent." - }, - "requestNewCode": "ਨਵਾਂ ਕੋਡ ਮੰਗੋ", - "@requestNewCode": { - "description": "Button text to request a new verification code." - }, - "requestNewCodeIn": "ਨਵਾਂ ਕੋਡ ਮੰਗਣ ਲਈ ਉਡੀਕ ਕਰੋ", - "@requestNewCodeIn": { - "description": "Text displayed before a countdown timer for requesting a new code." - }, - "clickPictureCamera": "ਕੈਮਰਾ ਨਾਲ ਫੋਟੋ ਲਓ", - "@clickPictureCamera": { - "description": "Option to take a new photo using the device camera." - }, - "pickImageGallery": "ਗੈਲਰੀ ਤੋਂ ਚਿੱਤਰ ਚੁਣੋ", - "@pickImageGallery": { - "description": "Option to choose an existing image from the device gallery." - }, - "deleteMessageTitle": "ਸੁਨੇਹਾ ਹਟਾਓ", - "@deleteMessageTitle": { - "description": "Title shown in the delete message confirmation dialog." - }, - "deleteMessageContent": "ਕੀ ਤੁਸੀਂ ਯਕੀਨਨ ਇਸ ਸੁਨੇਹੇ ਨੂੰ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?", - "@deleteMessageContent": { - "description": "Confirmation text asking the user if they want to delete a message." - }, - "thisMessageWasDeleted": "ਇਹ ਸੁਨੇਹਾ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ", - "@thisMessageWasDeleted": { - "description": "Text shown in place of a deleted message." - }, - "failedToDeleteMessage": "ਸੁਨੇਹਾ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", - "@failedToDeleteMessage": { - "description": "Error message shown when a message deletion fails." - }, - "hide": "ਲੁਕਾਓ", - "@hide": { - "description": "Button text to hide something." - }, - "removeRoom": "ਰੂਮ ਹਟਾਓ", - "@removeRoom": { - "description": "Button text to remove a room." - }, - "removeRoomFromList": "ਲਿਸਟ ਤੋਂ ਰੂਮ ਹਟਾਓ", - "@removeRoomFromList": { - "description": "Button text to remove a room from the list." - }, - "removeRoomConfirmation": "ਕੀ ਤੁਸੀਂ ਇਹ ਰੂਮ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?", - "@removeRoomConfirmation": { - "description": "Confirmation text for removing a room." - }, - "deleteMyAccount": "ਮੇਰਾ ਅਕਾਊਂਟ ਹਟਾਓ", - "@deleteMyAccount": { - "description": "Button text to delete user's account." - }, - "createNewRoom": "ਨਵਾਂ ਰੂਮ ਬਣਾਓ", - "@createNewRoom": { - "description": "Button text to create a new room." - }, - "pleaseEnterScheduledDateTime": "ਕਿਰਪਾ ਕਰਕੇ ਨਿਰਧਾਰਤ ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ ਦਾਖਲ ਕਰੋ", - "@pleaseEnterScheduledDateTime": { - "description": "Prompt to enter scheduled date and time." - }, - "scheduleDateTimeLabel": "ਨਿਰਧਾਰਤ ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ", - "@scheduleDateTimeLabel": { - "description": "Label for scheduled date and time field." - }, - "enterTags": "ਟੈਗ ਦਾਖਲ ਕਰੋ", - "@enterTags": { - "description": "Prompt to enter tags." - }, - "joinCommunity": "ਕਮਿਊਨਿਟੀ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", - "@joinCommunity": { - "description": "Button text to join the community." - }, - "followUsOnX": "X 'ਤੇ ਸਾਡਾ ਪਾਲਣਾ ਕਰੋ", - "@followUsOnX": { - "description": "Prompt to follow on X (Twitter)." - }, - "joinDiscordServer": "Discord ਸਰਵਰ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", - "@joinDiscordServer": { - "description": "Button text to join Discord server." - }, - "noLyrics": "ਕੋਈ ਲਿਰਿਕਸ ਨਹੀਂ", - "@noLyrics": { - "description": "Message when no lyrics are available." - }, - "noStoriesInCategory": "{categoryName} ਸ਼੍ਰੇਣੀ ਵਿੱਚ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ", - "@noStoriesInCategory": { - "description": "Message when no stories exist in a category.", - "placeholders": { - "categoryName": { - "type": "String", - "example": "drama" - } - } - }, - "newChapters": "ਨਵੇਂ ਅਧਿਆਇ", - "@newChapters": { - "description": "Label for new chapters." - }, - "helpToGrow": "ਵਧਣ ਵਿੱਚ ਮਦਦ ਕਰੋ", - "@helpToGrow": { - "description": "Prompt to help the app grow." - }, - "share": "ਸਾਂਝਾ ਕਰੋ", - "@share": { - "description": "Button text to share something." - }, - "rate": "ਰੇਟ ਕਰੋ", - "@rate": { - "description": "Button text to rate the app." - }, - "aboutResonate": "ਰੇਜ਼ੋਨੇਟ ਬਾਰੇ", - "@aboutResonate": { - "description": "About Resonate section." - }, - "description": "ਵਰਣਨ", - "@description": { - "description": "Label for description field." - }, - "confirm": "ਪੁਸ਼ਟੀ ਕਰੋ", - "@confirm": { - "description": "Button text to confirm an action." - }, - "classic": "ਕਲਾਸਿਕ", - "@classic": { - "description": "Classic theme label." - }, - "time": "ਸਮਾਂ", - "@time": { - "description": "Label for time field." - }, - "vintage": "ਵਿੰਟੇਜ", - "@vintage": { - "description": "Vintage theme label." - }, - "amber": "ਐਂਬਰ", - "@amber": { - "description": "Amber theme label." - }, - "forest": "ਫਾਰਸਟ", - "@forest": { - "description": "Forest theme label." - }, - "cream": "ਕਰੀਮ", - "@cream": { - "description": "Cream theme label." - }, - "none": "ਕੋਈ ਨਹੀਂ", - "@none": { - "description": "None option label." - }, - "checkOutGitHub": "ਸਾਡਾ GitHub ਰੈਪੋ ਦੇਖੋ: {url}", - "@checkOutGitHub": { - "description": "Prompt to check out GitHub.", - "placeholders": { - "url": { - "type": "String", - "example": "https://github.com/AOSSIE-Org/Resonate" - } - } - }, - "aossie": "AOSSIE", - "@aossie": { - "description": "AOSSIE organization label." - }, - "aossieLogo": "AOSSIE ਲੋਗੋ", - "@aossieLogo": { - "description": "Accessibility text for AOSSIE logo." - }, - "errorLoadPackageInfo": "ਪੈਕੇਜ ਜਾਣਕਾਰੀ ਲੋਡ ਕਰਨ ਵਿੱਚ ਗਲਤੀ", - "@errorLoadPackageInfo": { - "description": "Error loading package info message." - }, - "searchFailed": "ਖੋਜ ਅਸਫਲ ਰਹੀ", - "@searchFailed": { - "description": "Message when search fails." - }, - "updateAvailable": "ਅੱਪਡੇਟ ਉਪਲਬਧ ਹੈ", - "@updateAvailable": { - "description": "Message when update is available." - }, - "newVersionAvailable": "ਨਵਾਂ ਵਰਜਨ ਉਪਲਬਧ ਹੈ", - "@newVersionAvailable": { - "description": "Message when a new version is available." - }, - "upToDate": "ਅੱਪ-ਟੂ-ਡੇਟ", - "@upToDate": { - "description": "Message when app is up to date." - }, - "latestVersion": "ਨਵਾਂ ਵਰਜਨ", - "@latestVersion": { - "description": "Label for latest version." - }, - "profileCreatedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਈ ਗਈ", - "@profileCreatedSuccessfully": { - "description": "Message when profile is created successfully." - }, - "invalidScheduledDateTime": "ਅਵੈਧ ਨਿਰਧਾਰਤ ਤਾਰੀਖ/ਸਮਾਂ", - "@invalidScheduledDateTime": { - "description": "Error for invalid scheduled date/time." - }, - "scheduledDateTimePast": "ਨਿਰਧਾਰਤ ਤਾਰੀਖ/ਸਮਾਂ ਪਿਛਲੇ ਸਮੇਂ ਵਿੱਚ ਹੈ", - "@scheduledDateTimePast": { - "description": "Error for scheduled date/time in the past." - }, - "joinRoom": "ਰੂਮ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", - "@joinRoom": { - "description": "Button text to join a room." - }, - "unknownUser": "ਅਣਜਾਣ ਯੂਜ਼ਰ", - "@unknownUser": { - "description": "Label for unknown user." - }, - "canceled": "ਰੱਦ ਕੀਤਾ ਗਿਆ", - "@canceled": { - "description": "Label for canceled action." - }, - "english": "ਅੰਗਰੇਜ਼ੀ", - "@english": { - "description": "Label for English language." - }, - "emailVerificationRequired": "ਈਮੇਲ ਵੈਰੀਫਿਕੇਸ਼ਨ ਲਾਜ਼ਮੀ ਹੈ", - "@emailVerificationRequired": { - "description": "Message that email verification is required." - }, - "verify": "ਵੇਰੀਫਾਈ ਕਰੋ", - "@verify": { - "description": "Button text to verify something." - }, - "audioRoom": "ਆਡੀਓ ਰੂਮ", - "@audioRoom": { - "description": "Label for audio room." - }, - "toRoomAction": "ਰੂਮ ਨੂੰ {action} ਕਰਨ ਲਈ", - "@toRoomAction": { - "description": "Label for room action parameter.", - "placeholders": { - "action": { - "type": "String", - "example": "delete" - } - } - }, - "mailSentMessage": "ਮੇਲ ਭੇਜੀ ਗਈ", - "@mailSentMessage": { - "description": "Message that mail has been sent." - }, - "disconnected": "ਡਿਸਕਨੈਕਟ ਕੀਤਾ ਗਿਆ", - "@disconnected": { - "description": "Message for disconnected state." - }, - "micOn": "ਮਾਈਕ ਚਾਲੂ", - "@micOn": { - "description": "Label for mic on state." - }, - "speakerOn": "ਸਪੀਕਰ ਚਾਲੂ", - "@speakerOn": { - "description": "Label for speaker on state." - }, - "endChat": "ਚੈਟ ਖਤਮ ਕਰੋ", - "@endChat": { - "description": "Button text to end chat." - }, - "monthJan": "ਜਨਵਰੀ", - "@monthJan": { - "description": "Label for January." - }, - "monthFeb": "ਫਰਵਰੀ", - "@monthFeb": { - "description": "Label for February." - }, - "monthMar": "ਮਾਰਚ", - "@monthMar": { - "description": "Label for March." - }, - "monthApr": "ਅਪ੍ਰੈਲ", - "@monthApr": { - "description": "Label for April." - }, - "monthMay": "ਮਈ", - "@monthMay": { - "description": "Label for May." - }, - "monthJun": "ਜੂਨ", - "@monthJun": { - "description": "Label for June." - }, - "monthJul": "ਜੁਲਾਈ", - "@monthJul": { - "description": "Label for July." - }, - "monthAug": "ਅਗਸਤ", - "@monthAug": { - "description": "Label for August." - }, - "monthSep": "ਸਤੰਬਰ", - "@monthSep": { - "description": "Label for September." - }, - "monthOct": "ਅਕਤੂਬਰ", - "@monthOct": { - "description": "Label for October." - }, - "monthNov": "ਨਵੰਬਰ", - "@monthNov": { - "description": "Label for November." - }, - "monthDec": "ਦਸੰਬਰ", - "@monthDec": { - "description": "Label for December." - }, - "register": "ਰਜਿਸਟਰ ਕਰੋ", - "@register": { - "description": "Button text to register." - }, - "newToResonate": "ਰੇਜ਼ੋਨੇਟ ਲਈ ਨਵੇਂ ਹੋ?", - "@newToResonate": { - "description": "Prompt for new users to Resonate." - }, - "alreadyHaveAccount": "ਪਹਿਲਾਂ ਤੋਂ ਅਕਾਊਂਟ ਹੈ?", - "@alreadyHaveAccount": { - "description": "Prompt for users who already have an account." - }, - "checking": "ਚੈੱਕ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ...", - "@checking": { - "description": "Message for checking/loading state." - }, - "forgotPasswordMessage": "ਪਾਸਵਰਡ ਭੁੱਲ ਗਏ?", - "@forgotPasswordMessage": { - "description": "Message for forgot password prompt." - }, - "usernameUnavailable": "ਯੂਜ਼ਰਨੇਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ", - "@usernameUnavailable": { - "description": "Message when username is unavailable." - }, - "usernameInvalidOrTaken": "ਯੂਜ਼ਰਨੇਮ ਗਲਤ ਜਾਂ ਪਹਿਲਾਂ ਤੋਂ ਲਿਆ ਹੋਇਆ ਹੈ", - "@usernameInvalidOrTaken": { - "description": "Message when username is invalid or taken." - }, - "otpResentMessage": "OTP ਮੁੜ ਭੇਜਿਆ ਗਿਆ", - "@otpResentMessage": { - "description": "Message when OTP is resent." - }, - "connectionError": "ਕਨੈਕਸ਼ਨ ਵਿੱਚ ਗਲਤੀ", - "@connectionError": { - "description": "Message for connection error." - }, - "seconds": "ਸਕਿੰਟ", - "@seconds": { - "description": "Label for seconds unit." - }, - "unsavedChangesWarning": "ਅਸੁਰੱਖਿਅਤ ਤਬਦੀਲੀਆਂ ਚੇਤਾਵਨੀ", - "@unsavedChangesWarning": { - "description": "Warning for unsaved changes." - }, - "deleteAccountPermanent": "ਅਕਾਊਂਟ ਸਥਾਈ ਤੌਰ 'ਤੇ ਹਟਾਓ", - "@deleteAccountPermanent": { - "description": "Prompt for permanent account deletion." - }, - "giveGreatName": "ਵਧੀਆ ਨਾਮ ਦਿਓ", - "@giveGreatName": { - "description": "Prompt to give a great name." - }, - "joinCommunityDescription": "ਰੇਜ਼ੋਨੇਟ ਕਮਿਊਨਿਟੀ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ ਅਤੇ ਵਧੋ!", - "@joinCommunityDescription": { - "description": "Description for joining the community." - }, - "resonateDescription": "ਰੇਜ਼ੋਨੇਟ ਇੱਕ ਸੋਸ਼ਲ ਮੀਡੀਆ ਪਲੇਟਫਾਰਮ ਹੈ, ਜਿੱਥੇ ਹਰ ਆਵਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਆਪਣੇ ਵਿਚਾਰ, ਕਹਾਣੀਆਂ ਅਤੇ ਤਜ਼ਰਬੇ ਹੋਰਾਂ ਨਾਲ ਸਾਂਝੇ ਕਰੋ। ਆਪਣੀ ਆਡੀਓ ਯਾਤਰਾ ਹੁਣ ਸ਼ੁਰੂ ਕਰੋ। ਵੱਖ-ਵੱਖ ਚਰਚਾਵਾਂ ਅਤੇ ਵਿਸ਼ਿਆਂ ਵਿੱਚ ਡੁੱਬੋ। ਉਹ ਰੂਮ ਲੱਭੋ ਜੋ ਤੁਹਾਡੇ ਨਾਲ ਗੂੰਝਦੇ ਹਨ ਅਤੇ ਕਮਿਊਨਿਟੀ ਦਾ ਹਿੱਸਾ ਬਣੋ। ਗੱਲਬਾਤ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ! ਰੂਮ ਖੋਜੋ, ਦੋਸਤਾਂ ਨਾਲ ਜੁੜੋ, ਅਤੇ ਦੁਨੀਆਂ ਨਾਲ ਆਪਣੀ ਆਵਾਜ਼ ਸਾਂਝੀ ਕਰੋ।", - "@resonateDescription": { - "description": "Description about Resonate." - }, - "resonateFullDescription": "ਰੇਜ਼ੋਨੇਟ - ਗੱਲਾਂ ਦੀ ਦੁਨੀਆ!", - "@resonateFullDescription": { - "description": "Full description about Resonate." - }, - "stable": "ਸਥਿਰ", - "@stable": { - "description": "Label for stable version." - }, - "usernameCharacterLimit": "ਯੂਜ਼ਰਨੇਮ ਅੱਖਰ ਸੀਮਾ", - "@usernameCharacterLimit": { - "description": "Label for username character limit." - }, - "submit": "ਸਬਮਿਟ ਕਰੋ", - "@submit": { - "description": "Button text to submit." - }, - "anonymous": "ਅਗਿਆਤ", - "@anonymous": { - "description": "Label for anonymous user." - }, - "noSearchResults": "ਕੋਈ ਖੋਜ ਨਤੀਜੇ ਨਹੀਂ", - "@noSearchResults": { - "description": "Message when no search results found." - }, - "searchRooms": "ਰੂਮ ਖੋਜੋ", - "@searchRooms": { - "description": "Prompt to search rooms." - }, - "searchingRooms": "ਰੂਮ ਖੋਜੇ ਜਾ ਰਹੇ ਹਨ...", - "@searchingRooms": { - "description": "Message for searching rooms." - }, - "clearSearch": "ਖੋਜ ਸਾਫ਼ ਕਰੋ", - "@clearSearch": { - "description": "Button text to clear search." - }, - "searchError": "ਖੋਜ ਵਿੱਚ ਗਲਤੀ", - "@searchError": { - "description": "Error message for search." - }, - "searchRoomsError": "ਰੂਮ ਖੋਜਣ ਵਿੱਚ ਗਲਤੀ", - "@searchRoomsError": { - "description": "Message when searching rooms fails." - }, - "searchUpcomingRoomsError": "ਆਉਣ ਵਾਲੇ ਰੂਮ ਖੋਜਣ ਵਿੱਚ ਗਲਤੀ", - "@searchUpcomingRoomsError": { - "description": "Message when searching upcoming rooms fails." - }, - "search": "ਖੋਜੋ", - "@search": { - "description": "Button text to search." - }, - "clear": "ਸਾਫ਼ ਕਰੋ", - "@clear": { - "description": "Button text to clear something." - }, - "shareRoomMessage": "ਇਸ ਸ਼ਾਨਦਾਰ ਰੂਮ ਨੂੰ ਦੇਖੋ: {roomName}!\n\n📖 ਵੇਰਵਾ: {description}\n👥 ਹੁਣੇ {participants} ਲੋਕ ਜੁੜ ਚੁੱਕੇ ਹਨ!", - "@shareRoomMessage": { - "description": "Message to share a room.", - "placeholders": { - "roomName": { - "type": "String", - "example": "Discussion Room" - }, - "description": { - "type": "String", - "example": "A great place to discuss" - }, - "participants": { - "type": "int", - "example": "5" - } - } - }, - "participantsCount": "{count} ਭਾਗੀਦਾਰ", - "@participantsCount": { - "description": "Label for participants count.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "join": "ਸ਼ਾਮਲ ਹੋਵੋ", - "@join": { - "description": "Button text to join." - }, - "invalidTags": "ਅਵੈਧ ਟੈਗ", - "@invalidTags": { - "description": "Error for invalid tags." - }, - "cropImage": "ਚਿੱਤਰ ਕੱਟੋ", - "@cropImage": { - "description": "Button text to crop image." - }, - "profileSavedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਸੰਭਾਲੀ ਗਈ", - "@profileSavedSuccessfully": { - "description": "Message when profile is saved successfully." - }, - "profileUpdatedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਅੱਪਡੇਟ ਹੋਈ", - "@profileUpdatedSuccessfully": { - "description": "Message when profile is updated successfully." - }, - "profileUpToDate": "ਪ੍ਰੋਫਾਈਲ ਅੱਪ-ਟੂ-ਡੇਟ ਹੈ", - "@profileUpToDate": { - "description": "Message when profile is up to date." - }, - "noChangesToSave": "ਸੰਭਾਲਣ ਲਈ ਕੋਈ ਤਬਦੀਲੀਆਂ ਨਹੀਂ", - "@noChangesToSave": { - "description": "Message when there are no changes to save." - }, - "connectionFailed": "ਕਨੈਕਸ਼ਨ ਅਸਫਲ", - "@connectionFailed": { - "description": "Message when connection fails." - }, - "unableToJoinRoom": "ਰੂਮ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਣ ਵਿੱਚ ਅਸਮਰਥ", - "@unableToJoinRoom": { - "description": "Message when unable to join room." - }, - "connectionLost": "ਕਨੈਕਸ਼ਨ ਖਤਮ ਹੋ ਗਿਆ", - "@connectionLost": { - "description": "Message when connection is lost." - }, - "unableToReconnect": "ਮੁੜ-ਕਨੈਕਟ ਕਰਨ ਵਿੱਚ ਅਸਮਰਥ", - "@unableToReconnect": { - "description": "Message when unable to reconnect." - }, - "invalidFormat": "ਅਵੈਧ ਫਾਰਮੈਟ", - "@invalidFormat": { - "description": "Error for invalid format." - }, - "usernameAlphanumeric": "ਯੂਜ਼ਰਨੇਮ ਅਲਫਾ-ਨਿਊਮੈਰਿਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ", - "@usernameAlphanumeric": { - "description": "Message for alphanumeric username requirement." - }, - "userProfileCreatedSuccessfully": "ਯੂਜ਼ਰ ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਈ ਗਈ", - "@userProfileCreatedSuccessfully": { - "description": "Message when user profile is created successfully." - }, - "emailVerificationMessage": "ਈਮੇਲ ਵੈਰੀਫਿਕੇਸ਼ਨ ਸੁਨੇਹਾ", - "@emailVerificationMessage": { - "description": "Message for email verification." - }, - "addNewChaptersToStory": "{storyName} ਵਿੱਚ ਨਵੇਂ ਅਧਿਆਇ ਜੋੜੋ", - "@addNewChaptersToStory": { - "description": "Prompt to add new chapters to story.", - "placeholders": { - "storyName": { - "type": "String", - "example": "My Story" - } - } - }, - "currentChapters": "ਮੌਜੂਦਾ ਅਧਿਆਇ", - "@currentChapters": { - "description": "Label for current chapters." - }, - "sourceCodeOnGitHub": "ਗਿਥੁਬ ਤੇ ਸਰੋਤ ਕੋਡ", - "@sourceCodeOnGitHub": { - "description": "Label for source code on GitHub." - }, - "createAChapter": "ਅਧਿਆਇ ਬਣਾਓ", - "@createAChapter": { - "description": "Button text to create a chapter." - }, - "chapterTitle": "ਅਧਿਆਇ ਸਿਰਲੇਖ", - "@chapterTitle": { - "description": "Label for chapter title." - }, - "aboutRequired": "'ਬਾਰੇ' ਲਾਜ਼ਮੀ ਹੈ", - "@aboutRequired": { - "description": "Message that 'about' is required." - }, - "changeCoverImage": "ਕਵਰ ਚਿੱਤਰ ਬਦਲੋ", - "@changeCoverImage": { - "description": "Button text to change cover image." - }, - "uploadAudioFile": "ਆਡੀਓ ਫਾਈਲ ਅੱਪਲੋਡ ਕਰੋ", - "@uploadAudioFile": { - "description": "Button text to upload audio file." - }, - "uploadLyricsFile": "ਲਿਰਿਕਸ ਫਾਈਲ ਅੱਪਲੋਡ ਕਰੋ", - "@uploadLyricsFile": { - "description": "Button text to upload lyrics file." - }, - "createChapter": "ਅਧਿਆਇ ਬਣਾਓ", - "@createChapter": { - "description": "Button text to create chapter." - }, - "audioFileSelected": "ਆਡੀਓ ਫਾਈਲ ਚੁਣੀ ਗਈ", - "@audioFileSelected": { - "description": "Message when audio file is selected." - }, - "lyricsFileSelected": "ਲਿਰਿਕਸ ਫਾਈਲ ਚੁਣੀ ਗਈ", - "@lyricsFileSelected": { - "description": "Message when lyrics file is selected." - }, - "fillAllRequiredFields": "ਸਾਰੇ ਲਾਜ਼ਮੀ ਖੇਤਰ ਭਰੋ", - "@fillAllRequiredFields": { - "description": "Prompt to fill all required fields." - }, - "scheduled": "ਨਿਰਧਾਰਤ", - "@scheduled": { - "description": "Label for scheduled status." - }, - "ok": "ਠੀਕ ਹੈ", - "@ok": { - "description": "Button text for OK." - }, - "roomDescriptionOptional": "ਰੂਮ ਵਰਣਨ ਵਿਕਲਪਿਕ ਹੈ", - "@roomDescriptionOptional": { - "description": "Message that room description is optional." - }, - "deleteAccount": "ਅਕਾਊਂਟ ਹਟਾਓ", - "@deleteAccount": { - "description": "Button text to delete account." - }, - "createYourStory": "ਆਪਣੀ ਕਹਾਣੀ ਬਣਾਓ", - "@createYourStory": { - "description": "Button text to create your story." - }, - "titleRequired": "ਸਿਰਲੇਖ ਲਾਜ਼ਮੀ ਹੈ", - "@titleRequired": { - "description": "Message that title is required." - }, - "category": "ਸ਼੍ਰੇਣੀ", - "@category": { - "description": "Label for category field." - }, - "addChapter": "ਅਧਿਆਇ ਜੋੜੋ", - "@addChapter": { - "description": "Button text to add chapter." - }, - "createStory": "ਕਹਾਣੀ ਬਣਾਓ", - "@createStory": { - "description": "Button text to create story." - }, - "fillAllRequiredFieldsAndChapter": "ਸਾਰੇ ਲਾਜ਼ਮੀ ਖੇਤਰ ਅਤੇ ਅਧਿਆਇ ਭਰੋ", - "@fillAllRequiredFieldsAndChapter": { - "description": "Prompt to fill all required fields and chapter." - }, - "toConfirmType": "ਪੁਸ਼ਟੀ ਕਰਨ ਲਈ ਟਾਈਪ ਕਰੋ", - "@toConfirmType": { - "description": "Prompt to type for confirmation." - }, - "inTheBoxBelow": "ਹੇਠਾਂ ਵਾਲੇ ਬਾਕਸ ਵਿੱਚ", - "@inTheBoxBelow": { - "description": "Instruction to type in the box below." - }, - "iUnderstandDeleteMyAccount": "ਮੈਂ ਸਮਝਦਾ ਹਾਂ ਕਿ ਮੇਰਾ ਅਕਾਊਂਟ ਹਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ", - "@iUnderstandDeleteMyAccount": { - "description": "Confirmation text for account deletion." - }, - "whatDoYouWantToListenTo": "ਤੁਸੀਂ ਕੀ ਸੁਣਨਾ ਚਾਹੁੰਦੇ ਹੋ?", - "@whatDoYouWantToListenTo": { - "description": "Prompt asking what user wants to listen to." - }, - "categories": "ਸ਼੍ਰੇਣੀਆਂ", - "@categories": { - "description": "Label for categories." - }, - "stories": "ਕਹਾਣੀਆਂ", - "@stories": { - "description": "Label for stories." - }, - "someSuggestions": "ਕੁਝ ਸੁਝਾਵ", - "@someSuggestions": { - "description": "Label for some suggestions." - }, - "getStarted": "ਸ਼ੁਰੂ ਕਰੋ", - "@getStarted": { - "description": "Button text to get started." - }, - "skip": "ਛੱਡੋ", - "@skip": { - "description": "Button text to skip." - }, - "welcomeToResonate": "ਰੇਜ਼ੋਨੇਟ ਵਿੱਚ ਤੁਹਾਡਾ ਸੁਆਗਤ ਹੈ", - "@welcomeToResonate": { - "description": "Welcome message for Resonate." - }, - "exploreDiverseConversations": "ਵੱਖ-ਵੱਖ ਗੱਲਬਾਤਾਂ ਦੀ ਖੋਜ ਕਰੋ", - "@exploreDiverseConversations": { - "description": "Prompt to explore diverse conversations." - }, - "yourVoiceMatters": "ਤੁਹਾਡੀ ਆਵਾਜ਼ ਮਹੱਤਵਪੂਰਨ ਹੈ", - "@yourVoiceMatters": { - "description": "Message that your voice matters." - }, - "joinConversationExploreRooms": "ਗੱਲਬਾਤ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ, ਰੂਮ ਖੋਜੋ", - "@joinConversationExploreRooms": { - "description": "Prompt to join conversation and explore rooms." - }, - "diveIntoDiverseDiscussions": "ਵੱਖ-ਵੱਖ ਚਰਚਾਵਾਂ ਵਿੱਚ ਡੁੱਬੋ", - "@diveIntoDiverseDiscussions": { - "description": "Prompt to dive into diverse discussions." - }, - "atResonateEveryVoiceValued": "ਰੇਜ਼ੋਨੇਟ 'ਤੇ ਹਰ ਆवਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ", - "@atResonateEveryVoiceValued": { - "description": "Message that every voice is valued at Resonate." - }, - "notifications": "ਨੋਟੀਫਿਕੇਸ਼ਨ", - "@notifications": { - "description": "Label for notifications." - }, - "taggedYouInUpcomingRoom": "{username} ਨੇ ਤੁਹਾਨੂੰ ਇੱਕ ਆਉਣ ਵਾਲੇ ਰੂਮ ਵਿੱਚ ਟੈਗ ਕੀਤਾ: {subject}", - "@taggedYouInUpcomingRoom": { - "description": "Notification for being tagged in upcoming room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "taggedYouInRoom": "{username} ਨੇ ਤੁਹਾਨੂੰ ਇੱਕ ਰੂਮ ਵਿੱਚ ਟੈਗ ਕੀਤਾ: {subject}", - "@taggedYouInRoom": { - "description": "Notification for being tagged in room.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "Discussion Room" - } - } - }, - "likedYourStory": "{username} ਨੇ ਤੁਹਾਡੀ ਕਹਾਣੀ ਨੂੰ ਪਸੰਦ ਕੀਤਾ: {subject}", - "@likedYourStory": { - "description": "Notification for liked story.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Story" - } - } - }, - "subscribedToYourRoom": "{username} ਨੇ ਤੁਹਾਡੇ ਰੂਮ ਨੂੰ ਸਬਸਕ੍ਰਾਈਬ ਕੀਤਾ: {subject}", - "@subscribedToYourRoom": { - "description": "Notification for room subscription.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - }, - "subject": { - "type": "String", - "example": "My Room" - } - } - }, - "startedFollowingYou": "{username} ਨੇ ਤੁਹਾਨੂੰ ਫਾਲੋ ਕਰਨਾ ਸ਼ੁਰੂ ਕੀਤਾ", - "@startedFollowingYou": { - "description": "Notification for started following you.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "youHaveNewNotification": "ਤੁਹਾਨੂੰ ਨਵੀਂ ਨੋਟੀਫਿਕੇਸ਼ਨ ਮਿਲੀ ਹੈ", - "@youHaveNewNotification": { - "description": "Message for new notification." - }, - "hangOnGoodThingsTakeTime": "ਇੰਤਜ਼ਾਰ ਕਰੋ, ਚੰਗੀਆਂ ਚੀਜ਼ਾਂ ਸਮਾਂ ਲੈਂਦੀਆਂ ਹਨ", - "@hangOnGoodThingsTakeTime": { - "description": "Message to hang on, good things take time." - }, - "resonateOpenSourceProject": "ਰੇਜ਼ੋਨੇਟ ਖੁੱਲਾ ਸਰੋਤ ਪ੍ਰੋਜੈਕਟ ਹੈ", - "@resonateOpenSourceProject": { - "description": "Message that Resonate is open source project." - }, - "mute": "ਮਿਊਟ", - "@mute": { - "description": "Button text to mute." - }, - "speakerLabel": "ਸਪੀਕਰ", - "@speakerLabel": { - "description": "Label for speaker." - }, - "audioOptions": "ਆਡੀਓ ਵਿਕਲਪ", - "@audioOptions": { - "description": "Label for the audio options/settings button." - }, - "end": "ਅੰਤ", - "@end": { - "description": "Label for end." - }, - "saveChanges": "ਤਬਦੀਲੀਆਂ ਸੰਭਾਲੋ", - "@saveChanges": { - "description": "Button text to save changes." - }, - "discard": "ਅਣਡਿੱਠਾ ਕਰੋ", - "@discard": { - "description": "Button text to discard changes." - }, - "save": "ਸੰਭਾਲੋ", - "@save": { - "description": "Button text to save." - }, - "changeProfilePicture": "ਪ੍ਰੋਫਾਈਲ ਚਿੱਤਰ ਬਦਲੋ", - "@changeProfilePicture": { - "description": "Button text to change profile picture." - }, - "camera": "ਕੈਮਰਾ", - "@camera": { - "description": "Label for camera." - }, - "gallery": "ਗੈਲਰੀ", - "@gallery": { - "description": "Label for gallery." - }, - "remove": "ਹਟਾਓ", - "@remove": { - "description": "Button text to remove something." - }, - "created": "ਬਣਾਇਆ ਗਿਆ: {date}", - "@created": { - "description": "Label for created.", - "placeholders": { - "date": { - "type": "String", - "example": "Jan 15, 2023" - } - } - }, - "chapters": "ਅਧਿਆਇ", - "@chapters": { - "description": "Label for chapters." - }, - "deleteStory": "ਕਹਾਣੀ ਹਟਾਓ", - "@deleteStory": { - "description": "Button text to delete story." - }, - "createdBy": "{creatorName} ਦੁਆਰਾ ਬਣਾਇਆ ਗਿਆ", - "@createdBy": { - "description": "Label for created by.", - "placeholders": { - "creatorName": { - "type": "String", - "example": "John Doe" - } - } - }, - "start": "ਸ਼ੁਰੂ ਕਰੋ", - "@start": { - "description": "Button text to start something." - }, - "unsubscribe": "ਅਣ-ਸਬਸਕ੍ਰਾਈਬ ਕਰੋ", - "@unsubscribe": { - "description": "Button text to unsubscribe." - }, - "subscribe": "ਸਬਸਕ੍ਰਾਈਬ ਕਰੋ", - "@subscribe": { - "description": "Button text to subscribe." - }, - "storyCategory": "{category, select, drama{ਨਾਟਕ} comedy{ਹਾਸਿਆਸਪਦ} horror{ਭਿਆਨਕ} romance{ਰੋਮਾਂਟਿਕ} thriller{ਥ੍ਰਿਲਰ} spiritual{ਆਧਿਆਤਮਿਕ} other{ਹੋਰ}}", - "@storyCategory": { - "description": "Selects the appropriate category name for a story based on a key.", - "placeholders": { - "category": { - "type": "String", - "example": "drama" - } - } - }, - "chooseTheme": "ਥੀਮ ਚੁਣੋ", - "@chooseTheme": { - "description": "Prompt to choose theme." - }, - "minutesAgo": "{count, plural, =1{1 ਮਿੰਟ ਪਹਿਲਾਂ} other{{count} ਮਿੰਟ ਪਹਿਲਾਂ}}", - "@minutesAgo": { - "description": "Label for minutes ago.", - "placeholders": { - "count": { - "type": "int", - "example": "5" - } - } - }, - "hoursAgo": "{count, plural, =1{1 ਘੰਟਾ ਪਹਿਲਾਂ} other{{count} ਘੰਟੇ ਪਹਿਲਾਂ}}", - "@hoursAgo": { - "description": "Label for hours ago.", - "placeholders": { - "count": { - "type": "int", - "example": "2" - } - } - }, - "daysAgo": "{count, plural, =1{1 ਦਿਨ ਪਹਿਲਾਂ} other{{count} ਦਿਨ ਪਹਿਲਾਂ}}", - "@daysAgo": { - "description": "Label for days ago.", - "placeholders": { - "count": { - "type": "int", - "example": "3" - } - } - }, - "by": "ਦੁਆਰਾ", - "@by": { - "description": "Label for by." - }, - "likes": "ਪਸੰਦ", - "@likes": { - "description": "Label for likes." - }, - "lengthMinutes": "ਮਿੰਟ ਲੰਬਾਈ", - "@lengthMinutes": { - "description": "Label for length in minutes." - }, - "requiredField": "ਲਾਜ਼ਮੀ ਖੇਤਰ", - "@requiredField": { - "description": "Label for required field." - }, - "onlineUsers": "ਆਨਲਾਈਨ ਯੂਜ਼ਰ", - "@onlineUsers": { - "description": "Label for online users." - }, - "noOnlineUsers": "ਕੋਈ ਆਨਲਾਈਨ ਯੂਜ਼ਰ ਨਹੀਂ", - "@noOnlineUsers": { - "description": "Message when no online users." - }, - "chooseUser": "ਯੂਜ਼ਰ ਚੁਣੋ", - "@chooseUser": { - "description": "Prompt to choose user." - }, - "quickMatch": "ਤੁਰੰਤ ਮੇਲ", - "@quickMatch": { - "description": "Label for quick match feature." - }, - "story": "ਕਹਾਣੀ", - "@story": { - "description": "Label for story." - }, - "user": "ਯੂਜ਼ਰ", - "@user": { - "description": "Label for user." - }, - "following": "ਫਾਲੋ ਕਰ ਰਹੇ ਹੋ", - "@following": { - "description": "Label for following." - }, - "followers": "ਫਾਲੋਅਰ", - "@followers": { - "description": "Label for followers." - }, - "friendRequests": "ਦੋਸਤ ਅਰਜ਼ੀਆਂ", - "@friendRequests": { - "description": "Label for friend requests." - }, - "friendRequestSent": "ਦੋਸਤ ਅਰਜ਼ੀ ਭੇਜੀ ਗਈ", - "@friendRequestSent": { - "description": "Message when friend request is sent." - }, - "friendRequestSentTo": "ਤੁਹਾਡੀ ਦੋਸਤ ਅਰਜ਼ੀ {username} ਨੂੰ ਭੇਜੀ ਗਈ ਹੈ", - "@friendRequestSentTo": { - "description": "Message when friend request is sent to someone.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestCancelled": "ਦੋਸਤ ਅਰਜ਼ੀ ਰੱਦ ਕੀਤੀ ਗਈ", - "@friendRequestCancelled": { - "description": "Message when friend request is cancelled." - }, - "friendRequestCancelledTo": "ਤੁਹਾਡੀ {username} ਨੂੰ ਭੇਜੀ ਦੋਸਤ ਅਰਜ਼ੀ ਰੱਦ ਕਰ ਦਿੱਤੀ ਗਈ ਹੈ", - "@friendRequestCancelledTo": { - "description": "Message when friend request is cancelled to someone.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "requested": "ਅਰਜ਼ੀ ਕੀਤੀ", - "@requested": { - "description": "Label for requested status." - }, - "friends": "ਦੋਸਤ", - "@friends": { - "description": "Label for friends." - }, - "addFriend": "ਦੋਸਤ ਜੋੜੋ", - "@addFriend": { - "description": "Button text to add friend." - }, - "friendRequestAccepted": "ਦੋਸਤ ਅਰਜ਼ੀ ਮਨਜ਼ੂਰ ਹੋਈ", - "@friendRequestAccepted": { - "description": "Message when friend request is accepted." - }, - "friendRequestAcceptedTo": "ਤੁਸੀਂ {username} ਦੀ ਦੋਸਤ ਅਰਜ਼ੀ ਮਨਜ਼ੂਰ ਕਰ ਲਈ ਹੈ", - "@friendRequestAcceptedTo": { - "description": "Message when friend request is accepted to someone.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "friendRequestDeclined": "ਦੋਸਤ ਅਰਜ਼ੀ ਅਸਵੀਕਾਰ ਕੀਤੀ ਗਈ", - "@friendRequestDeclined": { - "description": "Message when friend request is declined." - }, - "friendRequestDeclinedTo": "ਤੁਸੀਂ {username} ਦੀ ਦੋਸਤ ਅਰਜ਼ੀ ਅਸਵੀਕਾਰ ਕਰ ਦਿੱਤੀ ਹੈ", - "@friendRequestDeclinedTo": { - "description": "Message when friend request is declined to someone.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "accept": "ਮਨਜ਼ੂਰ ਕਰੋ", - "@accept": { - "description": "Button text to accept." - }, - "callDeclined": "ਕਾਲ ਅਸਵੀਕਾਰ ਕੀਤੀ ਗਈ", - "@callDeclined": { - "description": "Message when call is declined." - }, - "callDeclinedTo": "{username} ਨੇ ਕਾਲ ਅਸਵੀਕਾਰ ਕਰ ਦਿੱਤੀ", - "@callDeclinedTo": { - "description": "Message when call is declined to someone.", - "placeholders": { - "username": { - "type": "String", - "example": "john_doe" - } - } - }, - "checkForUpdates": "ਅੱਪਡੇਟ ਲਈ ਚੈੱਕ ਕਰੋ", - "@checkForUpdates": { - "description": "Button text to check for updates." - }, - "updateNow": "ਹੁਣ ਅੱਪਡੇਟ ਕਰੋ", - "@updateNow": { - "description": "Button text to update now." - }, - "updateLater": "ਬਾਅਦ ਵਿੱਚ ਅੱਪਡੇਟ ਕਰੋ", - "@updateLater": { - "description": "Button text to update later." - }, - "updateSuccessful": "ਅੱਪਡੇਟ ਸਫਲ", - "@updateSuccessful": { - "description": "Message when update is successful." - }, - "updateSuccessfulMessage": "ਅੱਪਡੇਟ ਸਫਲਤਾਪੂਰਵਕ ਹੋ ਗਿਆ", - "@updateSuccessfulMessage": { - "description": "Message for successful update." - }, - "updateCancelled": "ਅੱਪਡੇਟ ਰੱਦ ਕੀਤਾ ਗਿਆ", - "@updateCancelled": { - "description": "Message when update is cancelled." - }, - "updateCancelledMessage": "ਅੱਪਡੇਟ ਰੱਦ ਹੋ ਗਿਆ", - "@updateCancelledMessage": { - "description": "Message for cancelled update." - }, - "updateFailed": "ਅੱਪਡੇਟ ਅਸਫਲ", - "@updateFailed": { - "description": "Message when update fails." - }, - "updateFailedMessage": "ਅੱਪਡੇਟ ਅਸਫਲ ਹੋ ਗਿਆ", - "@updateFailedMessage": { - "description": "Message for failed update." - }, - "updateError": "ਅੱਪਡੇਟ ਵਿੱਚ ਗਲਤੀ", - "@updateError": { - "description": "Error message for update." - }, - "updateErrorMessage": "ਅੱਪਡੇਟ ਵਿੱਚ ਗਲਤੀ ਆਈ", - "@updateErrorMessage": { - "description": "Message for update error." - }, - "platformNotSupported": "ਪਲੇਟਫਾਰਮ ਸਹਾਇਕ ਨਹੀਂ ਹੈ", - "@platformNotSupported": { - "description": "Message when platform is not supported." - }, - "platformNotSupportedMessage": "ਇਹ ਪਲੇਟਫਾਰਮ ਸਹਾਇਕ ਨਹੀਂ ਹੈ", - "@platformNotSupportedMessage": { - "description": "Message for unsupported platform." - }, - "updateCheckFailed": "ਅੱਪਡੇਟ ਚੈੱਕ ਅਸਫਲ", - "@updateCheckFailed": { - "description": "Message when update check fails." - }, - "updateCheckFailedMessage": "ਅੱਪਡੇਟ ਚੈੱਕ ਕਰਨ ਵਿੱਚ ਅਸਫਲਤਾ", - "@updateCheckFailedMessage": { - "description": "Message for update check failure." - }, - "upToDateTitle": "ਅੱਪ-ਟੂ-ਡੇਟ", - "@upToDateTitle": { - "description": "Title for up to date status." - }, - "upToDateMessage": "ਐਪ ਅੱਪ-ਟੂ-ਡੇਟ ਹੈ", - "@upToDateMessage": { - "description": "Message for up to date app." - }, - "updateAvailableTitle": "ਅੱਪਡੇਟ ਉਪਲਬਧ", - "@updateAvailableTitle": { - "description": "Title for update available." - }, - "updateAvailableMessage": "ਨਵਾਂ ਅੱਪਡੇਟ ਉਪਲਬਧ ਹੈ", - "@updateAvailableMessage": { - "description": "Message for update available." - }, - "updateFeaturesImprovement": "ਅੱਪਡੇਟ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਅਤੇ ਸੁਧਾਰ", - "@updateFeaturesImprovement": { - "description": "Label for update features and improvements." - }, - "failedToRemoveRoom": "ਰੂਮ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", - "@failedToRemoveRoom": { - "description": "Message when failed to remove room." - }, - "roomRemovedSuccessfully": "ਰੂਮ ਸਫਲਤਾਪੂਰਵਕ ਹਟਾਇਆ ਗਿਆ", - "@roomRemovedSuccessfully": { - "description": "Message when room is removed successfully." - }, - "alert": "ਚੇਤਾਵਨੀ", - "@alert": { - "description": "Label for alert." - }, - "removedFromRoom": "ਰੂਮ ਤੋਂ ਹਟਾਇਆ ਗਿਆ", - "@removedFromRoom": { - "description": "Message when removed from room." - }, - "reportType": "{type, select, harassment{ਪਰੇਸ਼ਾਨੀ / ਨਫ਼ਰਤ ਭਰਿਆ ਭਾਸ਼ਣ} abuse{ਅਪਮਾਨਜਨਕ ਸਮੱਗਰੀ / ਹਿੰਸਾ} spam{ਸਪੈਮ / ਘੁਟਾਲੇ / ਧੋਖਾਧੜੀ} impersonation{ਰੂਪ ਬਦਲਣਾ / ਜਾਅਲੀ ਖਾਤੇ} illegal{ਗੈਰ-ਕਾਨੂੰਨੀ ਗਤੀਵਿਧੀਆਂ} selfharm{ਆਤਮ-ਹੱਤਿਆ / ਖੁਦਕੁਸ਼ੀ / ਮਾਨਸਿਕ ਸਿਹਤ} misuse{ਪਲੇਟਫਾਰਮ ਦੀ ਦੁਰਵਰਤੋਂ} other{ਹੋਰ}}", - "@reportType": { - "description": "Label for report type.", - "placeholders": { - "type": { - "type": "String", - "example": "harassment" - } - } - }, - "userBlockedFromResonate": "ਰੇਜ਼ੋਨੇਟ ਤੋਂ ਯੂਜ਼ਰ ਬਲੌਕ ਕੀਤਾ ਗਿਆ", - "@userBlockedFromResonate": { - "description": "Message when user is blocked from Resonate." - }, - "reportParticipant": "ਭਾਗੀਦਾਰ ਦੀ ਰਿਪੋਰਟ ਕਰੋ", - "@reportParticipant": { - "description": "Button text to report participant." - }, - "selectReportType": "ਰਿਪੋਰਟ ਕਿਸਮ ਚੁਣੋ", - "@selectReportType": { - "description": "Prompt to select report type." - }, - "reportSubmitted": "ਰਿਪੋਰਟ ਜਮ੍ਹਾਂ ਹੋਈ", - "@reportSubmitted": { - "description": "Message when report is submitted." - }, - "reportFailed": "ਰਿਪੋਰਟ ਅਸਫਲ", - "@reportFailed": { - "description": "Message when report fails." - }, - "additionalDetailsOptional": "ਵਾਧੂ ਵੇਰਵੇ ਵਿਕਲਪਿਕ ਹਨ", - "@additionalDetailsOptional": { - "description": "Message that additional details are optional." - }, - "submitReport": "ਰਿਪੋਰਟ ਜਮ੍ਹਾਂ ਕਰੋ", - "@submitReport": { - "description": "Button text to submit report." - }, - "actionBlocked": "ਕਾਰਵਾਈ ਰੋਕੀ ਗਈ", - "@actionBlocked": { - "description": "Message when action is blocked." - }, - "cannotStopRecording": "ਰਿਕਾਰਡਿੰਗ ਰੋਕੀ ਨਹੀਂ ਜਾ ਸਕਦੀ", - "@cannotStopRecording": { - "description": "Message when recording cannot be stopped." - }, - "liveChapter": "ਲਾਈਵ ਅਧਿਆਇ", - "@liveChapter": { - "description": "Label for live chapter." - }, - "viewOrEditLyrics": "ਲਿਰਿਕਸ ਵੇਖੋ ਜਾਂ ਐਡਿਟ ਕਰੋ", - "@viewOrEditLyrics": { - "description": "Button text to view or edit lyrics." - }, - "close": "ਬੰਦ ਕਰੋ", - "@close": { - "description": "Button text to close something." - }, - "verifyChapterDetails": "ਅਧਿਆਇ ਵੇਰਵੇ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ", - "@verifyChapterDetails": { - "description": "Prompt to verify chapter details." - }, - "author": "ਲੇਖਕ", - "@author": { - "description": "Label for author." - }, - "startLiveChapter": "ਲਾਈਵ ਅਧਿਆਇ ਸ਼ੁਰੂ ਕਰੋ", - "@startLiveChapter": { - "description": "Button text to start live chapter." - }, - "fillAllFields": "ਸਾਰੇ ਖੇਤਰ ਭਰੋ", - "@fillAllFields": { - "description": "Prompt to fill all fields." - }, - "noRecordingError": "ਤੁਸੀਂ ਇਸ ਅਧਿਆਇ ਲਈ ਕੁਝ ਵੀ ਰਿਕਾਰਡ ਨਹੀਂ ਕੀਤਾ। ਕਿਰਪਾ ਕਰਕੇ ਰੂਮ ਬੰਦ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਰਿਕਾਰਡ ਕਰੋ", - "@noRecordingError": { - "description": "Message when there is no recording error." - }, - "audioOutput": "ਆਡੀਓ ਆਊਟਪੁੱਟ", - "@audioOutput": { - "description": "Title for audio output device selector." - }, - "selectPreferredSpeaker": "ਆਪਣਾ ਪਸੰਦੀਦਾ ਸਪੀਕਰ ਚੁਣੋ", - "@selectPreferredSpeaker": { - "description": "Subtitle for audio device selector dialog." - }, - "noAudioOutputDevices": "ਕੋਈ ਆਡੀਓ ਆਊਟਪੁੱਟ ਡਿਵਾਈਸ ਨਹੀਂ ਮਿਲੀ", - "@noAudioOutputDevices": { - "description": "Message shown when no audio output devices are available." - }, - "refresh": "ਰਿਫ੍ਰੈਸ਼ ਕਰੋ", - "@refresh": { - "description": "Button text to refresh audio device list." - }, - "done": "ਹੋ ਗਿਆ", - "@done": { - "description": "Button text to close audio device selector." - }, - "noFriendsYet": "ਅਜੇ ਕੋਈ ਦੋਸਤ ਨਹੀਂ", - "@noFriendsYet": { - "description": "Title shown when user has no friends." - }, - "noFriendsDescription": "ਤੁਹਾਡੀ ਦੋਸਤਾਂ ਦੀ ਸੂਚੀ ਖਾਲੀ ਹੈ। ਲੋਕਾਂ ਨਾਲ ਜੁੜਨਾ ਸ਼ੁਰੂ ਕਰੋ ਅਤੇ ਆਪਣਾ ਨੈੱਟਵਰਕ ਵਧਾਓ!", - "@noFriendsDescription": { - "description": "Description shown when user has no friends." - }, - "findFriends": "ਦੋਸਤ ਲੱਭੋ", - "@findFriends": { - "description": "Button text to navigate to find friends screen." - }, - "inviteFriend": "ਦੋਸਤ ਨੂੰ ਸੱਦਾ ਦਿਓ", - "@inviteFriend": { - "description": "Button text to invite friends to the app." - }, - "noFriendRequestsYet": "ਕੋਈ ਦੋਸਤੀ ਬੇਨਤੀ ਨਹੀਂ", - "@noFriendRequestsYet": { - "description": "Title shown when user has no friend requests." - }, - "noFriendRequestsDescription": "ਤੁਹਾਡੇ ਕੋਲ ਕੋਈ ਬਕਾਇਆ ਦੋਸਤੀ ਬੇਨਤੀ ਨਹੀਂ ਹੈ। ਜੁੜਨ ਲਈ ਆਪਣੇ ਦੋਸਤਾਂ ਨੂੰ ਸੱਦਾ ਦਿਓ!", - "@noFriendRequestsDescription": { - "description": "Description shown when user has no friend requests." - }, - "inviteToResonate": "ਹੈਲੋ! ਰੇਜ਼ੋਨੇਟ 'ਤੇ ਮੇਰੇ ਨਾਲ ਜੁੜੋ - ਇੱਕ ਸੋਸ਼ਲ ਆਡੀਓ ਪਲੇਟਫਾਰਮ ਜਿੱਥੇ ਹਰ ਆਵਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਹੁਣੇ ਡਾਊਨਲੋਡ ਕਰੋ: {url}", - "@inviteToResonate": { - "description": "Text used when inviting friends to the app.", - "placeholders": { - "url": { - "type": "String" - } - } - }, - "usernameInvalidFormat": "ਯੂਜ਼ਰਨੇਮ ਵਿੱਚ ਸਿਰਫ਼ ਅੱਖਰ, ਨੰਬਰ ਅਤੇ ਅੰਡਰਸਕੋਰ ਹੋ ਸਕਦੇ ਹਨ", - "@usernameInvalidFormat": { - "description": "Error message for invalid username format." - }, - "usernameAlreadyTaken": "ਇਹ ਯੂਜ਼ਰਨੇਮ ਪਹਿਲਾਂ ਹੀ ਲਿਆ ਗਿਆ ਹੈ", - "@usernameAlreadyTaken": { - "description": "Error message when username is already taken." - } -} \ No newline at end of file +{ + "@@locale": "pa", + "title": "ਰੇਜ਼ੋਨੇਟ", + "@title": { + "description": "The title of the application." + }, + "roomDescription": "ਵਿਨਮ੍ਰ ਰਹੋ ਅਤੇ ਦੂਜੇ ਵਿਅਕਤੀ ਦੀ ਰਾਏ ਦਾ ਆਦਰ ਕਰੋ। ਅਪਮਾਨਜਨਕ ਟਿੱਪਣੀਆਂ ਤੋਂ ਬਚੋ।", + "@roomDescription": { + "description": "Guideline message for users in a chat room." + }, + "hidePassword": "ਪਾਸਵਰਡ ਲੁਕਾਓ", + "@hidePassword": { + "description": "Button text to conceal the password field." + }, + "showPassword": "ਪਾਸਵਰਡ ਦਿਖਾਓ", + "@showPassword": { + "description": "Button text to reveal the password field." + }, + "passwordEmpty": "ਪਾਸਵਰਡ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ", + "@passwordEmpty": { + "description": "Error message when the password field is left blank." + }, + "password": "ਪਾਸਵਰਡ", + "@password": { + "description": "Label for the password input field." + }, + "confirmPassword": "ਪਾਸਵਰਡ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ", + "@confirmPassword": { + "description": "Label for the confirm password input field." + }, + "passwordsNotMatch": "ਪਾਸਵਰਡ ਮਿਲਦੇ ਨਹੀਂ ਹਨ", + "@passwordsNotMatch": { + "description": "Error message when password and confirmation password do not match." + }, + "userCreatedStories": "ਯੂਜ਼ਰ ਵਲੋਂ ਬਣਾਈਆਂ ਕਹਾਣੀਆਂ", + "@userCreatedStories": { + "description": "Header for the section showing stories created by another user." + }, + "yourStories": "ਤੁਹਾਡੀਆਂ ਕਹਾਣੀਆਂ", + "@yourStories": { + "description": "Header for the section showing stories created by the current user." + }, + "userNoStories": "ਯੂਜ਼ਰ ਨੇ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ ਬਣਾਈ", + "@userNoStories": { + "description": "Message displayed when viewing another user's profile and they have no stories." + }, + "youNoStories": "ਤੁਸੀਂ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ ਬਣਾਈ", + "@youNoStories": { + "description": "Message displayed on the current user's profile when they have no stories." + }, + "follow": "ਫਾਲो ਕਰੋ", + "@follow": { + "description": "Button text to follow a user." + }, + "editProfile": "ਪ੍ਰੋਫਾਈਲ ਐਡਿਟ ਕਰੋ", + "@editProfile": { + "description": "Button text to navigate to the profile editing screen." + }, + "verifyEmail": "ਈਮੇਲ ਵੇਰੀਫਾਈ ਕਰੋ", + "@verifyEmail": { + "description": "Button or link text to start the email verification process." + }, + "verified": "ਵੇਰੀਫਾਈਡ", + "@verified": { + "description": "A status label indicating that the user's email has been verified." + }, + "profile": "ਪ੍ਰੋਫਾਈਲ", + "@profile": { + "description": "Label for the user profile section or page." + }, + "userLikedStories": "ਯੂਜ਼ਰ ਨੂੰ ਪਸੰਦ ਆਈਆਂ ਕਹਾਣੀਆਂ", + "@userLikedStories": { + "description": "Header for the section showing stories liked by another user." + }, + "yourLikedStories": "ਤੁਹਾਨੂੰ ਪਸੰਦ ਆਈਆਂ ਕਹਾਣੀਆਂ", + "@yourLikedStories": { + "description": "Header for the section showing stories liked by the current user." + }, + "userNoLikedStories": "ਯੂਜ਼ਰ ਨੇ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਲਾਈਕ ਨਹੀਂ ਕੀਤੀ", + "@userNoLikedStories": { + "description": "Message displayed when viewing another user's profile and they have no liked stories." + }, + "youNoLikedStories": "ਤੁਸੀਂ ਹਾਲੇ ਕੋਈ ਕਹਾਣੀ ਲਾਈਕ ਨਹੀਂ ਕੀਤੀ", + "@youNoLikedStories": { + "description": "Message displayed on the current user's profile when they have no liked stories." + }, + "live": "ਲਾਈਵ", + "@live": { + "description": "Tab or label for live audio rooms." + }, + "upcoming": "ਆਉਣ ਵਾਲਾ", + "@upcoming": { + "description": "Tab or label for scheduled/upcoming audio rooms." + }, + "noAvailableRoom": "{isRoom, select, true{ਕੋਈ ਰੂਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ} false{ਕੋਈ ਆਉਣ ਵਾਲਾ ਰੂਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ} other{ਰੂਮ ਦੀ ਜਾਣਕਾਰੀ ਉਪਲਬਧ ਨਹੀਂ ਹੈ}}\nਹੇਠਾਂ ਤੋਂ ਇੱਕ ਰੂਮ ਬਣਾਕੇ ਸ਼ੁਰੂਆਤ ਕਰੋ!", + "@noAvailableRoom": { + "description": "Message shown when no rooms are available. Uses ICU select for room type. The placeholder is 'isRoom'.", + "placeholders": { + "isRoom": { + "type": "String", + "example": "true" + } + } + }, + "user1": "ਯੂਜ਼ਰ 1", + "@user1": { + "description": "A generic placeholder name for a user." + }, + "user2": "ਯੂਜ਼ਰ 2", + "@user2": { + "description": "A second generic placeholder name for a user." + }, + "you": "ਤੁਸੀਂ", + "@you": { + "description": "Label to identify the current user in a list or chat." + }, + "areYouSure": "ਕੀ ਤੁਸੀਂ ਪੱਕੇ ਹੋ?", + "@areYouSure": { + "description": "Confirmation prompt title." + }, + "loggingOut": "ਤੁਸੀਂ ਰੇਜ਼ੋਨੇਟ ਤੋਂ ਲੌਗ ਆਉਟ ਹੋ ਰਹੇ ਹੋ।", + "@loggingOut": { + "description": "Confirmation prompt message for logging out." + }, + "yes": "ਹਾਂ", + "@yes": { + "description": "Generic confirmation button text." + }, + "no": "ਨਹੀਂ", + "@no": { + "description": "Generic cancellation button text." + }, + "incorrectEmailOrPassword": "ਈਮੇਲ ਜਾਂ ਪਾਸਵਰਡ ਗਲਤ ਹੈ", + "@incorrectEmailOrPassword": { + "description": "Error message for failed login attempt." + }, + "passwordShort": "ਪਾਸਵਰਡ 8 ਅੱਖਰ ਤੋਂ ਛੋਟਾ ਹੈ", + "@passwordShort": { + "description": "Error message for a password that is too short." + }, + "tryAgain": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ!", + "@tryAgain": { + "description": "Button text to retry a failed action." + }, + "success": "ਸਫਲਤਾ", + "@success": { + "description": "Generic title for a successful operation." + }, + "passwordResetSent": "ਪਾਸਵਰਡ ਰੀਸੈਟ ਈਮੇਲ ਭੇਜੀ ਗਈ!", + "@passwordResetSent": { + "description": "Success message after a password reset email has been dispatched." + }, + "error": "ਗਲਤੀ", + "@error": { + "description": "Generic title for a failed operation." + }, + "resetPassword": "ਪਾਸਵਰਡ ਰੀਸੈਟ ਕਰੋ", + "@resetPassword": { + "description": "Title or button text for the reset password feature." + }, + "enterNewPassword": "ਨਵਾਂ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ", + "@enterNewPassword": { + "description": "Instructional text on the reset password screen." + }, + "newPassword": "ਨਵਾਂ ਪਾਸਵਰਡ", + "@newPassword": { + "description": "Label for the new password input field." + }, + "setNewPassword": "ਨਵਾਂ ਪਾਸਵਰਡ ਸੈੱਟ ਕਰੋ", + "@setNewPassword": { + "description": "Button text to confirm setting a new password." + }, + "emailChanged": "ਈਮੇਲ ਬਦਲ ਦਿੱਤੀ ਗਈ", + "@emailChanged": { + "description": "Title for the success dialog after an email change." + }, + "emailChangeSuccess": "ਈਮੇਲ ਸਫਲਤਾਪੂਰਵਕ ਬਦਲ ਗਈ!", + "@emailChangeSuccess": { + "description": "Success message after changing the user's email." + }, + "failed": "ਅਸਫਲ", + "@failed": { + "description": "Generic title for a failed operation." + }, + "emailChangeFailed": "ਈਮੇਲ ਬਦਲਣ ਵਿੱਚ ਅਸਫਲ", + "@emailChangeFailed": { + "description": "Error message when an email change operation fails." + }, + "oops": "ਉਫ਼!", + "@oops": { + "description": "An informal title for an error or warning message." + }, + "emailExists": "ਈਮੇਲ ਪਹਿਲਾਂ ਤੋਂ ਮੌਜੂਦ ਹੈ", + "@emailExists": { + "description": "Error message when a user tries to register or change to an email that is already in use." + }, + "changeEmail": "ਈਮੇਲ ਬਦਲੋ", + "@changeEmail": { + "description": "Title or button text for the change email feature." + }, + "enterValidEmail": "ਕਿਰਪਾ ਕਰਕੇ ਇੱਕ ਵੈਧ ਈਮੇਲ ਦਾਖਲ ਕਰੋ", + "@enterValidEmail": { + "description": "Error message for an invalid email format." + }, + "newEmail": "ਨਵੀਂ ਈਮੇਲ", + "@newEmail": { + "description": "Label for the new email input field." + }, + "currentPassword": "ਮੌਜੂਦਾ ਪਾਸਵਰਡ", + "@currentPassword": { + "description": "Label for the current password input field, used for verification." + }, + "emailChangeInfo": "ਸੁਰੱਖਿਆ ਲਈ, ਈਮੇਲ ਬਦਲਣ ਲਈ ਮੌਜੂਦਾ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ। ਬਦਲਣ ਤੋਂ ਬਾਅਦ, ਨਵੀਂ ਈਮੇਲ ਨਾਲ ਲੌਗਿਨ ਕਰੋ।", + "@emailChangeInfo": { + "description": "Informational text explaining the process and security requirements for changing an email address for standard users." + }, + "oauthUsersMessage": "(ਕੇਵਲ ਗੂਗਲ ਜਾਂ ਗਿਥੁਬ ਨਾਲ ਲੌਗਿਨ ਕਰਨ ਵਾਲੇ ਯੂਜ਼ਰਾਂ ਲਈ)", + "@oauthUsersMessage": { + "description": "A message to specify that the following instructions are for OAuth users." + }, + "oauthUsersEmailChangeInfo": "ਈਮੇਲ ਬਦਲਣ ਲਈ, \"ਮੌਜੂਦਾ ਪਾਸਵਰਡ\" ਫੀਲਡ ਵਿੱਚ ਨਵਾਂ ਪਾਸਵਰਡ ਦਾਖਲ ਕਰੋ। ਇਸ ਨੂੰ ਯਾਦ ਰੱਖੋ—ਅੱਗੇ ਕੇਵਲ ਗੂਗਲ/ਗਿਥੁਬ ਜਾਂ ਨਵੇਂ ਪਾਸਵਰਡ ਨਾਲ ਲੌਗਿਨ ਹੋਵੇਗਾ।", + "@oauthUsersEmailChangeInfo": { + "description": "Informational text explaining how users who signed up via Google or GitHub can change their email by setting a new password." + }, + "resonateTagline": "ਗੱਲਾਂ ਦੀ ਇੱਕ ਅਨੰਤ ਦੁਨੀਆ ਵਿੱਚ ਕਦਮ ਰੱਖੋ", + "@resonateTagline": { + "description": "The application's tagline, used on splash or login screens." + }, + "signInWithEmail": "ਈਮੇਲ ਨਾਲ ਸਾਈਨ ਇਨ ਕਰੋ", + "@signInWithEmail": { + "description": "Button text for signing in using email and password." + }, + "or": "ਜਾਂ", + "@or": { + "description": "A separator text, typically used between different login options." + }, + "continueWith": "ਇਨ੍ਹਾਂ ਵਿੱਚੋਂ ਕਿਸੇ ਇੱਕ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", + "@continueWith": { + "description": "Informational text preceding a list of third-party login providers." + }, + "continueWithGoogle": "ਗੂਗਲ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", + "@continueWithGoogle": { + "description": "Button text for signing in with a Google account." + }, + "continueWithGitHub": "ਗਿਥੁਬ ਨਾਲ ਲੌਗਿਨ ਕਰੋ", + "@continueWithGitHub": { + "description": "Button text for signing in with a GitHub account." + }, + "resonateLogo": "ਰੇਜ਼ੋਨੇਟ ਲੋਗੋ", + "@resonateLogo": { + "description": "Accessibility text for the Resonate application logo image." + }, + "iAlreadyHaveAnAccount": "ਮੇਰੇ ਕੋਲ ਪਹਿਲਾਂ ਤੋਂ ਇੱਕ ਅਕਾਊਂਟ ਹੈ", + "@iAlreadyHaveAnAccount": { + "description": "Text for a link or button to navigate to the login screen from the sign-up screen." + }, + "createNewAccount": "ਨਵਾਂ ਅਕਾਊਂਟ ਬਣਾਓ", + "@createNewAccount": { + "description": "Text for a link or button to navigate to the sign-up screen from the login screen." + }, + "userProfile": "ਯੂਜ਼ਰ ਪ੍ਰੋਫਾਈਲ", + "@userProfile": { + "description": "Accessibility text for a user's profile picture or avatar." + }, + "passwordIsStrong": "ਪਾਸਵਰਡ ਮਜ਼ਬੂਤ ਹੈ", + "@passwordIsStrong": { + "description": "Validation message indicating that the entered password meets all strength requirements." + }, + "admin": "ਐਡਮਿਨ", + "@admin": { + "description": "Label for the Admin user role in a room." + }, + "moderator": "ਮੋਡਰੇਟਰ", + "@moderator": { + "description": "Label for the Moderator user role in a room." + }, + "speaker": "ਸਪੀਕਰ", + "@speaker": { + "description": "Label for the Speaker user role in a room." + }, + "listener": "ਲਿਸਨਰ", + "@listener": { + "description": "Label for the Listener user role in a room." + }, + "removeModerator": "ਮੋਡਰੇਟਰ ਹਟਾਓ", + "@removeModerator": { + "description": "Menu item text to revoke moderator privileges from a user." + }, + "kickOut": "ਕਿਕ ਆਉਟ ਕਰੋ", + "@kickOut": { + "description": "Menu item text to remove a user from a room." + }, + "addModerator": "ਮੋਡਰੇਟਰ ਜੋੜੋ", + "@addModerator": { + "description": "Menu item text to grant moderator privileges to a user." + }, + "addSpeaker": "ਸਪੀਕਰ ਜੋੜੋ", + "@addSpeaker": { + "description": "Menu item text to grant speaker privileges to a listener." + }, + "makeListener": "ਲਿਸਨਰ ਬਣਾਓ", + "@makeListener": { + "description": "Menu item text to change a speaker's role to listener." + }, + "pairChat": "ਪੇਅਰ ਚੈਟ", + "@pairChat": { + "description": "A feature name for one-on-one random chat." + }, + "chooseIdentity": "ਪਛਾਣ ਚੁਣੋ", + "@chooseIdentity": { + "description": "Prompt for the user to choose their identity, e.g., anonymous or public." + }, + "selectLanguage": "ਭਾਸ਼ਾ ਚੁਣੋ", + "@selectLanguage": { + "description": "Label for the language selection setting." + }, + "noConnection": "ਕੋਈ ਕਨੈਕਸ਼ਨ ਨਹੀਂ", + "@noConnection": { + "description": "Title indicating that there is no internet connection." + }, + "loadingDialog": "ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ...", + "@loadingDialog": { + "description": "Accessibility text for a loading indicator or spinner." + }, + "createAccount": "ਅਕਾਊਂਟ ਬਣਾਓ", + "@createAccount": { + "description": "Button text or page title for the account creation screen." + }, + "enterValidEmailAddress": "ਵੈਧ ਈਮੇਲ ਪਤਾ ਦਾਖਲ ਕਰੋ", + "@enterValidEmailAddress": { + "description": "Error message shown for an invalid email format." + }, + "email": "ਈਮੇਲ", + "@email": { + "description": "Label for the email input field." + }, + "passwordRequirements": "ਪਾਸਵਰਡ ਘੱਟੋ-ਘੱਟ 8 ਅੱਖਰ ਦਾ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ", + "@passwordRequirements": { + "description": "Instructional text listing the requirements for a valid password, part 1." + }, + "includeNumericDigit": "ਘੱਟੋ-ਘੱਟ 1 ਸੰਖਿਆ ਸ਼ਾਮਲ ਕਰੋ", + "@includeNumericDigit": { + "description": "Instructional text listing the requirements for a valid password, part 2." + }, + "includeUppercase": "ਘੱਟੋ-ਘੱਟ 1 ਵੱਡਾ ਅੱਖਰ ਸ਼ਾਮਲ ਕਰੋ", + "@includeUppercase": { + "description": "Instructional text listing the requirements for a valid password, part 3." + }, + "includeLowercase": "ਘੱਟੋ-ਘੱਟ 1 ਛੋਟਾ ਅੱਖਰ ਸ਼ਾਮਲ ਕਰੋ", + "@includeLowercase": { + "description": "Instructional text listing the requirements for a valid password, part 4." + }, + "includeSymbol": "ਘੱਟੋ-ਘੱਟ 1 ਚਿੰਨ੍ਹ ਸ਼ਾਮਲ ਕਰੋ", + "@includeSymbol": { + "description": "Instructional text listing the requirements for a valid password, part 5." + }, + "signedUpSuccessfully": "ਸਫਲਤਾਪੂਰਵਕ ਸਾਈਨ ਅੱਪ ਹੋ ਗਿਆ!", + "@signedUpSuccessfully": { + "description": "Title for a success message after account creation." + }, + "newAccountCreated": "ਤੁਹਾਡਾ ਨਵਾਂ ਅਕਾਊਂਟ ਸਫਲਤਾਪੂਰਵਕ ਬਣ ਗਿਆ ਹੈ", + "@newAccountCreated": { + "description": "Success message confirming that a new account has been created." + }, + "signUp": "ਸਾਈਨ ਅੱਪ", + "@signUp": { + "description": "Button text to submit the sign-up form." + }, + "login": "ਲੌਗਿਨ", + "@login": { + "description": "Button text to submit the login form." + }, + "settings": "ਸੈਟਿੰਗਜ਼", + "@settings": { + "description": "Title for the settings page." + }, + "accountSettings": "ਅਕਾਊਂਟ ਸੈਟਿੰਗਜ਼", + "@accountSettings": { + "description": "Sub-header for account-related settings." + }, + "account": "ਅਕਾਊਂਟ", + "@account": { + "description": "Label for the account settings section." + }, + "appSettings": "ਐਪ ਸੈਟਿੰਗਜ਼", + "@appSettings": { + "description": "Sub-header for application-related settings." + }, + "themes": "ਥੀਮਜ਼", + "@themes": { + "description": "Label for the theme selection setting." + }, + "about": "ਰੇਜ਼ੋਨੇਟ ਬਾਰੇ", + "@about": { + "description": "Label for the 'About' section or page of the app or a story." + }, + "other": "ਹੋਰ", + "@other": { + "description": "A generic category label for miscellaneous settings or items." + }, + "contribute": "ਯੋਗਦਾਨ ਕਰੋ", + "@contribute": { + "description": "Label for a section encouraging users to contribute to the project." + }, + "appPreferences": "ਐਪ ਪਸੰਦ", + "@appPreferences": { + "description": "Label for the app preferences settings page." + }, + "transcriptionModel": "ਟ੍ਰਾਂਸਕ੍ਰਿਪਸ਼ਨ ਮਾਡਲ", + "@transcriptionModel": { + "description": "Section title for choosing AI transcription model." + }, + "transcriptionModelDescription": "ਵਾਇਸ ਟ੍ਰਾਂਸਕ੍ਰਿਪਸ਼ਨ ਲਈ AI ਮਾਡਲ ਚੁਣੋ। ਵੱਡੇ ਮਾਡਲ ਵਧੇਰੇ ਸਟੀਕ ਹਨ ਪਰ ਹੌਲੇ ਹਨ ਅਤੇ ਵਧੇਰੇ ਸਟੋਰੇਜ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ।", + "@transcriptionModelDescription": { + "description": "Description text explaining transcription model choices." + }, + "whisperModelTiny": "ਟਾਈਨੀ", + "@whisperModelTiny": { + "description": "Name of the smallest Whisper AI model." + }, + "whisperModelTinyDescription": "ਸਭ ਤੋਂ ਤੇਜ਼, ਘੱਟ ਸਟੀਕ (~39 MB)", + "@whisperModelTinyDescription": { + "description": "Description of the Tiny Whisper model performance and size." + }, + "whisperModelBase": "ਬੇਸ", + "@whisperModelBase": { + "description": "Name of the base Whisper AI model." + }, + "whisperModelBaseDescription": "ਸੰਤੁਲਿਤ ਗਤੀ ਅਤੇ ਸਟੀਕਤਾ (~74 MB)", + "@whisperModelBaseDescription": { + "description": "Description of the Base Whisper model performance and size." + }, + "whisperModelSmall": "ਸਮਾਲ", + "@whisperModelSmall": { + "description": "Name of the small Whisper AI model." + }, + "whisperModelSmallDescription": "ਚੰਗੀ ਸਟੀਕਤਾ, ਹੌਲਾ (~244 MB)", + "@whisperModelSmallDescription": { + "description": "Description of the Small Whisper model performance and size." + }, + "whisperModelMedium": "ਮੀਡੀਅਮ", + "@whisperModelMedium": { + "description": "Name of the medium Whisper AI model." + }, + "whisperModelMediumDescription": "ਉੱਚ ਸਟੀਕਤਾ, ਹੌਲਾ (~769 MB)", + "@whisperModelMediumDescription": { + "description": "Description of the Medium Whisper model performance and size." + }, + "whisperModelLargeV1": "ਲਾਰਜ V1", + "@whisperModelLargeV1": { + "description": "Name of the large V1 Whisper AI model." + }, + "whisperModelLargeV1Description": "ਸਭ ਤੋਂ ਵਧੇਰੇ ਸਟੀਕ, ਸਭ ਤੋਂ ਹੌਲਾ (~1.55 GB)", + "@whisperModelLargeV1Description": { + "description": "Description of the Large V1 Whisper model performance and size." + }, + "whisperModelLargeV2": "ਲਾਰਜ V2", + "@whisperModelLargeV2": { + "description": "Name of the large V2 Whisper AI model." + }, + "whisperModelLargeV2Description": "ਉੱਚ ਸਟੀਕਤਾ ਨਾਲ ਵਧੀਆ ਵੱਡਾ ਮਾਡਲ (~1.55 GB)", + "@whisperModelLargeV2Description": { + "description": "Description of the Large V2 Whisper model performance and size." + }, + "modelDownloadInfo": "ਮਾਡਲ ਪਹਿਲੀ ਵਾਰ ਵਰਤਣ 'ਤੇ ਡਾਊਨਲੋਡ ਹੋ ਜਾਂਦੇ ਹਨ। ਅਸੀਂ ਬੇਸ, ਸਮਾਲ ਜਾਂ ਮੀਡੀਅਮ ਵਰਤਣ ਦੀ ਸਿਫਾਰਸ਼ ਕਰਦੇ ਹਾਂ। ਵੱਡੇ ਮਾਡਲ ਲਈ ਬਹੁਤ ਉੱਚ-ਅੰਤ ਡਿਵਾਈਸ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ।", + "@modelDownloadInfo": { + "description": "Information message about model download." + }, + "logOut": "ਲੌਗ ਆਉਟ", + "@logOut": { + "description": "Button text to log the user out of their account." + }, + "participants": "ਭਾਗੀਦਾਰ", + "@participants": { + "description": "Label or title for the list of participants in a room." + }, + "delete": "ਡਿਲੀਟ", + "@delete": { + "description": "Generic button text for a delete action. Parameter for toRoomAction." + }, + "leave": "ਛੱਡੋ", + "@leave": { + "description": "Generic button text for a leave action. Parameter for toRoomAction." + }, + "leaveButton": "ਛੱਡੋ", + "@leaveButton": { + "description": "Button text to leave a room or conversation." + }, + "findingRandomPartner": "ਤੁਹਾਡੇ ਲਈ ਇੱਕ ਰੈਂਡਮ ਭਾਗੀਦਾਰ ਲੱਭ ਰਹੇ ਹਾਂ", + "@findingRandomPartner": { + "description": "Status message shown while the system is searching for a random chat partner." + }, + "quickFact": "ਤੁਰੰਤ ਤੱਥ", + "@quickFact": { + "description": "Header for a quick fact or tip, often shown during loading." + }, + "cancel": "ਕੈਂਸਲ", + "@cancel": { + "description": "Generic button text for a cancel action." + }, + "completeYourProfile": "ਆਪਣੀ ਪ੍ਰੋਫਾਈਲ ਪੂਰੀ ਕਰੋ", + "@completeYourProfile": { + "description": "Page title or prompt for the user to finish setting up their profile." + }, + "uploadProfilePicture": "ਪ੍ਰੋਫਾਈਲ ਪਿਕਚਰ ਅੱਪਲੋਡ ਕਰੋ", + "@uploadProfilePicture": { + "description": "Instructional text or button to upload a profile picture." + }, + "enterValidName": "ਵੈਧ ਨਾਮ ਦਾਖਲ ਕਰੋ", + "@enterValidName": { + "description": "Error message for an invalid name format." + }, + "name": "ਨਾਮ", + "@name": { + "description": "Label for the name input field." + }, + "username": "ਯੂਜ਼ਰਨੇਮ", + "@username": { + "description": "Label for the username input field." + }, + "enterValidDOB": "ਠੀक ਜਨਮ ਤਾਰੀਖ ਦਾਖਲ ਕਰੋ", + "@enterValidDOB": { + "description": "Error message for an invalid date of birth." + }, + "dateOfBirth": "ਜਨਮ ਤਾਰੀਖ", + "@dateOfBirth": { + "description": "Label for the date of birth input field." + }, + "forgotPassword": "ਪਾਸਵਰਡ ਭੁੱਲ ਗਏ?", + "@forgotPassword": { + "description": "Link text for users who have forgotten their password." + }, + "next": "ਅੱਗੇ", + "@next": { + "description": "Button text to proceed to the next step in a process." + }, + "noStoriesExist": "ਕੋਈ ਕਹਾਣੀ ਹਾਲੇ ਮੌਜੂਦ ਨਹੀਂ ਹੈ", + "@noStoriesExist": { + "description": "Message displayed when there are no stories available in a list." + }, + "enterVerificationCode": "ਆਪਣਾ\nਵੇਰੀਫਿਕੇਸ਼ਨ ਕੋਡ ਦਾਖਲ ਕਰੋ", + "@enterVerificationCode": { + "description": "Prompt for the user to enter the verification code sent to their email." + }, + "verificationCodeSent": "ਅਸੀਂ 6-ਅੰਕਾਂ ਦਾ ਕੋਡ ਭੇਜਿਆ ਹੈ\n", + "@verificationCodeSent": { + "description": "Message informing the user that a verification code has been sent. The email address is appended in the code." + }, + "verificationComplete": "ਵੇਰੀਫਿਕੇਸ਼ਨ ਪੂਰਾ ਹੋ ਗਿਆ", + "@verificationComplete": { + "description": "Title for a success message after email verification." + }, + "verificationCompleteMessage": "ਵਧਾਈ ਹੋ! ਤੁਹਾਡੀ ਈਮੇਲ ਵੇਰੀਫਾਈ ਹੋ ਗਈ ਹੈ", + "@verificationCompleteMessage": { + "description": "Success message confirming email verification." + }, + "verificationFailed": "ਵੇਰੀਫਿਕੇਸ਼ਨ ਅਸਫਲ ਹੋ ਗਿਆ", + "@verificationFailed": { + "description": "Title for an error message when verification fails." + }, + "otpMismatch": "OTP ਮਿਲਦਾ ਨਹੀਂ, ਕਿਰਪਾ ਕਰਕੇ ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ", + "@otpMismatch": { + "description": "Error message when the entered OTP is incorrect." + }, + "otpResent": "OTP ਮੁੜ ਭੇਜਿਆ ਗਿਆ", + "@otpResent": { + "description": "Confirmation message that the OTP has been resent." + }, + "requestNewCode": "ਨਵਾਂ ਕੋਡ ਮੰਗੋ", + "@requestNewCode": { + "description": "Button text to request a new verification code." + }, + "requestNewCodeIn": "ਨਵਾਂ ਕੋਡ ਮੰਗਣ ਲਈ ਉਡੀਕ ਕਰੋ", + "@requestNewCodeIn": { + "description": "Text displayed before a countdown timer for requesting a new code." + }, + "clickPictureCamera": "ਕੈਮਰਾ ਨਾਲ ਫੋਟੋ ਲਓ", + "@clickPictureCamera": { + "description": "Option to take a new photo using the device camera." + }, + "pickImageGallery": "ਗੈਲਰੀ ਤੋਂ ਚਿੱਤਰ ਚੁਣੋ", + "@pickImageGallery": { + "description": "Option to choose an existing image from the device gallery." + }, + "deleteMessageTitle": "ਸੁਨੇਹਾ ਹਟਾਓ", + "@deleteMessageTitle": { + "description": "Title shown in the delete message confirmation dialog." + }, + "deleteMessageContent": "ਕੀ ਤੁਸੀਂ ਯਕੀਨਨ ਇਸ ਸੁਨੇਹੇ ਨੂੰ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?", + "@deleteMessageContent": { + "description": "Confirmation text asking the user if they want to delete a message." + }, + "thisMessageWasDeleted": "ਇਹ ਸੁਨੇਹਾ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ", + "@thisMessageWasDeleted": { + "description": "Text shown in place of a deleted message." + }, + "failedToDeleteMessage": "ਸੁਨੇਹਾ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", + "@failedToDeleteMessage": { + "description": "Error message shown when a message deletion fails." + }, + "hide": "ਲੁਕਾਓ", + "@hide": { + "description": "Button text to hide something." + }, + "removeRoom": "ਰੂਮ ਹਟਾਓ", + "@removeRoom": { + "description": "Button text to remove a room." + }, + "removeRoomFromList": "ਲਿਸਟ ਤੋਂ ਰੂਮ ਹਟਾਓ", + "@removeRoomFromList": { + "description": "Button text to remove a room from the list." + }, + "removeRoomConfirmation": "ਕੀ ਤੁਸੀਂ ਇਹ ਰੂਮ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?", + "@removeRoomConfirmation": { + "description": "Confirmation text for removing a room." + }, + "deleteMyAccount": "ਮੇਰਾ ਅਕਾਊਂਟ ਹਟਾਓ", + "@deleteMyAccount": { + "description": "Button text to delete user's account." + }, + "createNewRoom": "ਨਵਾਂ ਰੂਮ ਬਣਾਓ", + "@createNewRoom": { + "description": "Button text to create a new room." + }, + "pleaseEnterScheduledDateTime": "ਕਿਰਪਾ ਕਰਕੇ ਨਿਰਧਾਰਤ ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ ਦਾਖਲ ਕਰੋ", + "@pleaseEnterScheduledDateTime": { + "description": "Prompt to enter scheduled date and time." + }, + "scheduleDateTimeLabel": "ਨਿਰਧਾਰਤ ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ", + "@scheduleDateTimeLabel": { + "description": "Label for scheduled date and time field." + }, + "enterTags": "ਟੈਗ ਦਾਖਲ ਕਰੋ", + "@enterTags": { + "description": "Prompt to enter tags." + }, + "joinCommunity": "ਕਮਿਊਨਿਟੀ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", + "@joinCommunity": { + "description": "Button text to join the community." + }, + "followUsOnX": "X 'ਤੇ ਸਾਡਾ ਪਾਲਣਾ ਕਰੋ", + "@followUsOnX": { + "description": "Prompt to follow on X (Twitter)." + }, + "joinDiscordServer": "Discord ਸਰਵਰ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", + "@joinDiscordServer": { + "description": "Button text to join Discord server." + }, + "noLyrics": "ਕੋਈ ਲਿਰਿਕਸ ਨਹੀਂ", + "@noLyrics": { + "description": "Message when no lyrics are available." + }, + "noStoriesInCategory": "{categoryName} ਸ਼੍ਰੇਣੀ ਵਿੱਚ ਕੋਈ ਕਹਾਣੀ ਨਹੀਂ", + "@noStoriesInCategory": { + "description": "Message when no stories exist in a category.", + "placeholders": { + "categoryName": { + "type": "String", + "example": "drama" + } + } + }, + "newChapters": "ਨਵੇਂ ਅਧਿਆਇ", + "@newChapters": { + "description": "Label for new chapters." + }, + "helpToGrow": "ਵਧਣ ਵਿੱਚ ਮਦਦ ਕਰੋ", + "@helpToGrow": { + "description": "Prompt to help the app grow." + }, + "share": "ਸਾਂਝਾ ਕਰੋ", + "@share": { + "description": "Button text to share something." + }, + "rate": "ਰੇਟ ਕਰੋ", + "@rate": { + "description": "Button text to rate the app." + }, + "aboutResonate": "ਰੇਜ਼ੋਨੇਟ ਬਾਰੇ", + "@aboutResonate": { + "description": "About Resonate section." + }, + "description": "ਵਰਣਨ", + "@description": { + "description": "Label for description field." + }, + "confirm": "ਪੁਸ਼ਟੀ ਕਰੋ", + "@confirm": { + "description": "Button text to confirm an action." + }, + "classic": "ਕਲਾਸਿਕ", + "@classic": { + "description": "Classic theme label." + }, + "time": "ਸਮਾਂ", + "@time": { + "description": "Label for time field." + }, + "vintage": "ਵਿੰਟੇਜ", + "@vintage": { + "description": "Vintage theme label." + }, + "amber": "ਐਂਬਰ", + "@amber": { + "description": "Amber theme label." + }, + "forest": "ਫਾਰਸਟ", + "@forest": { + "description": "Forest theme label." + }, + "cream": "ਕਰੀਮ", + "@cream": { + "description": "Cream theme label." + }, + "none": "ਕੋਈ ਨਹੀਂ", + "@none": { + "description": "None option label." + }, + "checkOutGitHub": "ਸਾਡਾ GitHub ਰੈਪੋ ਦੇਖੋ: {url}", + "@checkOutGitHub": { + "description": "Prompt to check out GitHub.", + "placeholders": { + "url": { + "type": "String", + "example": "https://github.com/AOSSIE-Org/Resonate" + } + } + }, + "aossie": "AOSSIE", + "@aossie": { + "description": "AOSSIE organization label." + }, + "aossieLogo": "AOSSIE ਲੋਗੋ", + "@aossieLogo": { + "description": "Accessibility text for AOSSIE logo." + }, + "errorLoadPackageInfo": "ਪੈਕੇਜ ਜਾਣਕਾਰੀ ਲੋਡ ਕਰਨ ਵਿੱਚ ਗਲਤੀ", + "@errorLoadPackageInfo": { + "description": "Error loading package info message." + }, + "searchFailed": "ਖੋਜ ਅਸਫਲ ਰਹੀ", + "@searchFailed": { + "description": "Message when search fails." + }, + "updateAvailable": "ਅੱਪਡੇਟ ਉਪਲਬਧ ਹੈ", + "@updateAvailable": { + "description": "Message when update is available." + }, + "newVersionAvailable": "ਨਵਾਂ ਵਰਜਨ ਉਪਲਬਧ ਹੈ", + "@newVersionAvailable": { + "description": "Message when a new version is available." + }, + "upToDate": "ਅੱਪ-ਟੂ-ਡੇਟ", + "@upToDate": { + "description": "Message when app is up to date." + }, + "latestVersion": "ਨਵਾਂ ਵਰਜਨ", + "@latestVersion": { + "description": "Label for latest version." + }, + "profileCreatedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਈ ਗਈ", + "@profileCreatedSuccessfully": { + "description": "Message when profile is created successfully." + }, + "invalidScheduledDateTime": "ਅਵੈਧ ਨਿਰਧਾਰਤ ਤਾਰੀਖ/ਸਮਾਂ", + "@invalidScheduledDateTime": { + "description": "Error for invalid scheduled date/time." + }, + "scheduledDateTimePast": "ਨਿਰਧਾਰਤ ਤਾਰੀਖ/ਸਮਾਂ ਪਿਛਲੇ ਸਮੇਂ ਵਿੱਚ ਹੈ", + "@scheduledDateTimePast": { + "description": "Error for scheduled date/time in the past." + }, + "joinRoom": "ਰੂਮ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", + "@joinRoom": { + "description": "Button text to join a room." + }, + "unknownUser": "ਅਣਜਾਣ ਯੂਜ਼ਰ", + "@unknownUser": { + "description": "Label for unknown user." + }, + "canceled": "ਰੱਦ ਕੀਤਾ ਗਿਆ", + "@canceled": { + "description": "Label for canceled action." + }, + "english": "ਅੰਗਰੇਜ਼ੀ", + "@english": { + "description": "Label for English language." + }, + "emailVerificationRequired": "ਈਮੇਲ ਵੈਰੀਫਿਕੇਸ਼ਨ ਲਾਜ਼ਮੀ ਹੈ", + "@emailVerificationRequired": { + "description": "Message that email verification is required." + }, + "verify": "ਵੇਰੀਫਾਈ ਕਰੋ", + "@verify": { + "description": "Button text to verify something." + }, + "audioRoom": "ਆਡੀਓ ਰੂਮ", + "@audioRoom": { + "description": "Label for audio room." + }, + "toRoomAction": "ਰੂਮ ਨੂੰ {action} ਕਰਨ ਲਈ", + "@toRoomAction": { + "description": "Label for room action parameter.", + "placeholders": { + "action": { + "type": "String", + "example": "delete" + } + } + }, + "mailSentMessage": "ਮੇਲ ਭੇਜੀ ਗਈ", + "@mailSentMessage": { + "description": "Message that mail has been sent." + }, + "disconnected": "ਡਿਸਕਨੈਕਟ ਕੀਤਾ ਗਿਆ", + "@disconnected": { + "description": "Message for disconnected state." + }, + "micOn": "ਮਾਈਕ ਚਾਲੂ", + "@micOn": { + "description": "Label for mic on state." + }, + "speakerOn": "ਸਪੀਕਰ ਚਾਲੂ", + "@speakerOn": { + "description": "Label for speaker on state." + }, + "endChat": "ਚੈਟ ਖਤਮ ਕਰੋ", + "@endChat": { + "description": "Button text to end chat." + }, + "monthJan": "ਜਨਵਰੀ", + "@monthJan": { + "description": "Label for January." + }, + "monthFeb": "ਫਰਵਰੀ", + "@monthFeb": { + "description": "Label for February." + }, + "monthMar": "ਮਾਰਚ", + "@monthMar": { + "description": "Label for March." + }, + "monthApr": "ਅਪ੍ਰੈਲ", + "@monthApr": { + "description": "Label for April." + }, + "monthMay": "ਮਈ", + "@monthMay": { + "description": "Label for May." + }, + "monthJun": "ਜੂਨ", + "@monthJun": { + "description": "Label for June." + }, + "monthJul": "ਜੁਲਾਈ", + "@monthJul": { + "description": "Label for July." + }, + "monthAug": "ਅਗਸਤ", + "@monthAug": { + "description": "Label for August." + }, + "monthSep": "ਸਤੰਬਰ", + "@monthSep": { + "description": "Label for September." + }, + "monthOct": "ਅਕਤੂਬਰ", + "@monthOct": { + "description": "Label for October." + }, + "monthNov": "ਨਵੰਬਰ", + "@monthNov": { + "description": "Label for November." + }, + "monthDec": "ਦਸੰਬਰ", + "@monthDec": { + "description": "Label for December." + }, + "register": "ਰਜਿਸਟਰ ਕਰੋ", + "@register": { + "description": "Button text to register." + }, + "newToResonate": "ਰੇਜ਼ੋਨੇਟ ਲਈ ਨਵੇਂ ਹੋ?", + "@newToResonate": { + "description": "Prompt for new users to Resonate." + }, + "alreadyHaveAccount": "ਪਹਿਲਾਂ ਤੋਂ ਅਕਾਊਂਟ ਹੈ?", + "@alreadyHaveAccount": { + "description": "Prompt for users who already have an account." + }, + "checking": "ਚੈੱਕ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ...", + "@checking": { + "description": "Message for checking/loading state." + }, + "forgotPasswordMessage": "ਪਾਸਵਰਡ ਭੁੱਲ ਗਏ?", + "@forgotPasswordMessage": { + "description": "Message for forgot password prompt." + }, + "usernameUnavailable": "ਯੂਜ਼ਰਨੇਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ", + "@usernameUnavailable": { + "description": "Message when username is unavailable." + }, + "usernameInvalidOrTaken": "ਯੂਜ਼ਰਨੇਮ ਗਲਤ ਜਾਂ ਪਹਿਲਾਂ ਤੋਂ ਲਿਆ ਹੋਇਆ ਹੈ", + "@usernameInvalidOrTaken": { + "description": "Message when username is invalid or taken." + }, + "otpResentMessage": "OTP ਮੁੜ ਭੇਜਿਆ ਗਿਆ", + "@otpResentMessage": { + "description": "Message when OTP is resent." + }, + "connectionError": "ਕਨੈਕਸ਼ਨ ਵਿੱਚ ਗਲਤੀ", + "@connectionError": { + "description": "Message for connection error." + }, + "seconds": "ਸਕਿੰਟ", + "@seconds": { + "description": "Label for seconds unit." + }, + "unsavedChangesWarning": "ਅਸੁਰੱਖਿਅਤ ਤਬਦੀਲੀਆਂ ਚੇਤਾਵਨੀ", + "@unsavedChangesWarning": { + "description": "Warning for unsaved changes." + }, + "deleteAccountPermanent": "ਅਕਾਊਂਟ ਸਥਾਈ ਤੌਰ 'ਤੇ ਹਟਾਓ", + "@deleteAccountPermanent": { + "description": "Prompt for permanent account deletion." + }, + "giveGreatName": "ਵਧੀਆ ਨਾਮ ਦਿਓ", + "@giveGreatName": { + "description": "Prompt to give a great name." + }, + "joinCommunityDescription": "ਰੇਜ਼ੋਨੇਟ ਕਮਿਊਨਿਟੀ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ ਅਤੇ ਵਧੋ!", + "@joinCommunityDescription": { + "description": "Description for joining the community." + }, + "resonateDescription": "ਰੇਜ਼ੋਨੇਟ ਇੱਕ ਸੋਸ਼ਲ ਮੀਡੀਆ ਪਲੇਟਫਾਰਮ ਹੈ, ਜਿੱਥੇ ਹਰ ਆਵਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਆਪਣੇ ਵਿਚਾਰ, ਕਹਾਣੀਆਂ ਅਤੇ ਤਜ਼ਰਬੇ ਹੋਰਾਂ ਨਾਲ ਸਾਂਝੇ ਕਰੋ। ਆਪਣੀ ਆਡੀਓ ਯਾਤਰਾ ਹੁਣ ਸ਼ੁਰੂ ਕਰੋ। ਵੱਖ-ਵੱਖ ਚਰਚਾਵਾਂ ਅਤੇ ਵਿਸ਼ਿਆਂ ਵਿੱਚ ਡੁੱਬੋ। ਉਹ ਰੂਮ ਲੱਭੋ ਜੋ ਤੁਹਾਡੇ ਨਾਲ ਗੂੰਝਦੇ ਹਨ ਅਤੇ ਕਮਿਊਨਿਟੀ ਦਾ ਹਿੱਸਾ ਬਣੋ। ਗੱਲਬਾਤ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ! ਰੂਮ ਖੋਜੋ, ਦੋਸਤਾਂ ਨਾਲ ਜੁੜੋ, ਅਤੇ ਦੁਨੀਆਂ ਨਾਲ ਆਪਣੀ ਆਵਾਜ਼ ਸਾਂਝੀ ਕਰੋ।", + "@resonateDescription": { + "description": "Description about Resonate." + }, + "resonateFullDescription": "ਰੇਜ਼ੋਨੇਟ - ਗੱਲਾਂ ਦੀ ਦੁਨੀਆ!", + "@resonateFullDescription": { + "description": "Full description about Resonate." + }, + "stable": "ਸਥਿਰ", + "@stable": { + "description": "Label for stable version." + }, + "usernameCharacterLimit": "ਯੂਜ਼ਰਨੇਮ ਅੱਖਰ ਸੀਮਾ", + "@usernameCharacterLimit": { + "description": "Label for username character limit." + }, + "submit": "ਸਬਮਿਟ ਕਰੋ", + "@submit": { + "description": "Button text to submit." + }, + "anonymous": "ਅਗਿਆਤ", + "@anonymous": { + "description": "Label for anonymous user." + }, + "noSearchResults": "ਕੋਈ ਖੋਜ ਨਤੀਜੇ ਨਹੀਂ", + "@noSearchResults": { + "description": "Message when no search results found." + }, + "searchRooms": "ਰੂਮ ਖੋਜੋ", + "@searchRooms": { + "description": "Prompt to search rooms." + }, + "searchingRooms": "ਰੂਮ ਖੋਜੇ ਜਾ ਰਹੇ ਹਨ...", + "@searchingRooms": { + "description": "Message for searching rooms." + }, + "clearSearch": "ਖੋਜ ਸਾਫ਼ ਕਰੋ", + "@clearSearch": { + "description": "Button text to clear search." + }, + "searchError": "ਖੋਜ ਵਿੱਚ ਗਲਤੀ", + "@searchError": { + "description": "Error message for search." + }, + "searchRoomsError": "ਰੂਮ ਖੋਜਣ ਵਿੱਚ ਗਲਤੀ", + "@searchRoomsError": { + "description": "Message when searching rooms fails." + }, + "searchUpcomingRoomsError": "ਆਉਣ ਵਾਲੇ ਰੂਮ ਖੋਜਣ ਵਿੱਚ ਗਲਤੀ", + "@searchUpcomingRoomsError": { + "description": "Message when searching upcoming rooms fails." + }, + "search": "ਖੋਜੋ", + "@search": { + "description": "Button text to search." + }, + "clear": "ਸਾਫ਼ ਕਰੋ", + "@clear": { + "description": "Button text to clear something." + }, + "shareRoomMessage": "ਇਸ ਸ਼ਾਨਦਾਰ ਰੂਮ ਨੂੰ ਦੇਖੋ: {roomName}!\n\n📖 ਵੇਰਵਾ: {description}\n👥 ਹੁਣੇ {participants} ਲੋਕ ਜੁੜ ਚੁੱਕੇ ਹਨ!", + "@shareRoomMessage": { + "description": "Message to share a room.", + "placeholders": { + "roomName": { + "type": "String", + "example": "Discussion Room" + }, + "description": { + "type": "String", + "example": "A great place to discuss" + }, + "participants": { + "type": "int", + "example": "5" + } + } + }, + "participantsCount": "{count} ਭਾਗੀਦਾਰ", + "@participantsCount": { + "description": "Label for participants count.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "join": "ਸ਼ਾਮਲ ਹੋਵੋ", + "@join": { + "description": "Button text to join." + }, + "invalidTags": "ਅਵੈਧ ਟੈਗ", + "@invalidTags": { + "description": "Error for invalid tags." + }, + "cropImage": "ਚਿੱਤਰ ਕੱਟੋ", + "@cropImage": { + "description": "Button text to crop image." + }, + "profileSavedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਸੰਭਾਲੀ ਗਈ", + "@profileSavedSuccessfully": { + "description": "Message when profile is saved successfully." + }, + "profileUpdatedSuccessfully": "ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਅੱਪਡੇਟ ਹੋਈ", + "@profileUpdatedSuccessfully": { + "description": "Message when profile is updated successfully." + }, + "profileUpToDate": "ਪ੍ਰੋਫਾਈਲ ਅੱਪ-ਟੂ-ਡੇਟ ਹੈ", + "@profileUpToDate": { + "description": "Message when profile is up to date." + }, + "noChangesToSave": "ਸੰਭਾਲਣ ਲਈ ਕੋਈ ਤਬਦੀਲੀਆਂ ਨਹੀਂ", + "@noChangesToSave": { + "description": "Message when there are no changes to save." + }, + "connectionFailed": "ਕਨੈਕਸ਼ਨ ਅਸਫਲ", + "@connectionFailed": { + "description": "Message when connection fails." + }, + "unableToJoinRoom": "ਰੂਮ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਣ ਵਿੱਚ ਅਸਮਰਥ", + "@unableToJoinRoom": { + "description": "Message when unable to join room." + }, + "connectionLost": "ਕਨੈਕਸ਼ਨ ਖਤਮ ਹੋ ਗਿਆ", + "@connectionLost": { + "description": "Message when connection is lost." + }, + "unableToReconnect": "ਮੁੜ-ਕਨੈਕਟ ਕਰਨ ਵਿੱਚ ਅਸਮਰਥ", + "@unableToReconnect": { + "description": "Message when unable to reconnect." + }, + "invalidFormat": "ਅਵੈਧ ਫਾਰਮੈਟ", + "@invalidFormat": { + "description": "Error for invalid format." + }, + "usernameAlphanumeric": "ਯੂਜ਼ਰਨੇਮ ਅਲਫਾ-ਨਿਊਮੈਰਿਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ", + "@usernameAlphanumeric": { + "description": "Message for alphanumeric username requirement." + }, + "userProfileCreatedSuccessfully": "ਯੂਜ਼ਰ ਪ੍ਰੋਫਾਈਲ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਈ ਗਈ", + "@userProfileCreatedSuccessfully": { + "description": "Message when user profile is created successfully." + }, + "emailVerificationMessage": "ਈਮੇਲ ਵੈਰੀਫਿਕੇਸ਼ਨ ਸੁਨੇਹਾ", + "@emailVerificationMessage": { + "description": "Message for email verification." + }, + "addNewChaptersToStory": "{storyName} ਵਿੱਚ ਨਵੇਂ ਅਧਿਆਇ ਜੋੜੋ", + "@addNewChaptersToStory": { + "description": "Prompt to add new chapters to story.", + "placeholders": { + "storyName": { + "type": "String", + "example": "My Story" + } + } + }, + "currentChapters": "ਮੌਜੂਦਾ ਅਧਿਆਇ", + "@currentChapters": { + "description": "Label for current chapters." + }, + "sourceCodeOnGitHub": "ਗਿਥੁਬ ਤੇ ਸਰੋਤ ਕੋਡ", + "@sourceCodeOnGitHub": { + "description": "Label for source code on GitHub." + }, + "createAChapter": "ਅਧਿਆਇ ਬਣਾਓ", + "@createAChapter": { + "description": "Button text to create a chapter." + }, + "chapterTitle": "ਅਧਿਆਇ ਸਿਰਲੇਖ", + "@chapterTitle": { + "description": "Label for chapter title." + }, + "aboutRequired": "'ਬਾਰੇ' ਲਾਜ਼ਮੀ ਹੈ", + "@aboutRequired": { + "description": "Message that 'about' is required." + }, + "changeCoverImage": "ਕਵਰ ਚਿੱਤਰ ਬਦਲੋ", + "@changeCoverImage": { + "description": "Button text to change cover image." + }, + "uploadAudioFile": "ਆਡੀਓ ਫਾਈਲ ਅੱਪਲੋਡ ਕਰੋ", + "@uploadAudioFile": { + "description": "Button text to upload audio file." + }, + "uploadLyricsFile": "ਲਿਰਿਕਸ ਫਾਈਲ ਅੱਪਲੋਡ ਕਰੋ", + "@uploadLyricsFile": { + "description": "Button text to upload lyrics file." + }, + "createChapter": "ਅਧਿਆਇ ਬਣਾਓ", + "@createChapter": { + "description": "Button text to create chapter." + }, + "audioFileSelected": "ਆਡੀਓ ਫਾਈਲ ਚੁਣੀ ਗਈ", + "@audioFileSelected": { + "description": "Message when audio file is selected." + }, + "lyricsFileSelected": "ਲਿਰਿਕਸ ਫਾਈਲ ਚੁਣੀ ਗਈ", + "@lyricsFileSelected": { + "description": "Message when lyrics file is selected." + }, + "fillAllRequiredFields": "ਸਾਰੇ ਲਾਜ਼ਮੀ ਖੇਤਰ ਭਰੋ", + "@fillAllRequiredFields": { + "description": "Prompt to fill all required fields." + }, + "scheduled": "ਨਿਰਧਾਰਤ", + "@scheduled": { + "description": "Label for scheduled status." + }, + "ok": "ਠੀਕ ਹੈ", + "@ok": { + "description": "Button text for OK." + }, + "roomDescriptionOptional": "ਰੂਮ ਵਰਣਨ ਵਿਕਲਪਿਕ ਹੈ", + "@roomDescriptionOptional": { + "description": "Message that room description is optional." + }, + "deleteAccount": "ਅਕਾਊਂਟ ਹਟਾਓ", + "@deleteAccount": { + "description": "Button text to delete account." + }, + "createYourStory": "ਆਪਣੀ ਕਹਾਣੀ ਬਣਾਓ", + "@createYourStory": { + "description": "Button text to create your story." + }, + "titleRequired": "ਸਿਰਲੇਖ ਲਾਜ਼ਮੀ ਹੈ", + "@titleRequired": { + "description": "Message that title is required." + }, + "category": "ਸ਼੍ਰੇਣੀ", + "@category": { + "description": "Label for category field." + }, + "addChapter": "ਅਧਿਆਇ ਜੋੜੋ", + "@addChapter": { + "description": "Button text to add chapter." + }, + "createStory": "ਕਹਾਣੀ ਬਣਾਓ", + "@createStory": { + "description": "Button text to create story." + }, + "fillAllRequiredFieldsAndChapter": "ਸਾਰੇ ਲਾਜ਼ਮੀ ਖੇਤਰ ਅਤੇ ਅਧਿਆਇ ਭਰੋ", + "@fillAllRequiredFieldsAndChapter": { + "description": "Prompt to fill all required fields and chapter." + }, + "toConfirmType": "ਪੁਸ਼ਟੀ ਕਰਨ ਲਈ ਟਾਈਪ ਕਰੋ", + "@toConfirmType": { + "description": "Prompt to type for confirmation." + }, + "inTheBoxBelow": "ਹੇਠਾਂ ਵਾਲੇ ਬਾਕਸ ਵਿੱਚ", + "@inTheBoxBelow": { + "description": "Instruction to type in the box below." + }, + "iUnderstandDeleteMyAccount": "ਮੈਂ ਸਮਝਦਾ ਹਾਂ ਕਿ ਮੇਰਾ ਅਕਾਊਂਟ ਹਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ", + "@iUnderstandDeleteMyAccount": { + "description": "Confirmation text for account deletion." + }, + "whatDoYouWantToListenTo": "ਤੁਸੀਂ ਕੀ ਸੁਣਨਾ ਚਾਹੁੰਦੇ ਹੋ?", + "@whatDoYouWantToListenTo": { + "description": "Prompt asking what user wants to listen to." + }, + "categories": "ਸ਼੍ਰੇਣੀਆਂ", + "@categories": { + "description": "Label for categories." + }, + "stories": "ਕਹਾਣੀਆਂ", + "@stories": { + "description": "Label for stories." + }, + "someSuggestions": "ਕੁਝ ਸੁਝਾਵ", + "@someSuggestions": { + "description": "Label for some suggestions." + }, + "getStarted": "ਸ਼ੁਰੂ ਕਰੋ", + "@getStarted": { + "description": "Button text to get started." + }, + "skip": "ਛੱਡੋ", + "@skip": { + "description": "Button text to skip." + }, + "welcomeToResonate": "ਰੇਜ਼ੋਨੇਟ ਵਿੱਚ ਤੁਹਾਡਾ ਸੁਆਗਤ ਹੈ", + "@welcomeToResonate": { + "description": "Welcome message for Resonate." + }, + "exploreDiverseConversations": "ਵੱਖ-ਵੱਖ ਗੱਲਬਾਤਾਂ ਦੀ ਖੋਜ ਕਰੋ", + "@exploreDiverseConversations": { + "description": "Prompt to explore diverse conversations." + }, + "yourVoiceMatters": "ਤੁਹਾਡੀ ਆਵਾਜ਼ ਮਹੱਤਵਪੂਰਨ ਹੈ", + "@yourVoiceMatters": { + "description": "Message that your voice matters." + }, + "joinConversationExploreRooms": "ਗੱਲਬਾਤ ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ, ਰੂਮ ਖੋਜੋ", + "@joinConversationExploreRooms": { + "description": "Prompt to join conversation and explore rooms." + }, + "diveIntoDiverseDiscussions": "ਵੱਖ-ਵੱਖ ਚਰਚਾਵਾਂ ਵਿੱਚ ਡੁੱਬੋ", + "@diveIntoDiverseDiscussions": { + "description": "Prompt to dive into diverse discussions." + }, + "atResonateEveryVoiceValued": "ਰੇਜ਼ੋਨੇਟ 'ਤੇ ਹਰ ਆवਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ", + "@atResonateEveryVoiceValued": { + "description": "Message that every voice is valued at Resonate." + }, + "notifications": "ਨੋਟੀਫਿਕੇਸ਼ਨ", + "@notifications": { + "description": "Label for notifications." + }, + "taggedYouInUpcomingRoom": "{username} ਨੇ ਤੁਹਾਨੂੰ ਇੱਕ ਆਉਣ ਵਾਲੇ ਰੂਮ ਵਿੱਚ ਟੈਗ ਕੀਤਾ: {subject}", + "@taggedYouInUpcomingRoom": { + "description": "Notification for being tagged in upcoming room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "taggedYouInRoom": "{username} ਨੇ ਤੁਹਾਨੂੰ ਇੱਕ ਰੂਮ ਵਿੱਚ ਟੈਗ ਕੀਤਾ: {subject}", + "@taggedYouInRoom": { + "description": "Notification for being tagged in room.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "Discussion Room" + } + } + }, + "likedYourStory": "{username} ਨੇ ਤੁਹਾਡੀ ਕਹਾਣੀ ਨੂੰ ਪਸੰਦ ਕੀਤਾ: {subject}", + "@likedYourStory": { + "description": "Notification for liked story.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Story" + } + } + }, + "subscribedToYourRoom": "{username} ਨੇ ਤੁਹਾਡੇ ਰੂਮ ਨੂੰ ਸਬਸਕ੍ਰਾਈਬ ਕੀਤਾ: {subject}", + "@subscribedToYourRoom": { + "description": "Notification for room subscription.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + }, + "subject": { + "type": "String", + "example": "My Room" + } + } + }, + "startedFollowingYou": "{username} ਨੇ ਤੁਹਾਨੂੰ ਫਾਲੋ ਕਰਨਾ ਸ਼ੁਰੂ ਕੀਤਾ", + "@startedFollowingYou": { + "description": "Notification for started following you.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "youHaveNewNotification": "ਤੁਹਾਨੂੰ ਨਵੀਂ ਨੋਟੀਫਿਕੇਸ਼ਨ ਮਿਲੀ ਹੈ", + "@youHaveNewNotification": { + "description": "Message for new notification." + }, + "hangOnGoodThingsTakeTime": "ਇੰਤਜ਼ਾਰ ਕਰੋ, ਚੰਗੀਆਂ ਚੀਜ਼ਾਂ ਸਮਾਂ ਲੈਂਦੀਆਂ ਹਨ", + "@hangOnGoodThingsTakeTime": { + "description": "Message to hang on, good things take time." + }, + "resonateOpenSourceProject": "ਰੇਜ਼ੋਨੇਟ ਖੁੱਲਾ ਸਰੋਤ ਪ੍ਰੋਜੈਕਟ ਹੈ", + "@resonateOpenSourceProject": { + "description": "Message that Resonate is open source project." + }, + "mute": "ਮਿਊਟ", + "@mute": { + "description": "Button text to mute." + }, + "speakerLabel": "ਸਪੀਕਰ", + "@speakerLabel": { + "description": "Label for speaker." + }, + "audioOptions": "ਆਡੀਓ ਵਿਕਲਪ", + "@audioOptions": { + "description": "Label for the audio options/settings button." + }, + "end": "ਅੰਤ", + "@end": { + "description": "Label for end." + }, + "saveChanges": "ਤਬਦੀਲੀਆਂ ਸੰਭਾਲੋ", + "@saveChanges": { + "description": "Button text to save changes." + }, + "discard": "ਅਣਡਿੱਠਾ ਕਰੋ", + "@discard": { + "description": "Button text to discard changes." + }, + "save": "ਸੰਭਾਲੋ", + "@save": { + "description": "Button text to save." + }, + "changeProfilePicture": "ਪ੍ਰੋਫਾਈਲ ਚਿੱਤਰ ਬਦਲੋ", + "@changeProfilePicture": { + "description": "Button text to change profile picture." + }, + "camera": "ਕੈਮਰਾ", + "@camera": { + "description": "Label for camera." + }, + "gallery": "ਗੈਲਰੀ", + "@gallery": { + "description": "Label for gallery." + }, + "remove": "ਹਟਾਓ", + "@remove": { + "description": "Button text to remove something." + }, + "created": "ਬਣਾਇਆ ਗਿਆ: {date}", + "@created": { + "description": "Label for created.", + "placeholders": { + "date": { + "type": "String", + "example": "Jan 15, 2023" + } + } + }, + "chapters": "ਅਧਿਆਇ", + "@chapters": { + "description": "Label for chapters." + }, + "deleteStory": "ਕਹਾਣੀ ਹਟਾਓ", + "@deleteStory": { + "description": "Button text to delete story." + }, + "createdBy": "{creatorName} ਦੁਆਰਾ ਬਣਾਇਆ ਗਿਆ", + "@createdBy": { + "description": "Label for created by.", + "placeholders": { + "creatorName": { + "type": "String", + "example": "John Doe" + } + } + }, + "start": "ਸ਼ੁਰੂ ਕਰੋ", + "@start": { + "description": "Button text to start something." + }, + "unsubscribe": "ਅਣ-ਸਬਸਕ੍ਰਾਈਬ ਕਰੋ", + "@unsubscribe": { + "description": "Button text to unsubscribe." + }, + "subscribe": "ਸਬਸਕ੍ਰਾਈਬ ਕਰੋ", + "@subscribe": { + "description": "Button text to subscribe." + }, + "storyCategory": "{category, select, drama{ਨਾਟਕ} comedy{ਹਾਸਿਆਸਪਦ} horror{ਭਿਆਨਕ} romance{ਰੋਮਾਂਟਿਕ} thriller{ਥ੍ਰਿਲਰ} spiritual{ਆਧਿਆਤਮਿਕ} other{ਹੋਰ}}", + "@storyCategory": { + "description": "Selects the appropriate category name for a story based on a key.", + "placeholders": { + "category": { + "type": "String", + "example": "drama" + } + } + }, + "chooseTheme": "ਥੀਮ ਚੁਣੋ", + "@chooseTheme": { + "description": "Prompt to choose theme." + }, + "minutesAgo": "{count, plural, =1{1 ਮਿੰਟ ਪਹਿਲਾਂ} other{{count} ਮਿੰਟ ਪਹਿਲਾਂ}}", + "@minutesAgo": { + "description": "Label for minutes ago.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "hoursAgo": "{count, plural, =1{1 ਘੰਟਾ ਪਹਿਲਾਂ} other{{count} ਘੰਟੇ ਪਹਿਲਾਂ}}", + "@hoursAgo": { + "description": "Label for hours ago.", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "daysAgo": "{count, plural, =1{1 ਦਿਨ ਪਹਿਲਾਂ} other{{count} ਦਿਨ ਪਹਿਲਾਂ}}", + "@daysAgo": { + "description": "Label for days ago.", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "by": "ਦੁਆਰਾ", + "@by": { + "description": "Label for by." + }, + "likes": "ਪਸੰਦ", + "@likes": { + "description": "Label for likes." + }, + "lengthMinutes": "ਮਿੰਟ ਲੰਬਾਈ", + "@lengthMinutes": { + "description": "Label for length in minutes." + }, + "requiredField": "ਲਾਜ਼ਮੀ ਖੇਤਰ", + "@requiredField": { + "description": "Label for required field." + }, + "onlineUsers": "ਆਨਲਾਈਨ ਯੂਜ਼ਰ", + "@onlineUsers": { + "description": "Label for online users." + }, + "noOnlineUsers": "ਕੋਈ ਆਨਲਾਈਨ ਯੂਜ਼ਰ ਨਹੀਂ", + "@noOnlineUsers": { + "description": "Message when no online users." + }, + "chooseUser": "ਯੂਜ਼ਰ ਚੁਣੋ", + "@chooseUser": { + "description": "Prompt to choose user." + }, + "quickMatch": "ਤੁਰੰਤ ਮੇਲ", + "@quickMatch": { + "description": "Label for quick match feature." + }, + "story": "ਕਹਾਣੀ", + "@story": { + "description": "Label for story." + }, + "user": "ਯੂਜ਼ਰ", + "@user": { + "description": "Label for user." + }, + "following": "ਫਾਲੋ ਕਰ ਰਹੇ ਹੋ", + "@following": { + "description": "Label for following." + }, + "followers": "ਫਾਲੋਅਰ", + "@followers": { + "description": "Label for followers." + }, + "friendRequests": "ਦੋਸਤ ਅਰਜ਼ੀਆਂ", + "@friendRequests": { + "description": "Label for friend requests." + }, + "friendRequestSent": "ਦੋਸਤ ਅਰਜ਼ੀ ਭੇਜੀ ਗਈ", + "@friendRequestSent": { + "description": "Message when friend request is sent." + }, + "friendRequestSentTo": "ਤੁਹਾਡੀ ਦੋਸਤ ਅਰਜ਼ੀ {username} ਨੂੰ ਭੇਜੀ ਗਈ ਹੈ", + "@friendRequestSentTo": { + "description": "Message when friend request is sent to someone.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestCancelled": "ਦੋਸਤ ਅਰਜ਼ੀ ਰੱਦ ਕੀਤੀ ਗਈ", + "@friendRequestCancelled": { + "description": "Message when friend request is cancelled." + }, + "friendRequestCancelledTo": "ਤੁਹਾਡੀ {username} ਨੂੰ ਭੇਜੀ ਦੋਸਤ ਅਰਜ਼ੀ ਰੱਦ ਕਰ ਦਿੱਤੀ ਗਈ ਹੈ", + "@friendRequestCancelledTo": { + "description": "Message when friend request is cancelled to someone.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "requested": "ਅਰਜ਼ੀ ਕੀਤੀ", + "@requested": { + "description": "Label for requested status." + }, + "friends": "ਦੋਸਤ", + "@friends": { + "description": "Label for friends." + }, + "addFriend": "ਦੋਸਤ ਜੋੜੋ", + "@addFriend": { + "description": "Button text to add friend." + }, + "friendRequestAccepted": "ਦੋਸਤ ਅਰਜ਼ੀ ਮਨਜ਼ੂਰ ਹੋਈ", + "@friendRequestAccepted": { + "description": "Message when friend request is accepted." + }, + "friendRequestAcceptedTo": "ਤੁਸੀਂ {username} ਦੀ ਦੋਸਤ ਅਰਜ਼ੀ ਮਨਜ਼ੂਰ ਕਰ ਲਈ ਹੈ", + "@friendRequestAcceptedTo": { + "description": "Message when friend request is accepted to someone.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "friendRequestDeclined": "ਦੋਸਤ ਅਰਜ਼ੀ ਅਸਵੀਕਾਰ ਕੀਤੀ ਗਈ", + "@friendRequestDeclined": { + "description": "Message when friend request is declined." + }, + "friendRequestDeclinedTo": "ਤੁਸੀਂ {username} ਦੀ ਦੋਸਤ ਅਰਜ਼ੀ ਅਸਵੀਕਾਰ ਕਰ ਦਿੱਤੀ ਹੈ", + "@friendRequestDeclinedTo": { + "description": "Message when friend request is declined to someone.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "accept": "ਮਨਜ਼ੂਰ ਕਰੋ", + "@accept": { + "description": "Button text to accept." + }, + "callDeclined": "ਕਾਲ ਅਸਵੀਕਾਰ ਕੀਤੀ ਗਈ", + "@callDeclined": { + "description": "Message when call is declined." + }, + "callDeclinedTo": "{username} ਨੇ ਕਾਲ ਅਸਵੀਕਾਰ ਕਰ ਦਿੱਤੀ", + "@callDeclinedTo": { + "description": "Message when call is declined to someone.", + "placeholders": { + "username": { + "type": "String", + "example": "john_doe" + } + } + }, + "checkForUpdates": "ਅੱਪਡੇਟ ਲਈ ਚੈੱਕ ਕਰੋ", + "@checkForUpdates": { + "description": "Button text to check for updates." + }, + "updateNow": "ਹੁਣ ਅੱਪਡੇਟ ਕਰੋ", + "@updateNow": { + "description": "Button text to update now." + }, + "updateLater": "ਬਾਅਦ ਵਿੱਚ ਅੱਪਡੇਟ ਕਰੋ", + "@updateLater": { + "description": "Button text to update later." + }, + "updateSuccessful": "ਅੱਪਡੇਟ ਸਫਲ", + "@updateSuccessful": { + "description": "Message when update is successful." + }, + "updateSuccessfulMessage": "ਅੱਪਡੇਟ ਸਫਲਤਾਪੂਰਵਕ ਹੋ ਗਿਆ", + "@updateSuccessfulMessage": { + "description": "Message for successful update." + }, + "updateCancelled": "ਅੱਪਡੇਟ ਰੱਦ ਕੀਤਾ ਗਿਆ", + "@updateCancelled": { + "description": "Message when update is cancelled." + }, + "updateCancelledMessage": "ਅੱਪਡੇਟ ਰੱਦ ਹੋ ਗਿਆ", + "@updateCancelledMessage": { + "description": "Message for cancelled update." + }, + "updateFailed": "ਅੱਪਡੇਟ ਅਸਫਲ", + "@updateFailed": { + "description": "Message when update fails." + }, + "updateFailedMessage": "ਅੱਪਡੇਟ ਅਸਫਲ ਹੋ ਗਿਆ", + "@updateFailedMessage": { + "description": "Message for failed update." + }, + "updateError": "ਅੱਪਡੇਟ ਵਿੱਚ ਗਲਤੀ", + "@updateError": { + "description": "Error message for update." + }, + "updateErrorMessage": "ਅੱਪਡੇਟ ਵਿੱਚ ਗਲਤੀ ਆਈ", + "@updateErrorMessage": { + "description": "Message for update error." + }, + "platformNotSupported": "ਪਲੇਟਫਾਰਮ ਸਹਾਇਕ ਨਹੀਂ ਹੈ", + "@platformNotSupported": { + "description": "Message when platform is not supported." + }, + "platformNotSupportedMessage": "ਇਹ ਪਲੇਟਫਾਰਮ ਸਹਾਇਕ ਨਹੀਂ ਹੈ", + "@platformNotSupportedMessage": { + "description": "Message for unsupported platform." + }, + "updateCheckFailed": "ਅੱਪਡੇਟ ਚੈੱਕ ਅਸਫਲ", + "@updateCheckFailed": { + "description": "Message when update check fails." + }, + "updateCheckFailedMessage": "ਅੱਪਡੇਟ ਚੈੱਕ ਕਰਨ ਵਿੱਚ ਅਸਫਲਤਾ", + "@updateCheckFailedMessage": { + "description": "Message for update check failure." + }, + "upToDateTitle": "ਅੱਪ-ਟੂ-ਡੇਟ", + "@upToDateTitle": { + "description": "Title for up to date status." + }, + "upToDateMessage": "ਐਪ ਅੱਪ-ਟੂ-ਡੇਟ ਹੈ", + "@upToDateMessage": { + "description": "Message for up to date app." + }, + "updateAvailableTitle": "ਅੱਪਡੇਟ ਉਪਲਬਧ", + "@updateAvailableTitle": { + "description": "Title for update available." + }, + "updateAvailableMessage": "ਨਵਾਂ ਅੱਪਡੇਟ ਉਪਲਬਧ ਹੈ", + "@updateAvailableMessage": { + "description": "Message for update available." + }, + "updateFeaturesImprovement": "ਅੱਪਡੇਟ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਅਤੇ ਸੁਧਾਰ", + "@updateFeaturesImprovement": { + "description": "Label for update features and improvements." + }, + "failedToRemoveRoom": "ਰੂਮ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", + "@failedToRemoveRoom": { + "description": "Message when failed to remove room." + }, + "failedToCreateRoom": "ਰੂਮ ਬਣਾਉਣ ਵਿੱਚ ਅਸਫਲ", + "@failedToCreateRoom": { + "description": "Message when failed to create room." + }, + "roomChat": "ਰੂਮ ਚੈਟ", + "@roomChat": { + "description": "Title of the in-room chat screen" + }, + "failedToResend": "ਮੁੜ ਭੇਜਣ ਵਿੱਚ ਅਸਫਲ", + "@failedToResend": { + "description": "Snackbar shown when retrying a failed chat message fails again" + }, + "edited": " (ਸੰਪਾਦਿਤ)", + "@edited": { + "description": "Inline suffix appended to an edited chat message" + }, + "failedToSendTapRetry": "ਭੇਜਣ ਵਿੱਚ ਅਸਫਲ। ਮੁੜ ਕੋਸ਼ਿਸ਼ ਲਈ ਸੁਨੇਹੇ ਉੱਤੇ ਟੈਪ ਕਰੋ।", + "@failedToSendTapRetry": { + "description": "Snackbar shown when a chat message fails to send" + }, + "saySomething": "ਕੁਝ ਕਹੋ", + "@saySomething": { + "description": "Hint text in the chat message input field" + }, + "tapToRetry": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਲਈ ਟੈਪ ਕਰੋ", + "@tapToRetry": { + "description": "Tooltip on the retry affordance of a failed chat message" + }, + "retry": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ", + "@retry": { + "description": "Label of the retry action on a failed chat message" + }, + "roomRemovedSuccessfully": "ਰੂਮ ਸਫਲਤਾਪੂਰਵਕ ਹਟਾਇਆ ਗਿਆ", + "@roomRemovedSuccessfully": { + "description": "Message when room is removed successfully." + }, + "alert": "ਚੇਤਾਵਨੀ", + "@alert": { + "description": "Label for alert." + }, + "removedFromRoom": "ਰੂਮ ਤੋਂ ਹਟਾਇਆ ਗਿਆ", + "@removedFromRoom": { + "description": "Message when removed from room." + }, + "reportType": "{type, select, harassment{ਪਰੇਸ਼ਾਨੀ / ਨਫ਼ਰਤ ਭਰਿਆ ਭਾਸ਼ਣ} abuse{ਅਪਮਾਨਜਨਕ ਸਮੱਗਰੀ / ਹਿੰਸਾ} spam{ਸਪੈਮ / ਘੁਟਾਲੇ / ਧੋਖਾਧੜੀ} impersonation{ਰੂਪ ਬਦਲਣਾ / ਜਾਅਲੀ ਖਾਤੇ} illegal{ਗੈਰ-ਕਾਨੂੰਨੀ ਗਤੀਵਿਧੀਆਂ} selfharm{ਆਤਮ-ਹੱਤਿਆ / ਖੁਦਕੁਸ਼ੀ / ਮਾਨਸਿਕ ਸਿਹਤ} misuse{ਪਲੇਟਫਾਰਮ ਦੀ ਦੁਰਵਰਤੋਂ} other{ਹੋਰ}}", + "@reportType": { + "description": "Label for report type.", + "placeholders": { + "type": { + "type": "String", + "example": "harassment" + } + } + }, + "userBlockedFromResonate": "ਰੇਜ਼ੋਨੇਟ ਤੋਂ ਯੂਜ਼ਰ ਬਲੌਕ ਕੀਤਾ ਗਿਆ", + "@userBlockedFromResonate": { + "description": "Message when user is blocked from Resonate." + }, + "reportParticipant": "ਭਾਗੀਦਾਰ ਦੀ ਰਿਪੋਰਟ ਕਰੋ", + "@reportParticipant": { + "description": "Button text to report participant." + }, + "selectReportType": "ਰਿਪੋਰਟ ਕਿਸਮ ਚੁਣੋ", + "@selectReportType": { + "description": "Prompt to select report type." + }, + "reportSubmitted": "ਰਿਪੋਰਟ ਜਮ੍ਹਾਂ ਹੋਈ", + "@reportSubmitted": { + "description": "Message when report is submitted." + }, + "reportFailed": "ਰਿਪੋਰਟ ਅਸਫਲ", + "@reportFailed": { + "description": "Message when report fails." + }, + "additionalDetailsOptional": "ਵਾਧੂ ਵੇਰਵੇ ਵਿਕਲਪਿਕ ਹਨ", + "@additionalDetailsOptional": { + "description": "Message that additional details are optional." + }, + "submitReport": "ਰਿਪੋਰਟ ਜਮ੍ਹਾਂ ਕਰੋ", + "@submitReport": { + "description": "Button text to submit report." + }, + "actionBlocked": "ਕਾਰਵਾਈ ਰੋਕੀ ਗਈ", + "@actionBlocked": { + "description": "Message when action is blocked." + }, + "cannotStopRecording": "ਰਿਕਾਰਡਿੰਗ ਰੋਕੀ ਨਹੀਂ ਜਾ ਸਕਦੀ", + "@cannotStopRecording": { + "description": "Message when recording cannot be stopped." + }, + "liveChapter": "ਲਾਈਵ ਅਧਿਆਇ", + "@liveChapter": { + "description": "Label for live chapter." + }, + "viewOrEditLyrics": "ਲਿਰਿਕਸ ਵੇਖੋ ਜਾਂ ਐਡਿਟ ਕਰੋ", + "@viewOrEditLyrics": { + "description": "Button text to view or edit lyrics." + }, + "close": "ਬੰਦ ਕਰੋ", + "@close": { + "description": "Button text to close something." + }, + "verifyChapterDetails": "ਅਧਿਆਇ ਵੇਰਵੇ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ", + "@verifyChapterDetails": { + "description": "Prompt to verify chapter details." + }, + "author": "ਲੇਖਕ", + "@author": { + "description": "Label for author." + }, + "startLiveChapter": "ਲਾਈਵ ਅਧਿਆਇ ਸ਼ੁਰੂ ਕਰੋ", + "@startLiveChapter": { + "description": "Button text to start live chapter." + }, + "fillAllFields": "ਸਾਰੇ ਖੇਤਰ ਭਰੋ", + "@fillAllFields": { + "description": "Prompt to fill all fields." + }, + "noRecordingError": "ਤੁਸੀਂ ਇਸ ਅਧਿਆਇ ਲਈ ਕੁਝ ਵੀ ਰਿਕਾਰਡ ਨਹੀਂ ਕੀਤਾ। ਕਿਰਪਾ ਕਰਕੇ ਰੂਮ ਬੰਦ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਰਿਕਾਰਡ ਕਰੋ", + "@noRecordingError": { + "description": "Message when there is no recording error." + }, + "audioOutput": "ਆਡੀਓ ਆਊਟਪੁੱਟ", + "@audioOutput": { + "description": "Title for audio output device selector." + }, + "selectPreferredSpeaker": "ਆਪਣਾ ਪਸੰਦੀਦਾ ਸਪੀਕਰ ਚੁਣੋ", + "@selectPreferredSpeaker": { + "description": "Subtitle for audio device selector dialog." + }, + "noAudioOutputDevices": "ਕੋਈ ਆਡੀਓ ਆਊਟਪੁੱਟ ਡਿਵਾਈਸ ਨਹੀਂ ਮਿਲੀ", + "@noAudioOutputDevices": { + "description": "Message shown when no audio output devices are available." + }, + "refresh": "ਰਿਫ੍ਰੈਸ਼ ਕਰੋ", + "@refresh": { + "description": "Button text to refresh audio device list." + }, + "done": "ਹੋ ਗਿਆ", + "@done": { + "description": "Button text to close audio device selector." + }, + "noFriendsYet": "ਅਜੇ ਕੋਈ ਦੋਸਤ ਨਹੀਂ", + "@noFriendsYet": { + "description": "Title shown when user has no friends." + }, + "noFriendsDescription": "ਤੁਹਾਡੀ ਦੋਸਤਾਂ ਦੀ ਸੂਚੀ ਖਾਲੀ ਹੈ। ਲੋਕਾਂ ਨਾਲ ਜੁੜਨਾ ਸ਼ੁਰੂ ਕਰੋ ਅਤੇ ਆਪਣਾ ਨੈੱਟਵਰਕ ਵਧਾਓ!", + "@noFriendsDescription": { + "description": "Description shown when user has no friends." + }, + "findFriends": "ਦੋਸਤ ਲੱਭੋ", + "@findFriends": { + "description": "Button text to navigate to find friends screen." + }, + "inviteFriend": "ਦੋਸਤ ਨੂੰ ਸੱਦਾ ਦਿਓ", + "@inviteFriend": { + "description": "Button text to invite friends to the app." + }, + "noFriendRequestsYet": "ਕੋਈ ਦੋਸਤੀ ਬੇਨਤੀ ਨਹੀਂ", + "@noFriendRequestsYet": { + "description": "Title shown when user has no friend requests." + }, + "noFriendRequestsDescription": "ਤੁਹਾਡੇ ਕੋਲ ਕੋਈ ਬਕਾਇਆ ਦੋਸਤੀ ਬੇਨਤੀ ਨਹੀਂ ਹੈ। ਜੁੜਨ ਲਈ ਆਪਣੇ ਦੋਸਤਾਂ ਨੂੰ ਸੱਦਾ ਦਿਓ!", + "@noFriendRequestsDescription": { + "description": "Description shown when user has no friend requests." + }, + "inviteToResonate": "ਹੈਲੋ! ਰੇਜ਼ੋਨੇਟ 'ਤੇ ਮੇਰੇ ਨਾਲ ਜੁੜੋ - ਇੱਕ ਸੋਸ਼ਲ ਆਡੀਓ ਪਲੇਟਫਾਰਮ ਜਿੱਥੇ ਹਰ ਆਵਾਜ਼ ਦੀ ਕਦਰ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਹੁਣੇ ਡਾਊਨਲੋਡ ਕਰੋ: {url}", + "@inviteToResonate": { + "description": "Text used when inviting friends to the app.", + "placeholders": { + "url": { + "type": "String" + } + } + }, + "usernameInvalidFormat": "ਯੂਜ਼ਰਨੇਮ ਵਿੱਚ ਸਿਰਫ਼ ਅੱਖਰ, ਨੰਬਰ ਅਤੇ ਅੰਡਰਸਕੋਰ ਹੋ ਸਕਦੇ ਹਨ", + "@usernameInvalidFormat": { + "description": "Error message for invalid username format." + }, + "usernameAlreadyTaken": "ਇਹ ਯੂਜ਼ਰਨੇਮ ਪਹਿਲਾਂ ਹੀ ਲਿਆ ਗਿਆ ਹੈ", + "@usernameAlreadyTaken": { + "description": "Error message when username is already taken." + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 6696f1de..0e97c46a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -790,12 +790,11 @@ packages: flutter_lyric: dependency: "direct main" description: - path: "." - ref: master - resolved-ref: "2cf076d0ae4a2d6c00d6a521e35b97782ab24887" - url: "https://github.com/Aarush-Acharya/flutter_lyric.git" - source: git - version: "2.0.4+6" + name: flutter_lyric + sha256: b48b07d47136406e95180f3b3d97c596682164817df967933c3d4a3bfe46a9bd + url: "https://pub.dev" + source: hosted + version: "3.0.6" flutter_native_splash: dependency: "direct main" description: diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index c6744ec8..f5a7c846 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -4,7 +4,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_failure.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/utils/constants.dart'; diff --git a/test/features/auth/view/auth_test_helpers.dart b/test/features/auth/view/auth_test_helpers.dart index dd29877a..75f7e8d0 100644 --- a/test/features/auth/view/auth_test_helpers.dart +++ b/test/features/auth/view/auth_test_helpers.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; diff --git a/test/features/auth/viewmodel/email_verify_notifier_test.dart b/test/features/auth/viewmodel/email_verify_notifier_test.dart index 5c5a03a7..7f0f55ac 100644 --- a/test/features/auth/viewmodel/email_verify_notifier_test.dart +++ b/test/features/auth/viewmodel/email_verify_notifier_test.dart @@ -1,7 +1,7 @@ import 'package:appwrite/models.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; diff --git a/test/features/auth/viewmodel/reset_password_notifier_test.dart b/test/features/auth/viewmodel/reset_password_notifier_test.dart index e0c318d3..a5b3e47d 100644 --- a/test/features/auth/viewmodel/reset_password_notifier_test.dart +++ b/test/features/auth/viewmodel/reset_password_notifier_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/reset_password_notifier.dart'; diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 8e213cf5..753d10ab 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -6,7 +6,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/change_email_state.dart'; import 'package:resonate/models/follower_user_model.dart'; import 'package:resonate/utils/constants.dart'; diff --git a/test/features/profile/fake_profile_repository.dart b/test/features/profile/fake_profile_repository.dart index 25a94be3..e3d8e43d 100644 --- a/test/features/profile/fake_profile_repository.dart +++ b/test/features/profile/fake_profile_repository.dart @@ -1,4 +1,4 @@ -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/models/follower_user_model.dart'; import 'package:resonate/models/story.dart'; diff --git a/test/features/profile/view/profile_test_helpers.dart b/test/features/profile/view/profile_test_helpers.dart index 6c9d69cc..ea048dfa 100644 --- a/test/features/profile/view/profile_test_helpers.dart +++ b/test/features/profile/view/profile_test_helpers.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; diff --git a/test/features/profile/viewmodel/change_email_notifier_test.dart b/test/features/profile/viewmodel/change_email_notifier_test.dart index 8f05f567..80292ace 100644 --- a/test/features/profile/viewmodel/change_email_notifier_test.dart +++ b/test/features/profile/viewmodel/change_email_notifier_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/change_email_state.dart'; import 'package:resonate/features/profile/viewmodel/change_email_notifier.dart'; diff --git a/test/features/profile/viewmodel/edit_profile_notifier_test.dart b/test/features/profile/viewmodel/edit_profile_notifier_test.dart index 0a0fa59e..1679ad51 100644 --- a/test/features/profile/viewmodel/edit_profile_notifier_test.dart +++ b/test/features/profile/viewmodel/edit_profile_notifier_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/edit_profile_state.dart'; import 'package:resonate/features/profile/viewmodel/edit_profile_notifier.dart'; diff --git a/test/features/profile/viewmodel/onboarding_notifier_test.dart b/test/features/profile/viewmodel/onboarding_notifier_test.dart index d8f4d58a..d002cca5 100644 --- a/test/features/profile/viewmodel/onboarding_notifier_test.dart +++ b/test/features/profile/viewmodel/onboarding_notifier_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/onboarding_state.dart'; import 'package:resonate/features/profile/viewmodel/onboarding_notifier.dart'; diff --git a/test/features/profile/viewmodel/profile_view_notifier_test.dart b/test/features/profile/viewmodel/profile_view_notifier_test.dart index 2dcf31b8..b112245e 100644 --- a/test/features/profile/viewmodel/profile_view_notifier_test.dart +++ b/test/features/profile/viewmodel/profile_view_notifier_test.dart @@ -2,10 +2,10 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/profile/data/profile_repository.dart'; +import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; import 'package:resonate/models/follower_user_model.dart'; import 'package:resonate/models/story.dart'; diff --git a/test/features/rooms/data/rooms_repository_test.dart b/test/features/rooms/data/rooms_repository_test.dart index 4eadee4f..659aa73f 100644 --- a/test/features/rooms/data/rooms_repository_test.dart +++ b/test/features/rooms/data/rooms_repository_test.dart @@ -3,7 +3,7 @@ import 'package:appwrite/models.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:resonate/features/rooms/data/rooms_repository.dart'; +import 'package:resonate/features/rooms/data/repositories/rooms_repository.dart'; import 'package:resonate/features/rooms/model/room_failure.dart'; import 'package:resonate/services/api_service.dart'; import 'package:resonate/utils/constants.dart'; diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index f6e0dd90..821f130e 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -10,7 +10,7 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/core/providers/appwrite_providers.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/core/providers/get_storage_provider.dart'; -import 'package:resonate/features/auth/data/auth_repository.dart'; +import 'package:resonate/features/auth/data/repositories/auth_repository.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; diff --git a/untranslated.txt b/untranslated.txt index 1340a455..47c81663 100644 --- a/untranslated.txt +++ b/untranslated.txt @@ -1,6 +1,14 @@ { "bn": [ "audioOptions", + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry", "audioOutput", "selectPreferredSpeaker", "noAudioOutputDevices", @@ -49,6 +57,14 @@ "clear", "audioOptions", "failedToRemoveRoom", + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry", "roomRemovedSuccessfully", "audioOutput", "selectPreferredSpeaker", @@ -85,6 +101,14 @@ "clear", "audioOptions", "failedToRemoveRoom", + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry", "roomRemovedSuccessfully", "audioOutput", "selectPreferredSpeaker", @@ -106,8 +130,38 @@ "usernameAlreadyTaken" ], + "ml": [ + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry" + ], + + "mr": [ + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry" + ], + "raj": [ "audioOptions", + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry", "audioOutput", "selectPreferredSpeaker", "noAudioOutputDevices", @@ -129,6 +183,14 @@ ], "ta": [ + "failedToCreateRoom", + "roomChat", + "failedToResend", + "edited", + "failedToSendTapRetry", + "saySomething", + "tapToRetry", + "retry", "noFriendsYet", "noFriendsDescription", "findFriends", From be3f556e2d9fd33edb92f351e6d5edfafc0fcd59 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:19:24 +0530 Subject: [PATCH 08/12] fix: fixed search overlay bug and room deleation --- .../data/repositories/rooms_repository.dart | 48 ++++++++++++++----- .../rooms/viewmodel/single_room_notifier.dart | 1 + lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_hi.dart | 8 ++-- lib/views/screens/home_screen.dart | 28 +---------- untranslated.txt | 5 -- 6 files changed, 42 insertions(+), 50 deletions(-) diff --git a/lib/features/rooms/data/repositories/rooms_repository.dart b/lib/features/rooms/data/repositories/rooms_repository.dart index 5849d499..fd83743c 100644 --- a/lib/features/rooms/data/repositories/rooms_repository.dart +++ b/lib/features/rooms/data/repositories/rooms_repository.dart @@ -105,8 +105,9 @@ class RoomsRepository { memberAvatarUrls: memberAvatarUrls, state: RoomState.live, isUserAdmin: data['adminUid'] == userUid, - reportedUsers: - List.from(data['reportedUsers'] as List? ?? const []), + reportedUsers: List.from( + data['reportedUsers'] as List? ?? const [], + ), ); } @@ -126,8 +127,14 @@ class RoomsRepository { ? localhostLivekitEndpoint : socketUrl; - await _secureStorage.write(key: 'createdRoomAdminToken', value: roomToken); - await _secureStorage.write(key: 'createdRoomLivekitUrl', value: liveKitUri); + await _secureStorage.write( + key: 'createdRoomAdminToken', + value: roomToken, + ); + await _secureStorage.write( + key: 'createdRoomLivekitUrl', + value: liveKitUri, + ); final myDocId = await _addParticipant( roomId: roomId, @@ -214,8 +221,8 @@ class RoomsRepository { ); final newCount = ((roomDoc.data['totalParticipants'] as num?)?.toInt() ?? 0) - - existing.rows.length + - 1; + existing.rows.length + + 1; await _tables.updateRow( databaseId: masterDatabaseId, tableId: roomsTableId, @@ -227,7 +234,10 @@ class RoomsRepository { return participantDoc.$id; } - Future leaveRoom({required String roomId, required String userId}) async { + Future leaveRoom({ + required String roomId, + required String userId, + }) async { try { final roomDoc = await _tables.getRow( databaseId: masterDatabaseId, @@ -253,7 +263,7 @@ class RoomsRepository { final remaining = ((roomDoc.data['totalParticipants'] as num?)?.toInt() ?? 0) - - participantDocs.rows.length; + participantDocs.rows.length; if (remaining == 0) { await _tables.deleteRow( databaseId: masterDatabaseId, @@ -277,10 +287,11 @@ class RoomsRepository { Future deleteRoom({required String roomId}) async { try { final token = await _secureStorage.read(key: 'createdRoomAdminToken'); - if (token == null) { - throw const RoomFailure.permissionDenied(); + if (token != null) { + try { + await _api.deleteRoom(roomId, token); + } catch (_) {} } - await _api.deleteRoom(roomId, token); final participantDocs = await _tables.listRows( databaseId: masterDatabaseId, @@ -296,6 +307,18 @@ class RoomsRepository { rowId: doc.$id, ); } + + // Ensure the room doc is deleted even when the server call above failed + // (it normally does this). Tolerate it already being gone. + try { + await _tables.deleteRow( + databaseId: masterDatabaseId, + tableId: roomsTableId, + rowId: roomId, + ); + } on AppwriteException catch (e) { + if (e.code != 404) rethrow; + } } on AppwriteException catch (e) { throw _mapException(e); } @@ -345,8 +368,7 @@ class RoomsRepository { final subscription = _realtime.subscribe([channel]); final controller = StreamController(); final sub = subscription.stream.listen((event) { - if (event.payload.isNotEmpty && - event.payload['roomId'] == roomId) { + if (event.payload.isNotEmpty && event.payload['roomId'] == roomId) { controller.add(event); } }); diff --git a/lib/features/rooms/viewmodel/single_room_notifier.dart b/lib/features/rooms/viewmodel/single_room_notifier.dart index a8781d06..e1268f73 100644 --- a/lib/features/rooms/viewmodel/single_room_notifier.dart +++ b/lib/features/rooms/viewmodel/single_room_notifier.dart @@ -276,6 +276,7 @@ class SingleRoomNotifier extends _$SingleRoomNotifier { if (current != null) { state = AsyncData(current.copyWith(isLoading: true)); } + await _disposeStream(); await ref.read(roomsRepositoryProvider).deleteRoom(roomId: appwriteRoom.id); await ref.read(liveKitProvider.notifier).disconnect(); ref.invalidate(roomsProvider); diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7e1a9add..e9f93fde 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2578,7 +2578,7 @@ abstract class AppLocalizations { /// **'This message was deleted'** String get thisMessageWasDeleted; - /// Error message shown when the system is unable to delete a message. + /// No description provided for @failedToDeleteMessage. /// /// In en, this message translates to: /// **'Failed to delete message'** diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 64847ef4..47074093 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1271,13 +1271,13 @@ class AppLocalizationsHi extends AppLocalizations { String get failedToRemoveRoom => 'रूम हटाने में विफल'; @override - String get failedToCreateRoom => 'रूम बनाने में विफल'; + String get failedToCreateRoom => 'रूम बनाने में असफल'; @override String get roomChat => 'रूम चैट'; @override - String get failedToResend => 'पुनः भेजने में विफल'; + String get failedToResend => 'पुनः भेजने में असफल'; @override String get edited => ' (संपादित)'; @@ -1430,9 +1430,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get usernameInvalidFormat => - 'Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.'; + 'उपयोगकर्ता नाम अमान्य है। कृपया केवल अक्षर, अंक और अंडरस्कोर का उपयोग करें।'; @override String get usernameAlreadyTaken => - 'This username is already taken. Try a different one.'; + 'यह उपयोगकर्ता नाम पहले से लिया जा चुका है। कृपया कोई और चुनें।'; } diff --git a/lib/views/screens/home_screen.dart b/lib/views/screens/home_screen.dart index 7d9c2d0b..65c63f8b 100644 --- a/lib/views/screens/home_screen.dart +++ b/lib/views/screens/home_screen.dart @@ -34,9 +34,6 @@ class _HomeScreenState extends ConsumerState { Widget build(BuildContext context) { final roomsAsync = ref.watch(roomsProvider); final upcomingAsync = ref.watch(upcomingRoomsProvider); - final isSearchingRooms = roomsAsync.value is RoomsStateReady - ? (roomsAsync.value as RoomsStateReady).isSearching - : false; return Scaffold( body: SafeArea( @@ -96,7 +93,7 @@ class _HomeScreenState extends ConsumerState { }); ref.read(roomsProvider.notifier).clearLiveSearch(); }, - isSearching: isLiveSelected ? isSearchingRooms : false, + isSearching: false, ), ], ), @@ -189,29 +186,6 @@ class _LiveRoomsListView extends StatelessWidget { final ready = state as RoomsStateReady; final roomsToShow = ready.searchBarIsEmpty ? ready.rooms : ready.filteredRooms; - if (ready.isSearching) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LoadingAnimationWidget.fourRotatingDots( - color: Theme.of(context).colorScheme.primary, - size: UiSizes.size_20, - ), - SizedBox(height: UiSizes.height_16), - Text( - AppLocalizations.of(context)!.searchingRooms, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - if (roomsToShow.isNotEmpty) { return ListView.builder( physics: const BouncingScrollPhysics(), diff --git a/untranslated.txt b/untranslated.txt index 47c81663..ad3e87a0 100644 --- a/untranslated.txt +++ b/untranslated.txt @@ -80,11 +80,6 @@ "inviteToResonate" ], - "hi": [ - "usernameInvalidFormat", - "usernameAlreadyTaken" - ], - "kn": [ "hide", "removeRoom", From c466c466fdfdd6540ce9616e6a88bbf24aaa8c30 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:24:58 +0530 Subject: [PATCH 09/12] feat: commit for untranslated.txt --- lib/features/friends/view/widgets/friends_empty_view.dart | 3 +-- lib/features/friends/viewmodel/friends_notifier.dart | 2 +- lib/features/friends/viewmodel/pair_chat_notifier.dart | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/features/friends/view/widgets/friends_empty_view.dart b/lib/features/friends/view/widgets/friends_empty_view.dart index bf54730f..6dd1238f 100644 --- a/lib/features/friends/view/widgets/friends_empty_view.dart +++ b/lib/features/friends/view/widgets/friends_empty_view.dart @@ -64,8 +64,7 @@ class FriendsEmptyView extends StatelessWidget { width: double.infinity, child: ElevatedButton.icon( onPressed: () { - // TabViewController is still GetX; bridge until the - // tab shell migrates. + // TabViewController is still GetX Get.find().setIndex(1); appRouter.go(RoutePaths.tabview); }, diff --git a/lib/features/friends/viewmodel/friends_notifier.dart b/lib/features/friends/viewmodel/friends_notifier.dart index c2cab89a..46aba46f 100644 --- a/lib/features/friends/viewmodel/friends_notifier.dart +++ b/lib/features/friends/viewmodel/friends_notifier.dart @@ -115,7 +115,7 @@ class FriendsNotifier extends _$FriendsNotifier { } } - // Nulls the field BEFORE the async cancel + // Nulls the field before the async cancel Future _cancelSub() async { final sub = _friendsSub; _friendsSub = null; diff --git a/lib/features/friends/viewmodel/pair_chat_notifier.dart b/lib/features/friends/viewmodel/pair_chat_notifier.dart index 810096b0..dca3c2f8 100644 --- a/lib/features/friends/viewmodel/pair_chat_notifier.dart +++ b/lib/features/friends/viewmodel/pair_chat_notifier.dart @@ -306,7 +306,7 @@ class PairChatNotifier extends _$PairChatNotifier { }); } - // Nulls the fields BEFORE the async cancels + // Nulls the fields before the async cancels Future _cancelSubs() async { final activePairSub = _activePairSub; final newUsersSub = _newUsersSub; From 8bf82e5c4708ed446803d87a0ca32cf05828af09 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:08:42 +0530 Subject: [PATCH 10/12] feat: initial migartion from Stories and Chapter Logic --- lib/bindings/create_story_binding.dart | 9 - lib/controllers/audio_device_controller.dart | 75 -- .../chapter_player_controller.dart | 48 -- lib/controllers/explore_story_controller.dart | 815 ------------------ lib/controllers/live_chapter_controller.dart | 312 ------- lib/controllers/livekit_controller.dart | 188 ---- .../whisper_transcription_controller.dart | 99 --- lib/core/container.dart | 6 - lib/core/legacy_dependencies.dart | 6 +- .../data/repositories/profile_repository.dart | 2 +- .../profile/model/profile_view_data.dart | 2 +- .../profile/view/pages/profile_page.dart | 53 +- .../viewmodel/profile_view_notifier.dart | 2 +- .../data/repositories/rooms_repository.dart | 2 - .../generated/live_chapter_repository.g.dart | 58 ++ .../generated/stories_repository.g.dart | 57 ++ .../repositories/live_chapter_repository.dart | 165 ++++ .../data/repositories/stories_repository.dart | 740 ++++++++++++++++ .../whisper_transcription_service.dart | 89 ++ .../stories/model}/chapter.dart | 4 +- .../stories/model/chapter_player_state.dart | 11 + .../chapter_player_state.freezed.dart | 274 ++++++ .../live_chapter_attendees_model.freezed.dart | 0 .../live_chapter_attendees_model.g.dart | 0 .../generated/live_chapter_model.freezed.dart | 0 .../generated/live_chapter_model.g.dart | 0 .../generated/live_chapter_state.freezed.dart | 298 +++++++ .../generated/stories_failure.freezed.dart | 344 ++++++++ .../generated/story_detail_state.freezed.dart | 310 +++++++ .../generated/story_search_state.freezed.dart | 286 ++++++ .../model}/live_chapter_attendees_model.dart | 0 .../stories/model}/live_chapter_model.dart | 2 +- .../stories/model/live_chapter_state.dart | 12 + .../stories/model/stories_failure.dart | 10 + lib/features/stories/model/story.dart | 68 ++ .../stories/model/story_detail_state.dart | 16 + .../stories/model/story_search_state.dart | 13 + lib/features/stories/stories_routes.dart | 26 + .../stories/view/pages/add_chapter_page.dart | 141 +++ .../stories/view/pages/category_page.dart | 83 ++ .../view/pages/chapter_play_page.dart} | 176 ++-- .../view/pages/create_chapter_page.dart | 283 ++++++ .../stories/view/pages/create_story_page.dart | 311 +++++++ .../stories/view/pages/explore_page.dart | 280 ++++++ .../stories/view/pages/live_chapter_page.dart | 245 ++++++ .../stories/view/pages/story_page.dart | 376 ++++++++ .../pages/verify_chapter_details_page.dart | 289 +++++++ lib/features/stories/view/story_format.dart | 11 + .../stories/view/widgets/category_card.dart | 49 ++ .../view}/widgets/chapter_list_tile.dart | 41 +- .../stories/view/widgets/chapter_player.dart | 192 +++++ .../view}/widgets/filtered_list_tile.dart | 43 +- .../stories/view}/widgets/like_button.dart | 33 +- .../widgets/live_chapter_attendee_block.dart | 81 ++ .../view}/widgets/live_chapter_header.dart | 15 +- .../view}/widgets/live_chapter_list_tile.dart | 31 +- .../widgets/start_live_chapter_dialog.dart | 134 +++ .../stories/view}/widgets/story_card.dart | 28 +- .../view}/widgets/story_list_tile.dart | 47 +- .../viewmodel/category_stories_notifier.dart | 19 + .../viewmodel/chapter_player_notifier.dart | 69 ++ .../viewmodel/create_story_notifier.dart | 60 ++ .../viewmodel/explore_stories_notifier.dart | 23 + .../category_stories_notifier.g.dart | 99 +++ .../generated/chapter_player_notifier.g.dart | 107 +++ .../generated/create_story_notifier.g.dart | 61 ++ .../generated/explore_stories_notifier.g.dart | 54 ++ .../generated/live_chapter_notifier.g.dart | 62 ++ .../generated/story_detail_notifier.g.dart | 100 +++ .../generated/story_search_notifier.g.dart | 62 ++ .../viewmodel/live_chapter_notifier.dart | 215 +++++ .../viewmodel/story_detail_notifier.dart | 56 ++ .../viewmodel/story_search_notifier.dart | 23 + .../viewmodel/whisper_model_notifier.dart | 33 + lib/main.dart | 8 - lib/models/story.dart | 45 - lib/routes/app_router.dart | 26 +- lib/services/room_service.dart | 70 -- lib/utils/constants.dart | 4 - lib/utils/enums/audio_format.dart | 12 + lib/utils/enums/lyrics_format.dart | 6 + lib/views/screens/add_chapter_screen.dart | 177 ---- lib/views/screens/app_preferences_screen.dart | 38 +- lib/views/screens/category_screen.dart | 85 -- lib/views/screens/create_chapter_screen.dart | 277 ------ lib/views/screens/create_story_screen.dart | 363 -------- lib/views/screens/explore_screen.dart | 358 -------- lib/views/screens/followers_screen.dart | 2 +- lib/views/screens/live_chapter_screen.dart | 273 ------ lib/views/screens/story_screen.dart | 488 ----------- lib/views/screens/tabview_screen.dart | 4 +- .../verify_chapter_details_screen.dart | 306 ------- lib/views/widgets/audio_selector_dialog.dart | 257 ------ lib/views/widgets/category_card.dart | 63 -- lib/views/widgets/chapter_player.dart | 218 ----- .../widgets/live_chapter_attendee_block.dart | 140 --- .../widgets/start_live_chapter_dialog.dart | 103 --- .../audio_device_controller_test.dart | 194 ----- .../chapter_player_controller_test.dart | 45 - .../explore_story_controller_test.dart | 341 -------- .../explore_story_controller_test.mocks.dart | 762 ---------------- .../profile/data/profile_repository_test.dart | 2 +- .../profile/fake_profile_repository.dart | 2 +- .../viewmodel/profile_view_notifier_test.dart | 2 +- 104 files changed, 6557 insertions(+), 6448 deletions(-) delete mode 100644 lib/bindings/create_story_binding.dart delete mode 100644 lib/controllers/audio_device_controller.dart delete mode 100644 lib/controllers/chapter_player_controller.dart delete mode 100644 lib/controllers/explore_story_controller.dart delete mode 100644 lib/controllers/live_chapter_controller.dart delete mode 100644 lib/controllers/livekit_controller.dart delete mode 100644 lib/controllers/whisper_transcription_controller.dart create mode 100644 lib/features/stories/data/repositories/generated/live_chapter_repository.g.dart create mode 100644 lib/features/stories/data/repositories/generated/stories_repository.g.dart create mode 100644 lib/features/stories/data/repositories/live_chapter_repository.dart create mode 100644 lib/features/stories/data/repositories/stories_repository.dart create mode 100644 lib/features/stories/data/services/whisper_transcription_service.dart rename lib/{models => features/stories/model}/chapter.dart (96%) create mode 100644 lib/features/stories/model/chapter_player_state.dart create mode 100644 lib/features/stories/model/generated/chapter_player_state.freezed.dart rename lib/{models => features/stories/model}/generated/live_chapter_attendees_model.freezed.dart (100%) rename lib/{models => features/stories/model}/generated/live_chapter_attendees_model.g.dart (100%) rename lib/{models => features/stories/model}/generated/live_chapter_model.freezed.dart (100%) rename lib/{models => features/stories/model}/generated/live_chapter_model.g.dart (100%) create mode 100644 lib/features/stories/model/generated/live_chapter_state.freezed.dart create mode 100644 lib/features/stories/model/generated/stories_failure.freezed.dart create mode 100644 lib/features/stories/model/generated/story_detail_state.freezed.dart create mode 100644 lib/features/stories/model/generated/story_search_state.freezed.dart rename lib/{models => features/stories/model}/live_chapter_attendees_model.dart (100%) rename lib/{models => features/stories/model}/live_chapter_model.dart (90%) create mode 100644 lib/features/stories/model/live_chapter_state.dart create mode 100644 lib/features/stories/model/stories_failure.dart create mode 100644 lib/features/stories/model/story.dart create mode 100644 lib/features/stories/model/story_detail_state.dart create mode 100644 lib/features/stories/model/story_search_state.dart create mode 100644 lib/features/stories/stories_routes.dart create mode 100644 lib/features/stories/view/pages/add_chapter_page.dart create mode 100644 lib/features/stories/view/pages/category_page.dart rename lib/{views/screens/chapter_play_screen.dart => features/stories/view/pages/chapter_play_page.dart} (51%) create mode 100644 lib/features/stories/view/pages/create_chapter_page.dart create mode 100644 lib/features/stories/view/pages/create_story_page.dart create mode 100644 lib/features/stories/view/pages/explore_page.dart create mode 100644 lib/features/stories/view/pages/live_chapter_page.dart create mode 100644 lib/features/stories/view/pages/story_page.dart create mode 100644 lib/features/stories/view/pages/verify_chapter_details_page.dart create mode 100644 lib/features/stories/view/story_format.dart create mode 100644 lib/features/stories/view/widgets/category_card.dart rename lib/{views => features/stories/view}/widgets/chapter_list_tile.dart (52%) create mode 100644 lib/features/stories/view/widgets/chapter_player.dart rename lib/{views => features/stories/view}/widgets/filtered_list_tile.dart (68%) rename lib/{views => features/stories/view}/widgets/like_button.dart (76%) create mode 100644 lib/features/stories/view/widgets/live_chapter_attendee_block.dart rename lib/{views => features/stories/view}/widgets/live_chapter_header.dart (73%) rename lib/{views => features/stories/view}/widgets/live_chapter_list_tile.dart (56%) create mode 100644 lib/features/stories/view/widgets/start_live_chapter_dialog.dart rename lib/{views => features/stories/view}/widgets/story_card.dart (59%) rename lib/{views => features/stories/view}/widgets/story_list_tile.dart (64%) create mode 100644 lib/features/stories/viewmodel/category_stories_notifier.dart create mode 100644 lib/features/stories/viewmodel/chapter_player_notifier.dart create mode 100644 lib/features/stories/viewmodel/create_story_notifier.dart create mode 100644 lib/features/stories/viewmodel/explore_stories_notifier.dart create mode 100644 lib/features/stories/viewmodel/generated/category_stories_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/chapter_player_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/create_story_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/explore_stories_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/generated/story_search_notifier.g.dart create mode 100644 lib/features/stories/viewmodel/live_chapter_notifier.dart create mode 100644 lib/features/stories/viewmodel/story_detail_notifier.dart create mode 100644 lib/features/stories/viewmodel/story_search_notifier.dart create mode 100644 lib/features/stories/viewmodel/whisper_model_notifier.dart delete mode 100644 lib/models/story.dart delete mode 100644 lib/services/room_service.dart create mode 100644 lib/utils/enums/audio_format.dart create mode 100644 lib/utils/enums/lyrics_format.dart delete mode 100644 lib/views/screens/add_chapter_screen.dart delete mode 100644 lib/views/screens/category_screen.dart delete mode 100644 lib/views/screens/create_chapter_screen.dart delete mode 100644 lib/views/screens/create_story_screen.dart delete mode 100644 lib/views/screens/explore_screen.dart delete mode 100644 lib/views/screens/live_chapter_screen.dart delete mode 100644 lib/views/screens/story_screen.dart delete mode 100644 lib/views/screens/verify_chapter_details_screen.dart delete mode 100644 lib/views/widgets/audio_selector_dialog.dart delete mode 100644 lib/views/widgets/category_card.dart delete mode 100644 lib/views/widgets/chapter_player.dart delete mode 100644 lib/views/widgets/live_chapter_attendee_block.dart delete mode 100644 lib/views/widgets/start_live_chapter_dialog.dart delete mode 100644 test/controllers/audio_device_controller_test.dart delete mode 100644 test/controllers/chapter_player_controller_test.dart delete mode 100644 test/controllers/explore_story_controller_test.dart delete mode 100644 test/controllers/explore_story_controller_test.mocks.dart diff --git a/lib/bindings/create_story_binding.dart b/lib/bindings/create_story_binding.dart deleted file mode 100644 index b2c15aa4..00000000 --- a/lib/bindings/create_story_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; - -class CreateStoryBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => ExploreStoryController()); - } -} diff --git a/lib/controllers/audio_device_controller.dart b/lib/controllers/audio_device_controller.dart deleted file mode 100644 index f9ab1e00..00000000 --- a/lib/controllers/audio_device_controller.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; - -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; -import 'package:get/get.dart'; -import 'package:resonate/models/audio_device.dart'; -import 'package:resonate/utils/enums/audio_device_enum.dart'; - -class AudioDeviceController extends GetxController { - final RxList audioOutputDevices = [].obs; - final Rx selectedAudioOutput = Rx(null); - - Timer? _deviceEnumerationTimer; - - @override - void onInit() async { - super.onInit(); - await enumerateDevices(); - _deviceEnumerationTimer = Timer.periodic( - const Duration(seconds: 5), - (_) async => await enumerateDevices(), - ); - } - - @override - void onClose() { - _deviceEnumerationTimer?.cancel(); - super.onClose(); - } - - Future enumerateDevices() async { - try { - final devices = await webrtc.navigator.mediaDevices.enumerateDevices(); - final outputs = devices - .map((device) => AudioDevice.fromMediaDeviceInfo(device)) - .where((d) => d.isAudioOutput) - .toList(); - audioOutputDevices.value = outputs; - selectedAudioOutput.value ??= outputs.firstOrNull; - log('Enumerated ${outputs.length} output devices'); - } catch (e) { - log('Error enumerating devices: $e'); - } - } - - Future selectAudioOutput(AudioDevice device) async { - try { - await webrtc.Helper.selectAudioOutput(device.deviceId); - selectedAudioOutput.value = device; - log('Selected audio output: ${device.label}'); - } catch (e) { - log('Error selecting audio output: $e'); - if (Get.testMode) { - selectedAudioOutput.value = device; - } - } - } - - String getDeviceName(AudioDevice device) { - final deviceType = device.deviceType; - log('Device label: "${device.label}" -> type: ${deviceType.name}'); - - if (deviceType == AudioDeviceType.bluetoothAudio) { - return device.label; - } else if (deviceType == AudioDeviceType.unknown && - device.label.isNotEmpty) { - return device.label; - } - return device.label.isNotEmpty ? deviceType.displayName : 'Unknown Device'; - } - - Future refreshDevices() async { - await enumerateDevices(); - } -} diff --git a/lib/controllers/chapter_player_controller.dart b/lib/controllers/chapter_player_controller.dart deleted file mode 100644 index ab8c1da0..00000000 --- a/lib/controllers/chapter_player_controller.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:get/get.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter_lyric/flutter_lyric.dart'; - -class ChapterPlayerController extends GetxController { - Rx currentPage = 0.obs; - Rx sliderProgress = 0.0.obs; - Rx isPlaying = false.obs; - AudioPlayer? audioPlayer; - late Duration chapterDuration; - final LyricController lyricController = LyricController(); - - void initialize( - AudioPlayer player, - String lyrics, - Duration duration, - ) { - audioPlayer = player; - chapterDuration = duration; - lyricController.loadLyric(lyrics); - audioPlayer?.setReleaseMode(ReleaseMode.stop); - - audioPlayer?.onPositionChanged.listen((Duration event) { - sliderProgress.value = event.inMilliseconds.toDouble(); - lyricController.setProgress(event); - }); - - audioPlayer?.onPlayerStateChanged.listen((PlayerState state) { - isPlaying.value = state == PlayerState.playing; - }); - } - - void togglePlayPause() { - if (isPlaying.value) { - audioPlayer?.pause(); - } else { - audioPlayer?.resume(); - } - isPlaying.value = !isPlaying.value; - } - - @override - void onClose() { - audioPlayer?.release(); - lyricController.dispose(); - super.onClose(); - } -} diff --git a/lib/controllers/explore_story_controller.dart b/lib/controllers/explore_story_controller.dart deleted file mode 100644 index 61660cbb..00000000 --- a/lib/controllers/explore_story_controller.dart +++ /dev/null @@ -1,815 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io' as io; -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:audio_metadata_reader/audio_metadata_reader.dart'; -import 'package:flutter/material.dart' hide Row; -import 'package:get/get.dart'; -import 'package:meilisearch/meilisearch.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/models/live_chapter_attendees_model.dart'; -import 'package:resonate/models/live_chapter_model.dart'; -import 'package:resonate/models/resonate_user.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/story_category.dart'; - -class ExploreStoryController extends GetxController { - final TablesDB tables; - final Storage storage; - AuthUser get authStateController => requireCurrentAuthUser; - final Functions functions; - RxList recommendedStories = [].obs; - RxList userCreatedStories = [].obs; - RxList userLikedStories = [].obs; - RxList searchResponseStories = [].obs; - RxList openedCategotyStories = [].obs; - RxList searchResponseUsers = [].obs; - final MeiliSearchClient client = MeiliSearchClient( - meilisearchEndpoint, - meilisearchApiKey, - ); - late final MeiliSearchIndex storyIndex; - late final MeiliSearchIndex userIndex; - - Rx isLoadingRecommendedStories = false.obs; - Rx isLoadingCategoryPage = false.obs; - Rx isLoadingStoryPage = false.obs; - - Rx isSearching = false.obs; - Rx searchBarIsEmpty = true.obs; - - ExploreStoryController({ - TablesDB? tables, - Storage? storage, - Functions? functions, - }) : tables = tables ?? AppwriteService.getTables(), - storage = storage ?? AppwriteService.getStorage(), - functions = functions ?? AppwriteService.getFunctions(); - - @override - void onInit() async { - super.onInit(); - storyIndex = client.index('stories'); - userIndex = client.index('users'); - await fetchStoryRecommendation(); - await fetchUserCreatedStories(); - await fetchUserLikedStories(); - } - - Future fetchMoreDetailsForSelectedStory(Story story) async { - isLoadingStoryPage.value = true; - - List storyChapters = []; - try { - storyChapters = await fetchChaptersForStory(story.storyId); - } on AppwriteException catch (e) { - log( - "failed to fetch story chapters for selected search result query: ${e.message}", - ); - } - - bool hasUserLiked = false; - try { - hasUserLiked = await checkIfStoryLikedByUser(story.storyId); - } on AppwriteException catch (e) { - log( - "failed to check if user liked story for selected search result query ${e.message}", - ); - } - Row doc = await tables.getRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: story.storyId, - queries: [ - Query.select(["likes"]), - ], - ); - - int likes = doc.data['likes']; - - story.chapters = storyChapters; - story.isLikedByCurrentUser.value = hasUserLiked; - story.likesCount.value = likes; - - final liveChapter = await fetchLiveChapterForStory(story.storyId); - story.liveChapter = liveChapter; - isLoadingStoryPage.value = false; - } - - Future updateLikesCountAndUserLikeStatus(Story story) async { - Row doc = await tables.getRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: story.storyId, - queries: [ - Query.select(["likes"]), - ], - ); - - int likes = doc.data['likes']; - - bool hasUserLiked = await checkIfStoryLikedByUser(story.storyId); - - story.likesCount.value = likes; - story.isLikedByCurrentUser.value = hasUserLiked; - } - - Future searchStories(String query) async { - if (isUsingMeilisearch) { - final meilisearchResult = await storyIndex.search( - query, - SearchQuery( - attributesToHighlight: ['title', 'creatorName', 'description'], - ), - ); - searchResponseStories.value = await convertMeilisearchResultsToStoryList( - meilisearchResult.hits, - ); - } else { - List storyDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [ - Query.or([ - Query.search('title', query), - Query.search('creatorName', query), - Query.search('description', query), - ]), - Query.limit(16), - ], - ) - .then((value) => value.rows); - - searchResponseStories.value = await convertAppwriteDocListToStoryList( - storyDocuments, - ); - } - } - - Future searchUsers(String query) async { - if (isUsingMeilisearch) { - final meilisearchResult = await userIndex.search( - query, - SearchQuery(attributesToHighlight: ['name', 'username']), - ); - searchResponseUsers.value = convertMeilisearchResultsToUserList( - meilisearchResult.hits, - ); - } else { - List userDocuments = await tables - .listRows( - databaseId: userDatabaseID, - tableId: usersTableID, - queries: [ - Query.or([ - Query.search('name', query), - Query.search('username', query), - ]), - Query.notEqual('\$id', authStateController.uid), - Query.limit(16), - ], - ) - .then((value) => value.rows); - - searchResponseUsers.value = convertAppwriteDocListToUserList( - userDocuments, - ); - } - - log(searchResponseUsers.toString()); - } - - List convertAppwriteDocListToUserList(List userDocuments) { - return userDocuments.map((doc) { - // log(doc.data.toString()); - final userData = doc.data; - userData['docId'] = doc.$id; - userData['uid'] = doc.$id; - userData['userName'] = userData['username']; - userData['userRating'] = - userData['ratingTotal'] / userData['ratingCount']; - log(userData['userRating'].toString()); - Future.delayed(Duration(seconds: 1)); - ResonateUser user = ResonateUser.fromJson(userData); - - return user; - }).toList(); - } - - List convertMeilisearchResultsToUserList( - List> meilisearchHits, - ) { - return meilisearchHits.map((doc) { - // log(doc.data.toString()); - final userData = doc; - userData['docId'] = doc['\$id']; - userData['uid'] = doc['\$id']; - userData['userName'] = userData['username']; - userData['userRating'] = - userData['ratingTotal'] / userData['ratingCount']; - log(userData['userRating'].toString()); - Future.delayed(Duration(seconds: 1)); - ResonateUser user = ResonateUser.fromJson(userData); - - return user; - }).toList(); - } - - Future updateStoriesPlayDurationLength( - List chapters, - String storyId, - ) async { - int totalStoryDuration = chapters.fold(0, (sum, chapter) { - return sum + chapter.playDuration; - }); - - try { - await tables.updateRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: storyId, - data: {"playDuration": totalStoryDuration}, - ); - } on AppwriteException catch (e) { - log("Failed to update story duration: ${e.message}"); - } - } - - Future pushChaptersToStory( - List chapters, - String storyId, - ) async { - for (Chapter chapter in chapters) { - String colorString = chapter.tintColor.toHex(leadingHashSign: false); - - String coverImgUrl = chapter.coverImageUrl; - - if (!coverImgUrl.contains("http")) { - coverImgUrl = await uploadFileToAppwriteGetUrl( - storyBucketId, - chapter.chapterId, - coverImgUrl, - "story cover", - ); - } - - String audioFileId = 'audioFor${chapter.chapterId}'; - - String audioFileUrl = await uploadFileToAppwriteGetUrl( - storyBucketId, - audioFileId, - chapter.audioFileUrl, - "audio file", - ); - await tables.createRow( - databaseId: storyDatabaseId, - tableId: chapterTableId, - rowId: chapter.chapterId, - data: { - 'title': chapter.title, - 'description': chapter.description, - 'coverImgUrl': coverImgUrl, - 'lyrics': chapter.lyrics, - 'playDuration': chapter.playDuration, - 'tintColor': colorString, - 'storyId': storyId, - 'audioFileUrl': audioFileUrl, - }, - ); - } - } - - Future fetchStoryByCategory(StoryCategory category) async { - isLoadingCategoryPage.value = true; - List storyDocuments = []; - try { - storyDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.limit(15), Query.equal('category', category.name)], - ) - .then((value) => value.rows); - } on AppwriteException catch (e) { - log('Failed to fetch stories for categories: ${e.message}'); - } - - openedCategotyStories.value = await convertAppwriteDocListToStoryList( - storyDocuments, - ); - isLoadingCategoryPage.value = false; - } - - Future uploadFileToAppwriteGetUrl( - String bucketId, - String fileId, - String filePath, - String fileIdentificationForError, - ) async { - try { - await storage.createFile( - bucketId: storyBucketId, - fileId: fileId, - file: InputFile.fromPath(path: filePath), - ); - } on AppwriteException catch (e) { - log( - "failed to upload $fileIdentificationForError to appwrite: ${e.message}", - ); - Get.snackbar( - "Upload failed", - "Could not upload $fileIdentificationForError: ${e.message}", - snackPosition: SnackPosition.BOTTOM, - duration: const Duration(seconds: 6), - ); - rethrow; - } - - return "$appwriteEndpoint/storage/buckets/$bucketId/files/$fileId/view?project=$appwriteProjectId"; - } - - Future createChapter( - String title, - String description, - String coverImgPath, - String audioFilePath, - String lyricsFilePath, - ) async { - final track = io.File(audioFilePath); - final metadata = readMetadata(track); - int playDuration = metadata.duration?.inMilliseconds ?? 0; - String chapterId = ID.unique(); - Color primaryColor; - - if (!coverImgPath.contains('http')) { - ColorScheme imageColorScheme = await ColorScheme.fromImageProvider( - provider: FileImage(io.File(coverImgPath)), - ); - - primaryColor = imageColorScheme.primary; - } else { - primaryColor = const Color(0xffcbc6c6); - } - String lyrics = ''; - if (lyricsFilePath != '') { - lyrics = await io.File(lyricsFilePath).readAsString(); - } - - // coverImageUrl and audioFileUrl recieve paths while the chapter creation process - // as cannot push files to storage to get URL unless user is final on creating a story - - return Chapter( - chapterId, - title, - coverImgPath, - description, - lyrics, - audioFilePath, - playDuration, - primaryColor, - ); - } - - Future createStory( - String title, - String desciption, - StoryCategory category, - String coverImgRef, - int storyPlayDuration, - List chapters, - ) async { - String storyId = ID.unique(); - - String coverImgUrl = coverImgRef; - Color primaryColor; - - if (!coverImgUrl.contains("http")) { - ColorScheme imageColorScheme = await ColorScheme.fromImageProvider( - provider: FileImage(io.File(coverImgUrl)), - ); - primaryColor = imageColorScheme.primary; - coverImgUrl = await uploadFileToAppwriteGetUrl( - storyBucketId, - storyId, - coverImgRef, - "story cover", - ); - } else { - primaryColor = const Color(0xffcbc6c6); - } - - await pushChaptersToStory(chapters, storyId); - - String colorString = primaryColor.toHex(leadingHashSign: false); - - try { - await tables.createRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: storyId, - data: { - 'title': title, - 'description': desciption, - 'category': category.name, - 'coverImgUrl': coverImgUrl, - 'creatorId': authStateController.uid, - 'creatorName': authStateController.displayName, - 'creatorImgUrl': authStateController.profileImageUrl, - 'likes': 0, - 'playDuration': storyPlayDuration, - 'tintColor': colorString, - }, - ); - //Don't send request to function if no followers - if (authStateController.followers.isNotEmpty) { - log('Sending notification for created story'); - var body = json.encode({ - 'creatorId': authStateController.uid, - 'payload': { - 'title': 'New story added!', - 'body': - "A new story was just added by ${authStateController.displayName}: $title", - }, - }); - var results = await functions.createExecution( - functionId: sendStoryNotificationFunctionID, - body: body.toString(), - ); - log(results.status.name); - } - } on AppwriteException catch (e) { - log("failed to upload story to appwrite: ${e.message}"); - } - - await fetchUserCreatedStories(); - await fetchStoryRecommendation(); - } - - Future fetchUserLikedStories() async { - List userLikedDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: likeTableId, - queries: [Query.equal('uId', authStateController.uid)], - ) - .then((value) => value.rows); - - List userLikedStoriesDocuments = await Future.wait( - userLikedDocuments.map((value) async { - return await tables.getRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: value.data['storyId'], - ); - }).toList(), - ); - - userLikedStories.value = await convertAppwriteDocListToStoryList( - userLikedStoriesDocuments, - ); - } - - Future deleteStory(Story story) async { - try { - await storage.deleteFile(bucketId: storyBucketId, fileId: story.storyId); - } on AppwriteException catch (e) { - log('failed to delete story cover image ${e.message}'); - } - - try { - await deleteAllStoryChapters(story.chapters); - } on AppwriteException catch (e) { - log('failed to delete all story chapters ${e.message}'); - } - - try { - await deleteAllStoryLikes(story.storyId); - } on AppwriteException catch (e) { - log('failed to delete all story likes: ${e.message}'); - } - - try { - await tables.deleteRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: story.storyId, - ); - } on AppwriteException catch (e) { - log("failed to delete a document: ${e.message}"); - } - - fetchUserCreatedStories(); - } - - Future deleteAllStoryLikes(String storyId) async { - List storyLikeDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: likeTableId, - queries: [Query.equal('storyId', storyId)], - ) - .then((value) => value.rows); - - for (Row like in storyLikeDocuments) { - await tables.deleteRow( - databaseId: storyDatabaseId, - tableId: likeTableId, - rowId: like.$id, - ); - } - } - - Future deleteChapter(Chapter chapter) async { - try { - await storage.deleteFile( - bucketId: storyBucketId, - fileId: chapter.chapterId, - ); - } on AppwriteException catch (e) { - log("failed to delete chapter cover img ${e.message}"); - } - try { - await storage.deleteFile( - bucketId: storyBucketId, - fileId: 'audioFor${chapter.chapterId}', - ); - } on AppwriteException catch (e) { - log("failed to delete chapter audio file ${e.message}"); - } - try { - await tables.deleteRow( - databaseId: storyDatabaseId, - tableId: chapterTableId, - rowId: chapter.chapterId, - ); - } on AppwriteException catch (e) { - log("failed to delete chapter document ${e.message}"); - } - } - - Future deleteAllStoryChapters(List chapters) async { - for (Chapter chapter in chapters) { - await deleteChapter(chapter); - } - } - - Future fetchUserCreatedStories() async { - List storyDocuments = []; - try { - storyDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', authStateController.uid)], - ) - .then((value) => value.rows); - } on AppwriteException catch (e) { - log('Failed to fetch user created stories: ${e.message}'); - } - - userCreatedStories.value = await convertAppwriteDocListToStoryList( - storyDocuments, - ); - } - - Future likeStoryFromUserAccount(Story story) async { - try { - await tables.createRow( - databaseId: storyDatabaseId, - tableId: likeTableId, - rowId: ID.unique(), - data: {'uId': authStateController.uid, 'storyId': story.storyId}, - ); - } on AppwriteException catch (e) { - log('Failed to like a story: ${e.message}'); - } - - try { - await tables.updateRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: story.storyId, - data: {"likes": story.likesCount.value + 1}, - ); - } on AppwriteException catch (e) { - log("Failed to add one story like: ${e.message}"); - } - - fetchUserLikedStories(); - } - - Future unlikeStoryFromUserAccount(Story story) async { - List userLikeDocuments = []; - try { - userLikeDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: likeTableId, - queries: [ - Query.and([ - Query.equal('uId', authStateController.uid), - Query.equal('storyId', story.storyId), - ]), - ], - ) - .then((value) => value.rows); - } on AppwriteException catch (e) { - log('Failed to fetch Like Document: ${e.message}'); - } - - try { - await tables.deleteRow( - databaseId: storyDatabaseId, - tableId: likeTableId, - rowId: userLikeDocuments.first.$id, - ); - } on AppwriteException catch (e) { - log('Failed to Unlike i.e delete Like Document: ${e.message}'); - } - - try { - await tables.updateRow( - databaseId: storyDatabaseId, - tableId: storyTableId, - rowId: story.storyId, - data: {"likes": story.likesCount.value - 1}, - ); - } on AppwriteException catch (e) { - log("Failed to reduce one story like: ${e.message}"); - } - - fetchUserLikedStories(); - } - - Future> fetchChaptersForStory(String storyId) async { - List chapterDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: chapterTableId, - queries: [Query.equal('storyId', storyId)], - ) - .then((value) => value.rows); - - List currentStoryChapters = chapterDocuments.map((value) { - Color tintColor = Color(int.parse("0xff${value.data['tintColor']}")); - return Chapter( - value.$id, - value.data['title'], - value.data['coverImgUrl'], - value.data['description'], - value.data['lyrics'], - value.data['audioFileUrl'], - value.data['playDuration'], - tintColor, - ); - }).toList(); - - return currentStoryChapters; - } - - Future fetchLiveChapterForStory(String storyId) async { - List liveStoryDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: liveChaptersTableId, - queries: [Query.equal('storyId', storyId)], - ) - .then((value) => value.rows); - if (liveStoryDocuments.isEmpty) { - return null; - } - - final attendeesDocument = await tables.getRow( - databaseId: userDatabaseID, - tableId: liveChapterAttendeesTableId, - rowId: liveStoryDocuments.first.$id, - queries: [Query.select(["*", "users.*"])], - ); - - final attendeesModel = LiveChapterAttendeesModel.fromJson( - attendeesDocument.data, - ); - final liveChapterModel = LiveChapterModel.fromJson( - liveStoryDocuments.first.data, - ).copyWith(attendees: attendeesModel); - - return liveChapterModel; - } - - Future checkIfStoryLikedByUser(String storyId) async { - List userLikeDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: likeTableId, - queries: [ - Query.and([ - Query.equal('uId', authStateController.uid), - Query.equal('storyId', storyId), - ]), - ], - ) - .then((value) => value.rows); - - return userLikeDocuments.isNotEmpty; - } - - Future fetchStoryRecommendation() async { - isLoadingRecommendedStories.value = true; - List storyDocuments = []; - try { - storyDocuments = await tables - .listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.limit(10)], - ) - .then((value) => value.rows); - } on AppwriteException catch (e) { - log('Failed to fetch stories: ${e.message}'); - } - - recommendedStories.value = await convertAppwriteDocListToStoryList( - storyDocuments, - ); - isLoadingRecommendedStories.value = false; - } - - Future> convertAppwriteDocListToStoryList( - List storyDocuments, - ) async { - return await Future.wait( - storyDocuments.map((value) async { - StoryCategory category = StoryCategory.values.byName( - value.data['category'], - ); - - Color tintColor = Color(int.parse("0xff${value.data['tintColor']}")); - - return Story( - title: value.data['title'], - storyId: value.$id, - description: value.data['description'], - userIsCreator: value.data['creatorId'] == authStateController.uid, - category: category, - coverImageUrl: value.data['coverImgUrl'], - creatorId: value.data['creatorId'], - creatorName: value.data['creatorName'], - creatorImgUrl: value.data['creatorImgUrl'], - creationDate: DateTime.parse(value.$createdAt), - likesCount: value.data['likes'], - isLikedByCurrentUser: false, - playDuration: value.data['playDuration'], - tintColor: tintColor, - chapters: [], - ); - }).toList(), - ); - } - - Future> convertMeilisearchResultsToStoryList( - List> meilisearchHits, - ) async { - return await Future.wait( - meilisearchHits.map((value) async { - StoryCategory category = StoryCategory.values.byName(value['category']); - - Color tintColor = Color(int.parse("0xff${value['tintColor']}")); - - return Story( - title: value['title'], - storyId: value['\$id'], - description: value['description'], - userIsCreator: value['creatorId'] == authStateController.uid, - category: category, - coverImageUrl: value['coverImgUrl'], - creatorId: value['creatorId'], - creatorName: value['creatorName'], - creatorImgUrl: value['creatorImgUrl'], - creationDate: DateTime.parse(value['\$createdAt']), - likesCount: value['likes'], - isLikedByCurrentUser: false, - playDuration: value['playDuration'], - tintColor: tintColor, - chapters: [], - ); - }).toList(), - ); - } -} - -extension HexColor on Color { - /// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`). - String toHex({bool leadingHashSign = true}) => - '${leadingHashSign ? '#' : ''}' - '${(a * 255).toInt().toRadixString(16).padLeft(2, '0')}' - '${r.toInt().toRadixString(16).padLeft(2, '0')}' - '${g.toInt().toRadixString(16).padLeft(2, '0')}' - '${b.toInt().toRadixString(16).padLeft(2, '0')}'; -} diff --git a/lib/controllers/live_chapter_controller.dart b/lib/controllers/live_chapter_controller.dart deleted file mode 100644 index 06567a98..00000000 --- a/lib/controllers/live_chapter_controller.dart +++ /dev/null @@ -1,312 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io' as io; - -import 'package:appwrite/appwrite.dart'; -import 'package:audio_metadata_reader/audio_metadata_reader.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/core/container.dart'; -import 'package:resonate/features/auth/model/auth_user.dart'; -import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/controllers/whisper_transcription_controller.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/models/live_chapter_attendees_model.dart'; -import 'package:resonate/models/live_chapter_model.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/services/appwrite_service.dart'; -import 'package:resonate/services/room_service.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/views/widgets/loading_dialog.dart'; - -class LiveChapterController extends GetxController { - Rx liveChapterModel = Rx(null); - final TablesDB tables; - final Realtime realtime; - final Functions functions; - AuthUser get authStateController => requireCurrentAuthUser; - RxBool isMicOn = false.obs; - RealtimeSubscription? liveChapterAttendeesSubscription; - - LiveChapterController({ - TablesDB? tables, - Realtime? realtime, - Functions? functions, - }) : tables = tables ?? AppwriteService.getTables(), - realtime = realtime ?? AppwriteService.getRealtime(), - functions = functions ?? AppwriteService.getFunctions(); - - void listenForAttendeesAdded() async { - String channel = - "databases.$userDatabaseID.tables.$liveChapterAttendeesTableId.rows.${liveChapterModel.value!.id}"; - liveChapterAttendeesSubscription = realtime.subscribe([channel]); - liveChapterAttendeesSubscription?.stream.listen((data) async { - if (data.payload.isNotEmpty) { - if (data.events.first.endsWith('.update')) { - log("update detected"); - final newLiveChapterAttendees = LiveChapterAttendeesModel.fromJson( - data.payload, - ); - liveChapterModel.value = liveChapterModel.value!.copyWith( - attendees: newLiveChapterAttendees, - ); - } - if (data.events.first.endsWith('.delete')) { - log("delete detected"); - if (!isAdmin) { - await liveChapterAttendeesSubscription?.close(); - await Get.delete(force: true); - - appRouter.go(RoutePaths.tabview); - Get.delete(); - } - } - } - }); - } - - Future startLiveChapter( - String roomId, - String chapterTitle, - String chapterDescription, - String storyId, - String storyName, - ) async { - try { - final liveChapterData = LiveChapterModel( - livekitRoomId: roomId, - authorUid: authStateController.uid, - authorProfileImageUrl: authStateController.profileImageUrl!, - authorName: authStateController.displayName, - chapterTitle: chapterTitle, - chapterDescription: chapterDescription, - storyId: storyId, - followersFCMToken: authStateController.followers - .map((e) => e.fcmToken) - .toList(), - attendees: LiveChapterAttendeesModel( - liveChapterId: roomId, - users: List.empty(), - userIds: List.empty(), - ), - id: roomId, - ); - await tables.createRow( - databaseId: storyDatabaseId, - tableId: liveChaptersTableId, - rowId: roomId, - data: liveChapterData.toJson(), - ); - await tables.createRow( - databaseId: userDatabaseID, - tableId: liveChapterAttendeesTableId, - rowId: roomId, - data: liveChapterData.attendees!.toJson(), - ); - await RoomService.createLiveChapterRoom( - appwriteRoomId: liveChapterData.livekitRoomId, - adminUid: authStateController.uid, - ); - liveChapterModel.value = liveChapterData; - if (authStateController.followers.isNotEmpty) { - log('Sending notification for created story'); - var body = json.encode({ - 'creatorId': authStateController.uid, - 'payload': { - 'title': 'Live Chapter Starting!', - 'body': - "${authStateController.displayName} is starting a Live Chapter in ${storyName}: ${liveChapterData.chapterTitle}. Tune In!", - }, - }); - var results = await functions.createExecution( - functionId: sendStoryNotificationFunctionID, - body: body.toString(), - ); - log(results.status.name); - } - listenForAttendeesAdded(); - appRouter.push(RoutePaths.liveChapterScreen); - } catch (e) { - log(e.toString()); - rethrow; - } - } - - Future joinLiveChapter( - String roomId, - - LiveChapterModel liveChapterData, - ) async { - try { - final newAttendeesModel = liveChapterData.attendees!.copyWith( - userIds: [ - ...liveChapterData.attendees!.users.map( - (element) => element["\$id"] as String, - ), - authStateController.uid, - ], - users: [ - ...liveChapterData.attendees!.users, - { - "\$id": authStateController.uid, - "name": authStateController.displayName, - "profileImageUrl": authStateController.profileImageUrl, - }, - ], - ); - log(newAttendeesModel.toJson().toString()); - - await tables.updateRow( - databaseId: userDatabaseID, - tableId: liveChapterAttendeesTableId, - rowId: roomId, - data: newAttendeesModel.toJson(), - ); - - liveChapterModel.value = liveChapterData.copyWith( - attendees: newAttendeesModel, - ); - await RoomService.joinLiveChapterRoom( - roomId: roomId, - userId: authStateController.uid, - ); - listenForAttendeesAdded(); - appRouter.push(RoutePaths.liveChapterScreen); - } catch (e) { - log(e.toString()); - rethrow; - } - } - - bool checkUserIsAdmin(String uid) { - if (liveChapterModel.value == null) return false; - return liveChapterModel.value!.authorUid == uid; - } - - bool get isAdmin { - if (liveChapterModel.value == null) return false; - return liveChapterModel.value!.authorUid == authStateController.uid; - } - - Future turnOnMic() async { - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(true); - isMicOn.value = true; - } - - Future turnOffMic() async { - await Get.find().liveKitRoom.localParticipant - ?.setMicrophoneEnabled(false); - isMicOn.value = false; - } - - void startRecording() { - Get.find().isRecording.value = true; - } - - void stopRecording() { - Get.find().isRecording.value = false; - } - - Future leaveRoom() async { - loadingDialog(Get.context!); - await liveChapterAttendeesSubscription?.close(); - final LiveChapterAttendeesModel updatedAttendees = liveChapterModel - .value! - .attendees! - .copyWith( - users: liveChapterModel.value!.attendees!.users - .where((element) => element["\$id"] != authStateController.uid) - .toList(), - userIds: liveChapterModel.value!.attendees!.userIds! - .where((element) => element != authStateController.uid) - .toList(), - ); - await tables.updateRow( - databaseId: userDatabaseID, - tableId: liveChapterAttendeesTableId, - rowId: liveChapterModel.value!.id, - data: updatedAttendees.toJson(), - ); - await Get.delete(force: true); - Get.delete(); - appRouter.go(RoutePaths.tabview); - } - - Future endLiveChapter() async { - loadingDialog(Get.context!); - - try { - Get.find().isRecording.value = false; - try { - await tables.deleteRow( - databaseId: storyDatabaseId, - tableId: liveChaptersTableId, - rowId: liveChapterModel.value!.id, - ); - await tables.deleteRow( - databaseId: userDatabaseID, - tableId: liveChapterAttendeesTableId, - rowId: liveChapterModel.value!.id, - ); - } catch (e) { - log(e.toString()); - } - final transcripController = WhisperTranscriptionController(); - final String lyrics = await transcripController.transcribeChapter( - liveChapterModel.value!.livekitRoomId, - ); - await RoomService.deleteLiveChapterRoom( - roomId: liveChapterModel.value!.livekitRoomId, - ); - - await liveChapterAttendeesSubscription?.close(); - await Get.delete(force: true); - Get.back(); - await Future.delayed(const Duration(milliseconds: 500)); - appRouter.push(RoutePaths.verifyChapterDetails, extra: lyrics); - } catch (e) { - log( - "Error in Delete Room Function (SingleRoomController): ${e.toString()}", - ); - Get.back(); - } - } - - Future createChapterFromLiveChapter( - String coverImgPath, - String audioFilePath, - String lyricsString, - ) async { - final track = io.File(audioFilePath); - final metadata = readMetadata(track); - int playDuration = metadata.duration?.inMilliseconds ?? 0; - - Color primaryColor; - - if (!coverImgPath.contains('http')) { - ColorScheme imageColorScheme = await ColorScheme.fromImageProvider( - provider: FileImage(io.File(coverImgPath)), - ); - - primaryColor = imageColorScheme.primary; - } else { - primaryColor = const Color(0xffcbc6c6); - } - - // coverImageUrl and audioFileUrl recieve paths while the chapter creation process - // as cannot push files to storage to get URL unless user is final on creating a story - - return Chapter( - liveChapterModel.value!.id, - liveChapterModel.value!.chapterTitle, - coverImgPath, - liveChapterModel.value!.chapterDescription, - lyricsString, - audioFilePath, - playDuration, - primaryColor, - ); - } -} diff --git a/lib/controllers/livekit_controller.dart b/lib/controllers/livekit_controller.dart deleted file mode 100644 index 9dcc879f..00000000 --- a/lib/controllers/livekit_controller.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'dart:developer'; -import 'dart:async'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:livekit_client/livekit_client.dart'; - -class LiveKitController extends GetxController { - late final Room liveKitRoom; - late final EventsListener listener; - final String liveKitUri; - final String roomToken; - final int maxAttempts; // Configurable max retry attempts - final Duration retryInterval; // Configurable retry delay - var reconnectAttempts = 0; - Timer? reconnectTimer; - final isConnected = false.obs; - final bool isLiveChapter; - final mediaRecorder = MediaRecorder(); - final RxBool isRecording = false.obs; - - LiveKitController({ - required this.liveKitUri, - required this.roomToken, - this.maxAttempts = 3, // Default value - this.retryInterval = const Duration(seconds: 2), // Default value - this.isLiveChapter = false, - }); - - @override - void onInit() { - super.onInit(); - _initializeConnection(); - } - - Future _initializeConnection() async { - await connectToRoom(); - if (isConnected.value) { - liveKitRoom.addListener(onRoomDidUpdate); - setUpListeners(); - if (isLiveChapter) { - listenToRecordingStateChanges(); - } - } - } - - void listenToRecordingStateChanges() async { - final storagePath = await getApplicationDocumentsDirectory(); - isRecording.listen((state) async { - if (state) { - log("Starting recording..."); - - log('Local track published: ${storagePath.path}'); - await mediaRecorder.start( - '${storagePath.path}/recordings/${liveKitRoom.name}.mp4', - audioChannel: RecorderAudioChannel.INPUT, - ); - } else { - await mediaRecorder.stop(); - log( - 'Recording stopped, file saved to: $storagePath/${liveKitRoom.name}.mp4', - ); - } - }); - } - - @override - void onClose() async { - reconnectTimer?.cancel(); - (() async { - liveKitRoom.removeListener(onRoomDidUpdate); - await listener.dispose(); - await liveKitRoom.dispose(); - })(); - super.onClose(); - } - - Future connectToRoom({bool isReconnect = false}) async { - if (!isReconnect) { - reconnectAttempts = 0; - } - while (reconnectAttempts < maxAttempts) { - Room? room; - try { - room = Room( - roomOptions: const RoomOptions( - dynacast: false, - adaptiveStream: false, - defaultVideoPublishOptions: VideoPublishOptions(simulcast: false), - ), - ); - liveKitRoom = room; - listener = liveKitRoom.createListener(); - - await liveKitRoom.connect( - liveKitUri, - roomToken, - connectOptions: const ConnectOptions(autoSubscribe: true), - ); - // Waiting for connection to stabilize - await Future.delayed(const Duration(milliseconds: 1000)); - - isConnected.value = true; - reconnectAttempts = 0; // Reset on success - log('Connected to room successfully'); - return true; - } catch (error) { - reconnectAttempts++; - log( - 'Connection attempt $reconnectAttempts/$maxAttempts failed: $error', - ); - //cleaning up failed connection so multiple room instances doesnt accumulate - if (room != null) { - try { - await room.disconnect(); - await room.dispose(); - } catch (e) { - log('Error cleaning up failed connection: $e'); - } - - if (reconnectAttempts < maxAttempts) { - await Future.delayed(retryInterval); // Wait before retrying - } else { - log('Failed to connect after $maxAttempts attempts'); - isConnected.value = false; //changed the connection value to false - Get.snackbar( - AppLocalizations.of(Get.context!)!.connectionFailed, - AppLocalizations.of(Get.context!)!.unableToJoinRoom, - duration: const Duration(seconds: 5), - ); - return false; - } - } - } - } - return false; // Fallback return (shouldn’t hit this due to loop logic) - } - - Future handleDisconnection() async { - isConnected.value = false; - - if (reconnectAttempts < maxAttempts) { - reconnectAttempts++; - log( - 'Attempting to reconnect: Attempt $reconnectAttempts of $maxAttempts', - ); - - reconnectTimer?.cancel(); - reconnectTimer = Timer(retryInterval, () async { - final success = await connectToRoom(isReconnect: true); - - if (!success && reconnectAttempts < maxAttempts) { - await handleDisconnection(); - } else if (!success) { - log('Failed to reconnect after $maxAttempts attempts'); - Get.snackbar( - AppLocalizations.of(Get.context!)!.connectionLost, - AppLocalizations.of(Get.context!)!.unableToReconnect, - duration: const Duration(seconds: 5), - ); - } - }); - } - } - - void onRoomDidUpdate() { - // Callback which will be called on room update - } - - void setUpListeners() => listener - ..on((event) async { - if (event.reason != null) { - log('Room disconnected: reason => ${event.reason}'); - await handleDisconnection(); - } - }) - ..on((event) { - if (event.metadata == "disconnected") { - handleDisconnection(); - } - }) - ..on((event) { - // context.showRecordingStatusChangedDialog(event.activeRecording); - - log('Recording status changed: ${event.activeRecording}'); - }); -} diff --git a/lib/controllers/whisper_transcription_controller.dart b/lib/controllers/whisper_transcription_controller.dart deleted file mode 100644 index 8590950f..00000000 --- a/lib/controllers/whisper_transcription_controller.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:developer'; - -import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new/return_code.dart'; -import 'package:get/get.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:whisper_flutter_new/whisper_flutter_new.dart'; - -class WhisperTranscriptionController extends GetxController { - final Whisper whisper = Whisper( - model: currentWhisperModel.value, - downloadHost: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main", - ); - - Future transcribeChapter(String chapterId) async { - log('Starting to Transcribe'); - final convertResult = await convertMp4ToWav(chapterId); - if (convertResult) { - final storagePath = await getApplicationDocumentsDirectory(); - final transcription = await whisper.transcribe( - transcribeRequest: TranscribeRequest( - audio: '${storagePath.path}/recordings/$chapterId.wav', - isTranslate: true, // Translate result from audio lang to english text - isNoTimestamps: false, // Get segments in result, - ), - ); - - return convertToLrc(transcription.segments!); - } - return "Transcription Failed"; - } - - Future convertMp4ToWav(String chapterId) async { - bool conversionSuccess = false; - final String recordingsPath = await getApplicationDocumentsDirectory().then( - (dir) => '${dir.path}/recordings', - ); - final String command = - '-i "$recordingsPath/$chapterId.mp4" -ar 16000 -ac 1 -acodec pcm_s16le "$recordingsPath/$chapterId.wav"'; - - await FFmpegKit.execute(command).then((session) async { - final returnCode = await session.getReturnCode(); - - if (ReturnCode.isSuccess(returnCode)) { - log('✅ Conversion successful!'); - conversionSuccess = true; - } else if (ReturnCode.isCancel(returnCode)) { - log('⚠️ Conversion cancelled.'); - } else { - final logs = await session.getAllLogsAsString(); - log('❌ FFmpeg error:\n$logs'); - } - }); - return conversionSuccess; - } - - String convertToLrc(List transcriptionSegments) { - final StringBuffer lrcContent = StringBuffer(); - lrcContent.writeln('[re:Resonate App - AOSSIE]'); - lrcContent.writeln('[ve:v1.0.0]'); - for (WhisperTranscribeSegment? segment in transcriptionSegments) { - try { - // Parse the log line - final segmentString = _parseTranscriptionSegment(segment); - if (segmentString != null) { - // Convert to LRC format and add to content - lrcContent.writeln(segmentString); - } - } catch (e) { - print(e.toString()); - } - } - - return lrcContent.toString(); - } - - String? _parseTranscriptionSegment(WhisperTranscribeSegment? segment) { - if (segment == null) return null; - - // Skip blank audio segments if desired - if (segment.text == '[BLANK_AUDIO]') { - return null; - } - - return "[${segment.fromTs.toMinSecMs()}]${segment.text}"; - } -} - -extension FormatAsMinSecMs on Duration { - String toMinSecMs() { - final sign = isNegative ? '-' : ''; - final ms = inMilliseconds.abs(); - final minutes = ms ~/ 60000; - final seconds = (ms % 60000) ~/ 1000; - final milliseconds = ms % 1000; - return '$sign${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${milliseconds.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/core/container.dart b/lib/core/container.dart index 9ee77f3d..7f4d0606 100644 --- a/lib/core/container.dart +++ b/lib/core/container.dart @@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; -import 'package:resonate/features/rooms/data/services/livekit_session.dart'; -import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; // Single root ProviderContainer for the app. ProviderContainer _rootContainer = ProviderContainer(); @@ -36,7 +34,3 @@ ProviderSubscription> listenAuthState( fireImmediately: true, ); } - -// LiveKit bridge for legacy GetX controllers -LiveKitSession? get currentLiveKitSession => - rootContainer.read(liveKitProvider.notifier).session; diff --git a/lib/core/legacy_dependencies.dart b/lib/core/legacy_dependencies.dart index 2e7f8e8c..077048c2 100644 --- a/lib/core/legacy_dependencies.dart +++ b/lib/core/legacy_dependencies.dart @@ -1,12 +1,10 @@ import 'package:get/get.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; import 'package:resonate/controllers/network_controller.dart'; import 'package:resonate/controllers/tabview_controller.dart'; -// Registers the GetX controllers that are still in use after the auth, -// rooms, profile, and friends features migrated to Riverpod. +// Registers the GetX controllers that are still in use after the auth, rooms, +// profile, friends, and stories features migrated to Riverpod. void setupLegacyGetXDependencies() { Get.put(NetworkController(), permanent: true); Get.lazyPut(() => TabViewController()); - Get.lazyPut(() => ExploreStoryController()); } diff --git a/lib/features/profile/data/repositories/profile_repository.dart b/lib/features/profile/data/repositories/profile_repository.dart index 31454501..12cff57e 100644 --- a/lib/features/profile/data/repositories/profile_repository.dart +++ b/lib/features/profile/data/repositories/profile_repository.dart @@ -7,8 +7,8 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:resonate/core/providers/appwrite_providers.dart'; import 'package:resonate/core/providers/firebase_providers.dart'; import 'package:resonate/features/profile/model/change_email_state.dart'; +import 'package:resonate/features/stories/model/story.dart'; import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; import 'package:resonate/utils/constants.dart'; import 'package:resonate/utils/enums/story_category.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/features/profile/model/profile_view_data.dart b/lib/features/profile/model/profile_view_data.dart index cbd1062e..ce096155 100644 --- a/lib/features/profile/model/profile_view_data.dart +++ b/lib/features/profile/model/profile_view_data.dart @@ -1,5 +1,5 @@ import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; +import 'package:resonate/features/stories/model/story.dart'; class ProfileViewData { const ProfileViewData({ diff --git a/lib/features/profile/view/pages/profile_page.dart b/lib/features/profile/view/pages/profile_page.dart index 994c04a0..eeb32e6c 100644 --- a/lib/features/profile/view/pages/profile_page.dart +++ b/lib/features/profile/view/pages/profile_page.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; import 'package:resonate/features/auth/model/auth_user.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/features/auth/viewmodel/email_verify_notifier.dart'; @@ -16,8 +15,9 @@ import 'package:resonate/features/friends/viewmodel/friends_notifier.dart'; import 'package:resonate/features/profile/model/profile_view_data.dart'; import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; import 'package:resonate/models/resonate_user.dart'; -import 'package:resonate/models/story.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/themes/theme_controller.dart'; import 'package:resonate/utils/app_images.dart'; @@ -25,7 +25,6 @@ import 'package:resonate/utils/enums/friend_request_status.dart'; import 'package:resonate/utils/enums/log_type.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/views/screens/followers_screen.dart'; -import 'package:resonate/views/screens/story_screen.dart'; import 'package:resonate/views/widgets/loading_dialog.dart'; import 'package:resonate/views/widgets/snackbar.dart'; @@ -45,7 +44,6 @@ class ProfilePage extends ConsumerStatefulWidget { class _ProfilePageState extends ConsumerState { final themeController = Get.find(); - final exploreStoryController = Get.find(); bool get _isCreator => widget.isCreatorProfile == true; String get _creatorId => widget.creator!.uid!; @@ -54,8 +52,13 @@ class _ProfilePageState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final authUser = ref.watch(authProvider).value?.userOrNull; - final profileAsync = - _isCreator ? ref.watch(profileViewProvider(_creatorId)) : null; + // Both self and creator profiles now source their stories from + // profileViewProvider; self profile previously read the GetX + // ExploreStoryController, which is gone after the stories migration. + final profileUserId = _isCreator ? _creatorId : authUser?.uid; + final profileAsync = profileUserId != null + ? ref.watch(profileViewProvider(profileUserId)) + : null; final profileData = profileAsync?.value; return Scaffold( @@ -464,19 +467,11 @@ class _ProfilePageState extends ConsumerState { ), ), SizedBox(height: UiSizes.height_5), - _isCreator - ? _buildStoriesList( - context, - profileData?.createdStories ?? const [], - l10n.userNoStories, - ) - : Obx( - () => _buildStoriesList( - context, - exploreStoryController.userCreatedStories, - l10n.youNoStories, - ), - ), + _buildStoriesList( + context, + profileData?.createdStories ?? const [], + _isCreator ? l10n.userNoStories : l10n.youNoStories, + ), SizedBox(height: UiSizes.height_10), Align( alignment: Alignment.centerLeft, @@ -490,19 +485,11 @@ class _ProfilePageState extends ConsumerState { ), ), SizedBox(height: UiSizes.height_5), - _isCreator - ? _buildStoriesList( - context, - profileData?.likedStories ?? const [], - l10n.userNoLikedStories, - ) - : Obx( - () => _buildStoriesList( - context, - exploreStoryController.userLikedStories, - l10n.youNoLikedStories, - ), - ), + _buildStoriesList( + context, + profileData?.likedStories ?? const [], + _isCreator ? l10n.userNoLikedStories : l10n.youNoLikedStories, + ), ], ), ); @@ -553,7 +540,7 @@ class StoryItem extends StatelessWidget { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => StoryScreen(story: story)), + MaterialPageRoute(builder: (_) => StoryPage(story: story)), ); }, child: Container( diff --git a/lib/features/profile/viewmodel/profile_view_notifier.dart b/lib/features/profile/viewmodel/profile_view_notifier.dart index cecc7b2b..5ba48882 100644 --- a/lib/features/profile/viewmodel/profile_view_notifier.dart +++ b/lib/features/profile/viewmodel/profile_view_notifier.dart @@ -3,7 +3,7 @@ import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/model/profile_view_data.dart'; import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; +import 'package:resonate/features/stories/model/story.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/profile_view_notifier.g.dart'; diff --git a/lib/features/rooms/data/repositories/rooms_repository.dart b/lib/features/rooms/data/repositories/rooms_repository.dart index 7a989930..ed8fb3d8 100644 --- a/lib/features/rooms/data/repositories/rooms_repository.dart +++ b/lib/features/rooms/data/repositories/rooms_repository.dart @@ -304,9 +304,7 @@ class RoomsRepository { rowId: doc.$id, ); } - // Ensure the room doc is deleted even when the server call above failed - // (it normally does this). Tolerate it already being gone. try { await _tables.deleteRow( databaseId: masterDatabaseId, diff --git a/lib/features/stories/data/repositories/generated/live_chapter_repository.g.dart b/lib/features/stories/data/repositories/generated/live_chapter_repository.g.dart new file mode 100644 index 00000000..8cb715fb --- /dev/null +++ b/lib/features/stories/data/repositories/generated/live_chapter_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../live_chapter_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(liveChapterRepository) +final liveChapterRepositoryProvider = LiveChapterRepositoryProvider._(); + +final class LiveChapterRepositoryProvider + extends + $FunctionalProvider< + LiveChapterRepository, + LiveChapterRepository, + LiveChapterRepository + > + with $Provider { + LiveChapterRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'liveChapterRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$liveChapterRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LiveChapterRepository create(Ref ref) { + return liveChapterRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LiveChapterRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$liveChapterRepositoryHash() => + r'1c3015f7f20fce5eacea456fdd775cddfb65e5cc'; diff --git a/lib/features/stories/data/repositories/generated/stories_repository.g.dart b/lib/features/stories/data/repositories/generated/stories_repository.g.dart new file mode 100644 index 00000000..7677e5f7 --- /dev/null +++ b/lib/features/stories/data/repositories/generated/stories_repository.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../stories_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(storiesRepository) +final storiesRepositoryProvider = StoriesRepositoryProvider._(); + +final class StoriesRepositoryProvider + extends + $FunctionalProvider< + StoriesRepository, + StoriesRepository, + StoriesRepository + > + with $Provider { + StoriesRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'storiesRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$storiesRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + StoriesRepository create(Ref ref) { + return storiesRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(StoriesRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$storiesRepositoryHash() => r'f15b1bf7232d7b46f8667acd431259046b8cfba0'; diff --git a/lib/features/stories/data/repositories/live_chapter_repository.dart b/lib/features/stories/data/repositories/live_chapter_repository.dart new file mode 100644 index 00000000..0b6f3c53 --- /dev/null +++ b/lib/features/stories/data/repositories/live_chapter_repository.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/rooms/data/livekit_join.dart'; +import 'package:resonate/features/stories/model/live_chapter_attendees_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/live_chapter_repository.g.dart'; + +typedef LiveKitJoinParams = ({String liveKitUri, String roomToken}); + +@Riverpod(keepAlive: true) +LiveChapterRepository liveChapterRepository(Ref ref) => LiveChapterRepository( + tables: ref.watch(appwriteTablesProvider), + realtime: ref.watch(appwriteRealtimeProvider), + functions: ref.watch(appwriteFunctionsProvider), + apiService: ApiService(functions: ref.watch(appwriteFunctionsProvider)), +); + +// Appwrite docs, realtime feed, cloud-function room ops and follower notification +class LiveChapterRepository { + LiveChapterRepository({ + required TablesDB tables, + required Realtime realtime, + required Functions functions, + required ApiService apiService, + FlutterSecureStorage? secureStorage, + }) : _tables = tables, + _realtime = realtime, + _functions = functions, + _api = apiService, + _secureStorage = secureStorage ?? const FlutterSecureStorage(); + + final TablesDB _tables; + final Realtime _realtime; + final Functions _functions; + final ApiService _api; + final FlutterSecureStorage _secureStorage; + + // LiveKit room (cloud functions) + + Future createLiveChapterRoom({ + required String appwriteRoomId, + required String adminUid, + }) async { + final response = await _api.createLiveChapterRoom(appwriteRoomId, adminUid); + final join = liveKitJoinFromResponse(response); + // The admin token is needed later to delete the room. + await _secureStorage.write( + key: "createdRoomAdminToken", + value: join.roomToken, + ); + await _secureStorage.write( + key: "createdRoomLivekitUrl", + value: join.liveKitUri, + ); + return join; + } + + Future joinLiveChapterRoom({ + required String roomId, + required String userId, + }) async { + final response = await _api.joinRoom(roomId, userId); + return liveKitJoinFromResponse(response); + } + + Future deleteLiveChapterRoom(String roomId) async { + final token = await _secureStorage.read(key: "createdRoomAdminToken"); + if (token == null) return; + await _api.deleteLiveChapterRoom(roomId, token); + } + + // Appwrite documents + + Future createLiveChapterDocs(LiveChapterModel model) async { + await _tables.createRow( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + rowId: model.id, + data: model.toJson(), + ); + await _tables.createRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: model.id, + data: model.attendees!.toJson(), + ); + } + + Future updateAttendees( + String roomId, + LiveChapterAttendeesModel attendees, + ) async { + await _tables.updateRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: roomId, + data: attendees.toJson(), + ); + } + + Future deleteLiveChapterDocs(String roomId) async { + try { + await _tables.deleteRow( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + rowId: roomId, + ); + await _tables.deleteRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: roomId, + ); + } catch (e) { + log('Failed to delete live chapter docs: $e'); + } + } + + // Realtime + + static String attendeesChannel(String roomId) => + "databases.$userDatabaseID.tables.$liveChapterAttendeesTableId.rows.$roomId"; + + // Stream of attendee-table events for a live chapter. + Stream attendeesStream(String roomId) { + final subscription = _realtime.subscribe([attendeesChannel(roomId)]); + final controller = StreamController(); + final sub = subscription.stream.listen((event) { + if (event.payload.isNotEmpty) controller.add(event); + }); + controller.onCancel = () async { + await sub.cancel(); + await subscription.close(); + }; + return controller.stream; + } + + // Notification + + Future sendLiveChapterNotification({ + required String creatorId, + required String title, + required String body, + }) async { + try { + await _functions.createExecution( + functionId: sendStoryNotificationFunctionID, + body: json.encode({ + 'creatorId': creatorId, + 'payload': {'title': title, 'body': body}, + }), + ); + } catch (e) { + log('Failed to send live chapter notification: $e'); + } + } +} diff --git a/lib/features/stories/data/repositories/stories_repository.dart b/lib/features/stories/data/repositories/stories_repository.dart new file mode 100644 index 00000000..c6e08fd4 --- /dev/null +++ b/lib/features/stories/data/repositories/stories_repository.dart @@ -0,0 +1,740 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io' as io; + +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:audio_metadata_reader/audio_metadata_reader.dart'; +import 'package:flutter/material.dart' hide Row; +import 'package:meilisearch/meilisearch.dart'; +import 'package:resonate/core/providers/appwrite_providers.dart'; +import 'package:resonate/features/auth/model/auth_user.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/model/live_chapter_attendees_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/features/stories/model/stories_failure.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/model/story_search_state.dart'; +import 'package:resonate/models/resonate_user.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/stories_repository.g.dart'; + +@Riverpod(keepAlive: true) +StoriesRepository storiesRepository(Ref ref) => StoriesRepository( + tables: ref.watch(appwriteTablesProvider), + storage: ref.watch(appwriteStorageProvider), + functions: ref.watch(appwriteFunctionsProvider), +); + +class StoriesRepository { + StoriesRepository({ + required TablesDB tables, + required Storage storage, + required Functions functions, + MeiliSearchClient? meili, + }) : _tables = tables, + _storage = storage, + _functions = functions, + _meili = + meili ?? MeiliSearchClient(meilisearchEndpoint, meilisearchApiKey); + + final TablesDB _tables; + final Storage _storage; + final Functions _functions; + final MeiliSearchClient _meili; + + MeiliSearchIndex get _storyIndex => _meili.index('stories'); + MeiliSearchIndex get _userIndex => _meili.index('users'); + + // Loaders + + Future> fetchRecommendedStories(String currentUid) async { + try { + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.limit(10)], + ); + return _rowsToStories(result.rows, currentUid); + } on AppwriteException catch (e) { + log('Failed to fetch recommended stories: ${e.message}'); + return []; + } + } + + Future> fetchStoriesByCategory( + StoryCategory category, + String currentUid, + ) async { + try { + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.limit(15), Query.equal('category', category.name)], + ); + return _rowsToStories(result.rows, currentUid); + } on AppwriteException catch (e) { + log('Failed to fetch stories for category ${category.name}: ${e.message}'); + return []; + } + } + + Future> fetchCreatedStories(String creatorId) async { + try { + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [Query.equal('creatorId', creatorId)], + ); + return _rowsToStories(result.rows, creatorId); + } on AppwriteException catch (e) { + log('Failed to fetch created stories: ${e.message}'); + return []; + } + } + + Future> fetchLikedStories(String uid) async { + try { + final likeDocs = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [Query.equal('uId', uid)], + ); + + final storyRows = []; + for (final like in likeDocs.rows) { + try { + storyRows.add( + await _tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: like.data['storyId'], + ), + ); + } on AppwriteException catch (e) { + log('Liked story row missing, skipping: ${e.message}'); + } + } + return _rowsToStories(storyRows, uid); + } on AppwriteException catch (e) { + log('Failed to fetch liked stories: ${e.message}'); + return []; + } + } + + // Loads the reactive slice of a story on the detail page. + Future loadStoryDetail( + String storyId, + String currentUid, + ) async { + List chapters = []; + try { + chapters = await fetchChaptersForStory(storyId); + } on AppwriteException catch (e) { + log('Failed to fetch story chapters: ${e.message}'); + } + + bool hasUserLiked = false; + try { + hasUserLiked = await checkIfStoryLikedByUser(storyId, currentUid); + } on AppwriteException catch (e) { + log('Failed to check story like status: ${e.message}'); + } + + int likes = 0; + try { + likes = await fetchLikesCount(storyId); + } on AppwriteException catch (e) { + log('Failed to fetch story likes count: ${e.message}'); + } + + LiveChapterModel? liveChapter; + try { + liveChapter = await fetchLiveChapterForStory(storyId); + } on AppwriteException catch (e) { + log('Failed to fetch live chapter: ${e.message}'); + } + + return StoryDetailState( + chapters: chapters, + likesCount: likes, + isLikedByCurrentUser: hasUserLiked, + liveChapter: liveChapter, + ); + } + + Future> fetchChaptersForStory(String storyId) async { + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: [Query.equal('storyId', storyId)], + ); + + return result.rows.map((value) { + final tintColor = Color(int.parse("0xff${value.data['tintColor']}")); + return Chapter( + value.$id, + value.data['title'], + value.data['coverImgUrl'], + value.data['description'], + value.data['lyrics'], + value.data['audioFileUrl'], + value.data['playDuration'], + tintColor, + ); + }).toList(); + } + + Future fetchLikesCount(String storyId) async { + final doc = await _tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: storyId, + queries: [ + Query.select(["likes"]), + ], + ); + return doc.data['likes'] as int; + } + + Future checkIfStoryLikedByUser(String storyId, String uid) async { + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [ + Query.and([ + Query.equal('uId', uid), + Query.equal('storyId', storyId), + ]), + ], + ); + return result.rows.isNotEmpty; + } + + Future fetchLiveChapterForStory(String storyId) async { + final liveDocs = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + queries: [Query.equal('storyId', storyId)], + ); + if (liveDocs.rows.isEmpty) return null; + + final attendeesDoc = await _tables.getRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: liveDocs.rows.first.$id, + queries: [ + Query.select(["*", "users.*"]), + ], + ); + + final attendees = LiveChapterAttendeesModel.fromJson(attendeesDoc.data); + return LiveChapterModel.fromJson( + liveDocs.rows.first.data, + ).copyWith(attendees: attendees); + } + + // Search + + Future search(String query, String currentUid) async { + final stories = await _searchStories(query, currentUid); + final users = await _searchUsers(query, currentUid); + return StorySearchState(stories: stories, users: users); + } + + Future> _searchStories(String query, String currentUid) async { + if (isUsingMeilisearch) { + final result = await _storyIndex.search( + query, + SearchQuery( + attributesToHighlight: ['title', 'creatorName', 'description'], + ), + ); + return _meiliHitsToStories(result.hits, currentUid); + } + + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [ + Query.or([ + Query.search('title', query), + Query.search('creatorName', query), + Query.search('description', query), + ]), + Query.limit(16), + ], + ); + return _rowsToStories(result.rows, currentUid); + } + + Future> _searchUsers( + String query, + String currentUid, + ) async { + if (isUsingMeilisearch) { + final result = await _userIndex.search( + query, + SearchQuery(attributesToHighlight: ['name', 'username']), + ); + return result.hits.map(_meiliHitToUser).toList(); + } + + final result = await _tables.listRows( + databaseId: userDatabaseID, + tableId: usersTableID, + queries: [ + Query.or([ + Query.search('name', query), + Query.search('username', query), + ]), + Query.notEqual('\$id', currentUid), + Query.limit(16), + ], + ); + return result.rows.map((doc) => _rowToUser(doc.data, doc.$id)).toList(); + } + + // Actions + + Future likeStory(Story story, String uid) async { + try { + await _tables.createRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: ID.unique(), + data: {'uId': uid, 'storyId': story.storyId}, + ); + await _tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: story.storyId, + data: {"likes": story.likesCount + 1}, + ); + } on AppwriteException catch (e) { + throw StoriesFailure.unknown(e.message ?? 'Failed to like story'); + } + } + + Future unlikeStory(Story story, String uid) async { + try { + final likeDocs = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [ + Query.and([ + Query.equal('uId', uid), + Query.equal('storyId', story.storyId), + ]), + ], + ); + if (likeDocs.rows.isNotEmpty) { + await _tables.deleteRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: likeDocs.rows.first.$id, + ); + } + await _tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: story.storyId, + data: {"likes": story.likesCount - 1}, + ); + } on AppwriteException catch (e) { + throw StoriesFailure.unknown(e.message ?? 'Failed to unlike story'); + } + } + + // Builds a chapter from the picked files + Future buildChapterFromFiles({ + required String title, + required String description, + required String coverImgPath, + required String audioFilePath, + required String lyricsFilePath, + }) async { + var lyrics = ''; + if (lyricsFilePath.isNotEmpty) { + lyrics = await io.File(lyricsFilePath).readAsString(); + } + return _buildChapter( + chapterId: ID.unique(), + title: title, + description: description, + coverImgPath: coverImgPath, + audioFilePath: audioFilePath, + lyrics: lyrics, + ); + } + + Future buildRecordedChapter({ + required String chapterId, + required String title, + required String description, + required String coverImgPath, + required String audioFilePath, + required String lyrics, + }) => _buildChapter( + chapterId: chapterId, + title: title, + description: description, + coverImgPath: coverImgPath, + audioFilePath: audioFilePath, + lyrics: lyrics, + ); + + Future _buildChapter({ + required String chapterId, + required String title, + required String description, + required String coverImgPath, + required String audioFilePath, + required String lyrics, + }) async { + final metadata = readMetadata(io.File(audioFilePath)); + final playDuration = metadata.duration?.inMilliseconds ?? 0; + final primaryColor = await _tintForCover(coverImgPath); + return Chapter( + chapterId, + title, + coverImgPath, + description, + lyrics, + audioFilePath, + playDuration, + primaryColor, + ); + } + + Future createStory({ + required AuthUser user, + required String title, + required String description, + required StoryCategory category, + required String coverImgRef, + required int storyPlayDuration, + required List chapters, + }) async { + final storyId = ID.unique(); + var coverImgUrl = coverImgRef; + Color primaryColor; + + if (!coverImgUrl.contains("http")) { + primaryColor = await _tintForCover(coverImgUrl); + coverImgUrl = await _uploadFile( + fileId: storyId, + filePath: coverImgRef, + what: "story cover", + ); + } else { + primaryColor = const Color(0xffcbc6c6); + } + + await pushChaptersToStory(chapters, storyId); + + try { + await _tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: storyId, + data: { + 'title': title, + 'description': description, + 'category': category.name, + 'coverImgUrl': coverImgUrl, + 'creatorId': user.uid, + 'creatorName': user.displayName, + 'creatorImgUrl': user.profileImageUrl, + 'likes': 0, + 'playDuration': storyPlayDuration, + 'tintColor': _colorToHex(primaryColor), + }, + ); + } on AppwriteException catch (e) { + log('Failed to create story row: ${e.message}'); + throw StoriesFailure.unknown(e.message ?? 'Failed to create story'); + } + + // Notify followers + if (user.followers.isNotEmpty) { + try { + await _functions.createExecution( + functionId: sendStoryNotificationFunctionID, + body: json.encode({ + 'creatorId': user.uid, + 'payload': { + 'title': 'New story added!', + 'body': + "A new story was just added by ${user.displayName}: $title", + }, + }), + ); + } catch (e) { + log('Failed to send story notification: $e'); + } + } + } + + Future pushChaptersToStory( + List chapters, + String storyId, + ) async { + for (final chapter in chapters) { + var coverImgUrl = chapter.coverImageUrl; + if (!coverImgUrl.contains("http")) { + coverImgUrl = await _uploadFile( + fileId: chapter.chapterId, + filePath: coverImgUrl, + what: "chapter cover", + ); + } + + final audioFileUrl = await _uploadFile( + fileId: 'audioFor${chapter.chapterId}', + filePath: chapter.audioFileUrl, + what: "audio file", + ); + + final chapterData = { + 'title': chapter.title, + 'description': chapter.description, + 'coverImgUrl': coverImgUrl, + 'lyrics': chapter.lyrics, + 'playDuration': chapter.playDuration, + 'tintColor': _colorToHex(chapter.tintColor), + 'storyId': storyId, + 'audioFileUrl': audioFileUrl, + }; + + try { + await _tables.createRow( + databaseId: storyDatabaseId, + tableId: chapterTableId, + rowId: chapter.chapterId, + data: chapterData, + ); + } on AppwriteException catch (e) { + if (e.code == 409) { + log("Chapter row '${chapter.chapterId}' already exists; updating it"); + await _tables.updateRow( + databaseId: storyDatabaseId, + tableId: chapterTableId, + rowId: chapter.chapterId, + data: chapterData, + ); + } else { + rethrow; + } + } + } + } + + Future addChaptersToStory( + List chapters, + String storyId, + ) async { + await pushChaptersToStory(chapters, storyId); + try { + final all = await fetchChaptersForStory(storyId); + final total = all.fold(0, (sum, c) => sum + c.playDuration); + await _tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: storyId, + data: {"playDuration": total}, + ); + } on AppwriteException catch (e) { + log("Failed to update story duration: ${e.message}"); + } + } + + Future deleteStory(Story story) async { + try { + await _storage.deleteFile(bucketId: storyBucketId, fileId: story.storyId); + } on AppwriteException catch (e) { + log('Failed to delete story cover image: ${e.message}'); + } + + // Deleting the live chapters from the DB + try { + final chapters = await fetchChaptersForStory(story.storyId); + for (final chapter in chapters) { + await deleteChapter(chapter); + } + } on AppwriteException catch (e) { + log('Failed to delete story chapters: ${e.message}'); + } + + try { + await _deleteAllStoryLikes(story.storyId); + } on AppwriteException catch (e) { + log('Failed to delete story likes: ${e.message}'); + } + + try { + await _tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: story.storyId, + ); + } on AppwriteException catch (e) { + throw StoriesFailure.unknown(e.message ?? 'Failed to delete story'); + } + } + + Future deleteChapter(Chapter chapter) async { + try { + await _storage.deleteFile( + bucketId: storyBucketId, + fileId: chapter.chapterId, + ); + } on AppwriteException catch (e) { + log("Failed to delete chapter cover image: ${e.message}"); + } + try { + await _storage.deleteFile( + bucketId: storyBucketId, + fileId: 'audioFor${chapter.chapterId}', + ); + } on AppwriteException catch (e) { + log("Failed to delete chapter audio file: ${e.message}"); + } + try { + await _tables.deleteRow( + databaseId: storyDatabaseId, + tableId: chapterTableId, + rowId: chapter.chapterId, + ); + } on AppwriteException catch (e) { + log("Failed to delete chapter document: ${e.message}"); + } + } + + Future _deleteAllStoryLikes(String storyId) async { + final likeDocs = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: [Query.equal('storyId', storyId)], + ); + for (final like in likeDocs.rows) { + await _tables.deleteRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: like.$id, + ); + } + } + + // Helpers + + Future _uploadFile({ + required String fileId, + required String filePath, + required String what, + }) async { + try { + await _storage.createFile( + bucketId: storyBucketId, + fileId: fileId, + file: InputFile.fromPath(path: filePath), + ); + } on AppwriteException catch (e) { + // if user tries uploading same file then reuse + if (e.code == 409 || e.type == 'storage_file_already_exists') { + log("File '$fileId' already exists ($what); reusing it"); + } else { + log("Failed to upload $what: ${e.message}"); + throw StoriesFailure.upload(what); + } + } + return "$appwriteEndpoint/storage/buckets/$storyBucketId/files/$fileId/view?project=$appwriteProjectId"; + } + + Future _tintForCover(String coverPath) async { + if (coverPath.contains('http')) return const Color(0xffcbc6c6); + final scheme = await ColorScheme.fromImageProvider( + provider: FileImage(io.File(coverPath)), + ); + return scheme.primary; + } + + List _rowsToStories(List rows, String currentUid) { + final stories = []; + for (final row in rows) { + try { + stories.add(_storyFromMap(row.data, row.$id, row.$createdAt, currentUid)); + } catch (e) { + // Skiping malformed story rows + log('Skipping malformed story row ${row.$id}: $e'); + } + } + return stories; + } + + List _meiliHitsToStories( + List> hits, + String currentUid, + ) { + final stories = []; + for (final hit in hits) { + try { + stories.add( + _storyFromMap(hit, hit['\$id'], hit['\$createdAt'], currentUid), + ); + } catch (e) { + log('Skipping malformed meilisearch story hit: $e'); + } + } + return stories; + } + + Story _storyFromMap( + Map data, + String id, + String createdAt, + String currentUid, + ) { + return Story( + title: data['title'], + storyId: id, + description: data['description'], + userIsCreator: data['creatorId'] == currentUid, + category: StoryCategory.values.byName(data['category']), + coverImageUrl: data['coverImgUrl'], + creatorId: data['creatorId'], + creatorName: data['creatorName'], + creatorImgUrl: data['creatorImgUrl'], + creationDate: DateTime.parse(createdAt), + likesCount: data['likes'], + isLikedByCurrentUser: false, + playDuration: data['playDuration'], + tintColor: Color(int.parse("0xff${data['tintColor']}")), + chapters: const [], + ); + } + + ResonateUser _rowToUser(Map data, String id) { + final userData = Map.from(data); + userData['docId'] = id; + userData['uid'] = id; + userData['userName'] = userData['username']; + final ratingCount = (userData['ratingCount'] ?? 0) as num; + userData['userRating'] = + ratingCount == 0 ? 0 : userData['ratingTotal'] / ratingCount; + return ResonateUser.fromJson(userData); + } + + ResonateUser _meiliHitToUser(Map hit) => + _rowToUser(hit, hit['\$id']); + + String _colorToHex(Color color) => + '${(color.a * 255).toInt().toRadixString(16).padLeft(2, '0')}' + '${(color.r * 255).toInt().toRadixString(16).padLeft(2, '0')}' + '${(color.g * 255).toInt().toRadixString(16).padLeft(2, '0')}' + '${(color.b * 255).toInt().toRadixString(16).padLeft(2, '0')}'; +} diff --git a/lib/features/stories/data/services/whisper_transcription_service.dart b/lib/features/stories/data/services/whisper_transcription_service.dart new file mode 100644 index 00000000..94bc0c5f --- /dev/null +++ b/lib/features/stories/data/services/whisper_transcription_service.dart @@ -0,0 +1,89 @@ +import 'dart:developer'; + +import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new/return_code.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:whisper_flutter_new/whisper_flutter_new.dart'; + +class WhisperTranscriptionService { + WhisperTranscriptionService({WhisperModel model = WhisperModel.base}) + : whisper = Whisper( + model: model, + downloadHost: + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main", + ); + + final Whisper whisper; + + Future transcribeChapter(String chapterId) async { + log('Starting to Transcribe'); + final convertResult = await _convertMp4ToWav(chapterId); + if (!convertResult) return "Transcription Failed"; + + final storagePath = await getApplicationDocumentsDirectory(); + final transcription = await whisper.transcribe( + transcribeRequest: TranscribeRequest( + audio: '${storagePath.path}/recordings/$chapterId.wav', + isTranslate: true, // Translate result from audio lang to english text + isNoTimestamps: false, + ), + ); + + return _convertToLrc(transcription.segments!); + } + + Future _convertMp4ToWav(String chapterId) async { + var conversionSuccess = false; + final recordingsPath = await getApplicationDocumentsDirectory().then( + (dir) => '${dir.path}/recordings', + ); + final command = + '-i "$recordingsPath/$chapterId.mp4" -ar 16000 -ac 1 -acodec pcm_s16le "$recordingsPath/$chapterId.wav"'; + + await FFmpegKit.execute(command).then((session) async { + final returnCode = await session.getReturnCode(); + if (ReturnCode.isSuccess(returnCode)) { + log('✅ Conversion successful!'); + conversionSuccess = true; + } else if (ReturnCode.isCancel(returnCode)) { + log('⚠️ Conversion cancelled.'); + } else { + final logs = await session.getAllLogsAsString(); + log('❌ FFmpeg error:\n$logs'); + } + }); + return conversionSuccess; + } + + String _convertToLrc(List segments) { + final lrcContent = StringBuffer() + ..writeln('[re:Resonate App - AOSSIE]') + ..writeln('[ve:v1.0.0]'); + for (final segment in segments) { + try { + final line = _parseSegment(segment); + if (line != null) lrcContent.writeln(line); + } catch (e) { + log(e.toString()); + } + } + return lrcContent.toString(); + } + + String? _parseSegment(WhisperTranscribeSegment? segment) { + if (segment == null) return null; + if (segment.text == '[BLANK_AUDIO]') return null; + return "[${segment.fromTs.toMinSecMs()}]${segment.text}"; + } +} + +extension FormatAsMinSecMs on Duration { + String toMinSecMs() { + final sign = isNegative ? '-' : ''; + final ms = inMilliseconds.abs(); + final minutes = ms ~/ 60000; + final seconds = (ms % 60000) ~/ 1000; + final milliseconds = ms % 1000; + return '$sign${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${milliseconds.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/models/chapter.dart b/lib/features/stories/model/chapter.dart similarity index 96% rename from lib/models/chapter.dart rename to lib/features/stories/model/chapter.dart index 6bd02b68..b799b1a5 100644 --- a/lib/models/chapter.dart +++ b/lib/features/stories/model/chapter.dart @@ -1,8 +1,8 @@ import 'dart:ui'; class Chapter { - final String title; final String chapterId; + final String title; final String coverImageUrl; final String description; final String lyrics; @@ -10,7 +10,7 @@ class Chapter { final int playDuration; final Color tintColor; - Chapter( + const Chapter( this.chapterId, this.title, this.coverImageUrl, diff --git a/lib/features/stories/model/chapter_player_state.dart b/lib/features/stories/model/chapter_player_state.dart new file mode 100644 index 00000000..4c62849d --- /dev/null +++ b/lib/features/stories/model/chapter_player_state.dart @@ -0,0 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/chapter_player_state.freezed.dart'; + +@freezed +abstract class ChapterPlayerState with _$ChapterPlayerState { + const factory ChapterPlayerState({ + @Default(0.0) double sliderProgress, + @Default(false) bool isPlaying, + }) = _ChapterPlayerState; +} diff --git a/lib/features/stories/model/generated/chapter_player_state.freezed.dart b/lib/features/stories/model/generated/chapter_player_state.freezed.dart new file mode 100644 index 00000000..dfcccd40 --- /dev/null +++ b/lib/features/stories/model/generated/chapter_player_state.freezed.dart @@ -0,0 +1,274 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../chapter_player_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ChapterPlayerState { + + double get sliderProgress; bool get isPlaying; +/// Create a copy of ChapterPlayerState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChapterPlayerStateCopyWith get copyWith => _$ChapterPlayerStateCopyWithImpl(this as ChapterPlayerState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChapterPlayerState&&(identical(other.sliderProgress, sliderProgress) || other.sliderProgress == sliderProgress)&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)); +} + + +@override +int get hashCode => Object.hash(runtimeType,sliderProgress,isPlaying); + +@override +String toString() { + return 'ChapterPlayerState(sliderProgress: $sliderProgress, isPlaying: $isPlaying)'; +} + + +} + +/// @nodoc +abstract mixin class $ChapterPlayerStateCopyWith<$Res> { + factory $ChapterPlayerStateCopyWith(ChapterPlayerState value, $Res Function(ChapterPlayerState) _then) = _$ChapterPlayerStateCopyWithImpl; +@useResult +$Res call({ + double sliderProgress, bool isPlaying +}); + + + + +} +/// @nodoc +class _$ChapterPlayerStateCopyWithImpl<$Res> + implements $ChapterPlayerStateCopyWith<$Res> { + _$ChapterPlayerStateCopyWithImpl(this._self, this._then); + + final ChapterPlayerState _self; + final $Res Function(ChapterPlayerState) _then; + +/// Create a copy of ChapterPlayerState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sliderProgress = null,Object? isPlaying = null,}) { + return _then(_self.copyWith( +sliderProgress: null == sliderProgress ? _self.sliderProgress : sliderProgress // ignore: cast_nullable_to_non_nullable +as double,isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChapterPlayerState]. +extension ChapterPlayerStatePatterns on ChapterPlayerState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChapterPlayerState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChapterPlayerState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChapterPlayerState value) $default,){ +final _that = this; +switch (_that) { +case _ChapterPlayerState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChapterPlayerState value)? $default,){ +final _that = this; +switch (_that) { +case _ChapterPlayerState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double sliderProgress, bool isPlaying)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChapterPlayerState() when $default != null: +return $default(_that.sliderProgress,_that.isPlaying);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double sliderProgress, bool isPlaying) $default,) {final _that = this; +switch (_that) { +case _ChapterPlayerState(): +return $default(_that.sliderProgress,_that.isPlaying);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double sliderProgress, bool isPlaying)? $default,) {final _that = this; +switch (_that) { +case _ChapterPlayerState() when $default != null: +return $default(_that.sliderProgress,_that.isPlaying);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ChapterPlayerState implements ChapterPlayerState { + const _ChapterPlayerState({this.sliderProgress = 0.0, this.isPlaying = false}); + + +@override@JsonKey() final double sliderProgress; +@override@JsonKey() final bool isPlaying; + +/// Create a copy of ChapterPlayerState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChapterPlayerStateCopyWith<_ChapterPlayerState> get copyWith => __$ChapterPlayerStateCopyWithImpl<_ChapterPlayerState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChapterPlayerState&&(identical(other.sliderProgress, sliderProgress) || other.sliderProgress == sliderProgress)&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)); +} + + +@override +int get hashCode => Object.hash(runtimeType,sliderProgress,isPlaying); + +@override +String toString() { + return 'ChapterPlayerState(sliderProgress: $sliderProgress, isPlaying: $isPlaying)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChapterPlayerStateCopyWith<$Res> implements $ChapterPlayerStateCopyWith<$Res> { + factory _$ChapterPlayerStateCopyWith(_ChapterPlayerState value, $Res Function(_ChapterPlayerState) _then) = __$ChapterPlayerStateCopyWithImpl; +@override @useResult +$Res call({ + double sliderProgress, bool isPlaying +}); + + + + +} +/// @nodoc +class __$ChapterPlayerStateCopyWithImpl<$Res> + implements _$ChapterPlayerStateCopyWith<$Res> { + __$ChapterPlayerStateCopyWithImpl(this._self, this._then); + + final _ChapterPlayerState _self; + final $Res Function(_ChapterPlayerState) _then; + +/// Create a copy of ChapterPlayerState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sliderProgress = null,Object? isPlaying = null,}) { + return _then(_ChapterPlayerState( +sliderProgress: null == sliderProgress ? _self.sliderProgress : sliderProgress // ignore: cast_nullable_to_non_nullable +as double,isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/models/generated/live_chapter_attendees_model.freezed.dart b/lib/features/stories/model/generated/live_chapter_attendees_model.freezed.dart similarity index 100% rename from lib/models/generated/live_chapter_attendees_model.freezed.dart rename to lib/features/stories/model/generated/live_chapter_attendees_model.freezed.dart diff --git a/lib/models/generated/live_chapter_attendees_model.g.dart b/lib/features/stories/model/generated/live_chapter_attendees_model.g.dart similarity index 100% rename from lib/models/generated/live_chapter_attendees_model.g.dart rename to lib/features/stories/model/generated/live_chapter_attendees_model.g.dart diff --git a/lib/models/generated/live_chapter_model.freezed.dart b/lib/features/stories/model/generated/live_chapter_model.freezed.dart similarity index 100% rename from lib/models/generated/live_chapter_model.freezed.dart rename to lib/features/stories/model/generated/live_chapter_model.freezed.dart diff --git a/lib/models/generated/live_chapter_model.g.dart b/lib/features/stories/model/generated/live_chapter_model.g.dart similarity index 100% rename from lib/models/generated/live_chapter_model.g.dart rename to lib/features/stories/model/generated/live_chapter_model.g.dart diff --git a/lib/features/stories/model/generated/live_chapter_state.freezed.dart b/lib/features/stories/model/generated/live_chapter_state.freezed.dart new file mode 100644 index 00000000..dc484b45 --- /dev/null +++ b/lib/features/stories/model/generated/live_chapter_state.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../live_chapter_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveChapterState { + + LiveChapterModel? get model; bool get isMicOn; +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveChapterStateCopyWith get copyWith => _$LiveChapterStateCopyWithImpl(this as LiveChapterState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveChapterState&&(identical(other.model, model) || other.model == model)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)); +} + + +@override +int get hashCode => Object.hash(runtimeType,model,isMicOn); + +@override +String toString() { + return 'LiveChapterState(model: $model, isMicOn: $isMicOn)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveChapterStateCopyWith<$Res> { + factory $LiveChapterStateCopyWith(LiveChapterState value, $Res Function(LiveChapterState) _then) = _$LiveChapterStateCopyWithImpl; +@useResult +$Res call({ + LiveChapterModel? model, bool isMicOn +}); + + +$LiveChapterModelCopyWith<$Res>? get model; + +} +/// @nodoc +class _$LiveChapterStateCopyWithImpl<$Res> + implements $LiveChapterStateCopyWith<$Res> { + _$LiveChapterStateCopyWithImpl(this._self, this._then); + + final LiveChapterState _self; + final $Res Function(LiveChapterState) _then; + +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? model = freezed,Object? isMicOn = null,}) { + return _then(_self.copyWith( +model: freezed == model ? _self.model : model // ignore: cast_nullable_to_non_nullable +as LiveChapterModel?,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveChapterModelCopyWith<$Res>? get model { + if (_self.model == null) { + return null; + } + + return $LiveChapterModelCopyWith<$Res>(_self.model!, (value) { + return _then(_self.copyWith(model: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LiveChapterState]. +extension LiveChapterStatePatterns on LiveChapterState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveChapterState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveChapterState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveChapterState value) $default,){ +final _that = this; +switch (_that) { +case _LiveChapterState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveChapterState value)? $default,){ +final _that = this; +switch (_that) { +case _LiveChapterState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveChapterModel? model, bool isMicOn)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveChapterState() when $default != null: +return $default(_that.model,_that.isMicOn);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LiveChapterModel? model, bool isMicOn) $default,) {final _that = this; +switch (_that) { +case _LiveChapterState(): +return $default(_that.model,_that.isMicOn);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveChapterModel? model, bool isMicOn)? $default,) {final _that = this; +switch (_that) { +case _LiveChapterState() when $default != null: +return $default(_that.model,_that.isMicOn);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveChapterState implements LiveChapterState { + const _LiveChapterState({this.model, this.isMicOn = false}); + + +@override final LiveChapterModel? model; +@override@JsonKey() final bool isMicOn; + +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveChapterStateCopyWith<_LiveChapterState> get copyWith => __$LiveChapterStateCopyWithImpl<_LiveChapterState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveChapterState&&(identical(other.model, model) || other.model == model)&&(identical(other.isMicOn, isMicOn) || other.isMicOn == isMicOn)); +} + + +@override +int get hashCode => Object.hash(runtimeType,model,isMicOn); + +@override +String toString() { + return 'LiveChapterState(model: $model, isMicOn: $isMicOn)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveChapterStateCopyWith<$Res> implements $LiveChapterStateCopyWith<$Res> { + factory _$LiveChapterStateCopyWith(_LiveChapterState value, $Res Function(_LiveChapterState) _then) = __$LiveChapterStateCopyWithImpl; +@override @useResult +$Res call({ + LiveChapterModel? model, bool isMicOn +}); + + +@override $LiveChapterModelCopyWith<$Res>? get model; + +} +/// @nodoc +class __$LiveChapterStateCopyWithImpl<$Res> + implements _$LiveChapterStateCopyWith<$Res> { + __$LiveChapterStateCopyWithImpl(this._self, this._then); + + final _LiveChapterState _self; + final $Res Function(_LiveChapterState) _then; + +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? model = freezed,Object? isMicOn = null,}) { + return _then(_LiveChapterState( +model: freezed == model ? _self.model : model // ignore: cast_nullable_to_non_nullable +as LiveChapterModel?,isMicOn: null == isMicOn ? _self.isMicOn : isMicOn // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of LiveChapterState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveChapterModelCopyWith<$Res>? get model { + if (_self.model == null) { + return null; + } + + return $LiveChapterModelCopyWith<$Res>(_self.model!, (value) { + return _then(_self.copyWith(model: value)); + }); +} +} + +// dart format on diff --git a/lib/features/stories/model/generated/stories_failure.freezed.dart b/lib/features/stories/model/generated/stories_failure.freezed.dart new file mode 100644 index 00000000..59f15928 --- /dev/null +++ b/lib/features/stories/model/generated/stories_failure.freezed.dart @@ -0,0 +1,344 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../stories_failure.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$StoriesFailure { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StoriesFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'StoriesFailure()'; +} + + +} + +/// @nodoc +class $StoriesFailureCopyWith<$Res> { +$StoriesFailureCopyWith(StoriesFailure _, $Res Function(StoriesFailure) __); +} + + +/// Adds pattern-matching-related methods to [StoriesFailure]. +extension StoriesFailurePatterns on StoriesFailure { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( StoriesFailureUpload value)? upload,TResult Function( StoriesFailureNetwork value)? network,TResult Function( StoriesFailureUnknown value)? unknown,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case StoriesFailureUpload() when upload != null: +return upload(_that);case StoriesFailureNetwork() when network != null: +return network(_that);case StoriesFailureUnknown() when unknown != null: +return unknown(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( StoriesFailureUpload value) upload,required TResult Function( StoriesFailureNetwork value) network,required TResult Function( StoriesFailureUnknown value) unknown,}){ +final _that = this; +switch (_that) { +case StoriesFailureUpload(): +return upload(_that);case StoriesFailureNetwork(): +return network(_that);case StoriesFailureUnknown(): +return unknown(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( StoriesFailureUpload value)? upload,TResult? Function( StoriesFailureNetwork value)? network,TResult? Function( StoriesFailureUnknown value)? unknown,}){ +final _that = this; +switch (_that) { +case StoriesFailureUpload() when upload != null: +return upload(_that);case StoriesFailureNetwork() when network != null: +return network(_that);case StoriesFailureUnknown() when unknown != null: +return unknown(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String what)? upload,TResult Function()? network,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; +switch (_that) { +case StoriesFailureUpload() when upload != null: +return upload(_that.what);case StoriesFailureNetwork() when network != null: +return network();case StoriesFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String what) upload,required TResult Function() network,required TResult Function( String message) unknown,}) {final _that = this; +switch (_that) { +case StoriesFailureUpload(): +return upload(_that.what);case StoriesFailureNetwork(): +return network();case StoriesFailureUnknown(): +return unknown(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String what)? upload,TResult? Function()? network,TResult? Function( String message)? unknown,}) {final _that = this; +switch (_that) { +case StoriesFailureUpload() when upload != null: +return upload(_that.what);case StoriesFailureNetwork() when network != null: +return network();case StoriesFailureUnknown() when unknown != null: +return unknown(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class StoriesFailureUpload implements StoriesFailure { + const StoriesFailureUpload(this.what); + + + final String what; + +/// Create a copy of StoriesFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StoriesFailureUploadCopyWith get copyWith => _$StoriesFailureUploadCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StoriesFailureUpload&&(identical(other.what, what) || other.what == what)); +} + + +@override +int get hashCode => Object.hash(runtimeType,what); + +@override +String toString() { + return 'StoriesFailure.upload(what: $what)'; +} + + +} + +/// @nodoc +abstract mixin class $StoriesFailureUploadCopyWith<$Res> implements $StoriesFailureCopyWith<$Res> { + factory $StoriesFailureUploadCopyWith(StoriesFailureUpload value, $Res Function(StoriesFailureUpload) _then) = _$StoriesFailureUploadCopyWithImpl; +@useResult +$Res call({ + String what +}); + + + + +} +/// @nodoc +class _$StoriesFailureUploadCopyWithImpl<$Res> + implements $StoriesFailureUploadCopyWith<$Res> { + _$StoriesFailureUploadCopyWithImpl(this._self, this._then); + + final StoriesFailureUpload _self; + final $Res Function(StoriesFailureUpload) _then; + +/// Create a copy of StoriesFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? what = null,}) { + return _then(StoriesFailureUpload( +null == what ? _self.what : what // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class StoriesFailureNetwork implements StoriesFailure { + const StoriesFailureNetwork(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StoriesFailureNetwork); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'StoriesFailure.network()'; +} + + +} + + + + +/// @nodoc + + +class StoriesFailureUnknown implements StoriesFailure { + const StoriesFailureUnknown(this.message); + + + final String message; + +/// Create a copy of StoriesFailure +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StoriesFailureUnknownCopyWith get copyWith => _$StoriesFailureUnknownCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StoriesFailureUnknown&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'StoriesFailure.unknown(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $StoriesFailureUnknownCopyWith<$Res> implements $StoriesFailureCopyWith<$Res> { + factory $StoriesFailureUnknownCopyWith(StoriesFailureUnknown value, $Res Function(StoriesFailureUnknown) _then) = _$StoriesFailureUnknownCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$StoriesFailureUnknownCopyWithImpl<$Res> + implements $StoriesFailureUnknownCopyWith<$Res> { + _$StoriesFailureUnknownCopyWithImpl(this._self, this._then); + + final StoriesFailureUnknown _self; + final $Res Function(StoriesFailureUnknown) _then; + +/// Create a copy of StoriesFailure +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(StoriesFailureUnknown( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/stories/model/generated/story_detail_state.freezed.dart b/lib/features/stories/model/generated/story_detail_state.freezed.dart new file mode 100644 index 00000000..2d744e0e --- /dev/null +++ b/lib/features/stories/model/generated/story_detail_state.freezed.dart @@ -0,0 +1,310 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../story_detail_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$StoryDetailState { + + List get chapters; int get likesCount; bool get isLikedByCurrentUser; LiveChapterModel? get liveChapter; +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StoryDetailStateCopyWith get copyWith => _$StoryDetailStateCopyWithImpl(this as StoryDetailState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StoryDetailState&&const DeepCollectionEquality().equals(other.chapters, chapters)&&(identical(other.likesCount, likesCount) || other.likesCount == likesCount)&&(identical(other.isLikedByCurrentUser, isLikedByCurrentUser) || other.isLikedByCurrentUser == isLikedByCurrentUser)&&(identical(other.liveChapter, liveChapter) || other.liveChapter == liveChapter)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(chapters),likesCount,isLikedByCurrentUser,liveChapter); + +@override +String toString() { + return 'StoryDetailState(chapters: $chapters, likesCount: $likesCount, isLikedByCurrentUser: $isLikedByCurrentUser, liveChapter: $liveChapter)'; +} + + +} + +/// @nodoc +abstract mixin class $StoryDetailStateCopyWith<$Res> { + factory $StoryDetailStateCopyWith(StoryDetailState value, $Res Function(StoryDetailState) _then) = _$StoryDetailStateCopyWithImpl; +@useResult +$Res call({ + List chapters, int likesCount, bool isLikedByCurrentUser, LiveChapterModel? liveChapter +}); + + +$LiveChapterModelCopyWith<$Res>? get liveChapter; + +} +/// @nodoc +class _$StoryDetailStateCopyWithImpl<$Res> + implements $StoryDetailStateCopyWith<$Res> { + _$StoryDetailStateCopyWithImpl(this._self, this._then); + + final StoryDetailState _self; + final $Res Function(StoryDetailState) _then; + +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? chapters = null,Object? likesCount = null,Object? isLikedByCurrentUser = null,Object? liveChapter = freezed,}) { + return _then(_self.copyWith( +chapters: null == chapters ? _self.chapters : chapters // ignore: cast_nullable_to_non_nullable +as List,likesCount: null == likesCount ? _self.likesCount : likesCount // ignore: cast_nullable_to_non_nullable +as int,isLikedByCurrentUser: null == isLikedByCurrentUser ? _self.isLikedByCurrentUser : isLikedByCurrentUser // ignore: cast_nullable_to_non_nullable +as bool,liveChapter: freezed == liveChapter ? _self.liveChapter : liveChapter // ignore: cast_nullable_to_non_nullable +as LiveChapterModel?, + )); +} +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveChapterModelCopyWith<$Res>? get liveChapter { + if (_self.liveChapter == null) { + return null; + } + + return $LiveChapterModelCopyWith<$Res>(_self.liveChapter!, (value) { + return _then(_self.copyWith(liveChapter: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [StoryDetailState]. +extension StoryDetailStatePatterns on StoryDetailState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _StoryDetailState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _StoryDetailState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _StoryDetailState value) $default,){ +final _that = this; +switch (_that) { +case _StoryDetailState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _StoryDetailState value)? $default,){ +final _that = this; +switch (_that) { +case _StoryDetailState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List chapters, int likesCount, bool isLikedByCurrentUser, LiveChapterModel? liveChapter)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _StoryDetailState() when $default != null: +return $default(_that.chapters,_that.likesCount,_that.isLikedByCurrentUser,_that.liveChapter);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List chapters, int likesCount, bool isLikedByCurrentUser, LiveChapterModel? liveChapter) $default,) {final _that = this; +switch (_that) { +case _StoryDetailState(): +return $default(_that.chapters,_that.likesCount,_that.isLikedByCurrentUser,_that.liveChapter);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List chapters, int likesCount, bool isLikedByCurrentUser, LiveChapterModel? liveChapter)? $default,) {final _that = this; +switch (_that) { +case _StoryDetailState() when $default != null: +return $default(_that.chapters,_that.likesCount,_that.isLikedByCurrentUser,_that.liveChapter);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _StoryDetailState implements StoryDetailState { + const _StoryDetailState({final List chapters = const [], this.likesCount = 0, this.isLikedByCurrentUser = false, this.liveChapter}): _chapters = chapters; + + + final List _chapters; +@override@JsonKey() List get chapters { + if (_chapters is EqualUnmodifiableListView) return _chapters; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_chapters); +} + +@override@JsonKey() final int likesCount; +@override@JsonKey() final bool isLikedByCurrentUser; +@override final LiveChapterModel? liveChapter; + +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StoryDetailStateCopyWith<_StoryDetailState> get copyWith => __$StoryDetailStateCopyWithImpl<_StoryDetailState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StoryDetailState&&const DeepCollectionEquality().equals(other._chapters, _chapters)&&(identical(other.likesCount, likesCount) || other.likesCount == likesCount)&&(identical(other.isLikedByCurrentUser, isLikedByCurrentUser) || other.isLikedByCurrentUser == isLikedByCurrentUser)&&(identical(other.liveChapter, liveChapter) || other.liveChapter == liveChapter)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_chapters),likesCount,isLikedByCurrentUser,liveChapter); + +@override +String toString() { + return 'StoryDetailState(chapters: $chapters, likesCount: $likesCount, isLikedByCurrentUser: $isLikedByCurrentUser, liveChapter: $liveChapter)'; +} + + +} + +/// @nodoc +abstract mixin class _$StoryDetailStateCopyWith<$Res> implements $StoryDetailStateCopyWith<$Res> { + factory _$StoryDetailStateCopyWith(_StoryDetailState value, $Res Function(_StoryDetailState) _then) = __$StoryDetailStateCopyWithImpl; +@override @useResult +$Res call({ + List chapters, int likesCount, bool isLikedByCurrentUser, LiveChapterModel? liveChapter +}); + + +@override $LiveChapterModelCopyWith<$Res>? get liveChapter; + +} +/// @nodoc +class __$StoryDetailStateCopyWithImpl<$Res> + implements _$StoryDetailStateCopyWith<$Res> { + __$StoryDetailStateCopyWithImpl(this._self, this._then); + + final _StoryDetailState _self; + final $Res Function(_StoryDetailState) _then; + +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? chapters = null,Object? likesCount = null,Object? isLikedByCurrentUser = null,Object? liveChapter = freezed,}) { + return _then(_StoryDetailState( +chapters: null == chapters ? _self._chapters : chapters // ignore: cast_nullable_to_non_nullable +as List,likesCount: null == likesCount ? _self.likesCount : likesCount // ignore: cast_nullable_to_non_nullable +as int,isLikedByCurrentUser: null == isLikedByCurrentUser ? _self.isLikedByCurrentUser : isLikedByCurrentUser // ignore: cast_nullable_to_non_nullable +as bool,liveChapter: freezed == liveChapter ? _self.liveChapter : liveChapter // ignore: cast_nullable_to_non_nullable +as LiveChapterModel?, + )); +} + +/// Create a copy of StoryDetailState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveChapterModelCopyWith<$Res>? get liveChapter { + if (_self.liveChapter == null) { + return null; + } + + return $LiveChapterModelCopyWith<$Res>(_self.liveChapter!, (value) { + return _then(_self.copyWith(liveChapter: value)); + }); +} +} + +// dart format on diff --git a/lib/features/stories/model/generated/story_search_state.freezed.dart b/lib/features/stories/model/generated/story_search_state.freezed.dart new file mode 100644 index 00000000..ec4253a9 --- /dev/null +++ b/lib/features/stories/model/generated/story_search_state.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of '../story_search_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$StorySearchState { + + List get stories; List get users; +/// Create a copy of StorySearchState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StorySearchStateCopyWith get copyWith => _$StorySearchStateCopyWithImpl(this as StorySearchState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StorySearchState&&const DeepCollectionEquality().equals(other.stories, stories)&&const DeepCollectionEquality().equals(other.users, users)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(stories),const DeepCollectionEquality().hash(users)); + +@override +String toString() { + return 'StorySearchState(stories: $stories, users: $users)'; +} + + +} + +/// @nodoc +abstract mixin class $StorySearchStateCopyWith<$Res> { + factory $StorySearchStateCopyWith(StorySearchState value, $Res Function(StorySearchState) _then) = _$StorySearchStateCopyWithImpl; +@useResult +$Res call({ + List stories, List users +}); + + + + +} +/// @nodoc +class _$StorySearchStateCopyWithImpl<$Res> + implements $StorySearchStateCopyWith<$Res> { + _$StorySearchStateCopyWithImpl(this._self, this._then); + + final StorySearchState _self; + final $Res Function(StorySearchState) _then; + +/// Create a copy of StorySearchState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? stories = null,Object? users = null,}) { + return _then(_self.copyWith( +stories: null == stories ? _self.stories : stories // ignore: cast_nullable_to_non_nullable +as List,users: null == users ? _self.users : users // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [StorySearchState]. +extension StorySearchStatePatterns on StorySearchState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _StorySearchState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _StorySearchState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _StorySearchState value) $default,){ +final _that = this; +switch (_that) { +case _StorySearchState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _StorySearchState value)? $default,){ +final _that = this; +switch (_that) { +case _StorySearchState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List stories, List users)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _StorySearchState() when $default != null: +return $default(_that.stories,_that.users);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List stories, List users) $default,) {final _that = this; +switch (_that) { +case _StorySearchState(): +return $default(_that.stories,_that.users);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List stories, List users)? $default,) {final _that = this; +switch (_that) { +case _StorySearchState() when $default != null: +return $default(_that.stories,_that.users);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _StorySearchState implements StorySearchState { + const _StorySearchState({final List stories = const [], final List users = const []}): _stories = stories,_users = users; + + + final List _stories; +@override@JsonKey() List get stories { + if (_stories is EqualUnmodifiableListView) return _stories; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_stories); +} + + final List _users; +@override@JsonKey() List get users { + if (_users is EqualUnmodifiableListView) return _users; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_users); +} + + +/// Create a copy of StorySearchState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StorySearchStateCopyWith<_StorySearchState> get copyWith => __$StorySearchStateCopyWithImpl<_StorySearchState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StorySearchState&&const DeepCollectionEquality().equals(other._stories, _stories)&&const DeepCollectionEquality().equals(other._users, _users)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_stories),const DeepCollectionEquality().hash(_users)); + +@override +String toString() { + return 'StorySearchState(stories: $stories, users: $users)'; +} + + +} + +/// @nodoc +abstract mixin class _$StorySearchStateCopyWith<$Res> implements $StorySearchStateCopyWith<$Res> { + factory _$StorySearchStateCopyWith(_StorySearchState value, $Res Function(_StorySearchState) _then) = __$StorySearchStateCopyWithImpl; +@override @useResult +$Res call({ + List stories, List users +}); + + + + +} +/// @nodoc +class __$StorySearchStateCopyWithImpl<$Res> + implements _$StorySearchStateCopyWith<$Res> { + __$StorySearchStateCopyWithImpl(this._self, this._then); + + final _StorySearchState _self; + final $Res Function(_StorySearchState) _then; + +/// Create a copy of StorySearchState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? stories = null,Object? users = null,}) { + return _then(_StorySearchState( +stories: null == stories ? _self._stories : stories // ignore: cast_nullable_to_non_nullable +as List,users: null == users ? _self._users : users // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/models/live_chapter_attendees_model.dart b/lib/features/stories/model/live_chapter_attendees_model.dart similarity index 100% rename from lib/models/live_chapter_attendees_model.dart rename to lib/features/stories/model/live_chapter_attendees_model.dart diff --git a/lib/models/live_chapter_model.dart b/lib/features/stories/model/live_chapter_model.dart similarity index 90% rename from lib/models/live_chapter_model.dart rename to lib/features/stories/model/live_chapter_model.dart index 33b9bb59..ecab2c0e 100644 --- a/lib/models/live_chapter_model.dart +++ b/lib/features/stories/model/live_chapter_model.dart @@ -1,5 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:resonate/models/live_chapter_attendees_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_attendees_model.dart'; part 'generated/live_chapter_model.freezed.dart'; part 'generated/live_chapter_model.g.dart'; diff --git a/lib/features/stories/model/live_chapter_state.dart b/lib/features/stories/model/live_chapter_state.dart new file mode 100644 index 00000000..9da58714 --- /dev/null +++ b/lib/features/stories/model/live_chapter_state.dart @@ -0,0 +1,12 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; + +part 'generated/live_chapter_state.freezed.dart'; + +@freezed +abstract class LiveChapterState with _$LiveChapterState { + const factory LiveChapterState({ + LiveChapterModel? model, + @Default(false) bool isMicOn, + }) = _LiveChapterState; +} diff --git a/lib/features/stories/model/stories_failure.dart b/lib/features/stories/model/stories_failure.dart new file mode 100644 index 00000000..e24f63df --- /dev/null +++ b/lib/features/stories/model/stories_failure.dart @@ -0,0 +1,10 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'generated/stories_failure.freezed.dart'; + +@freezed +sealed class StoriesFailure with _$StoriesFailure { + const factory StoriesFailure.upload(String what) = StoriesFailureUpload; + const factory StoriesFailure.network() = StoriesFailureNetwork; + const factory StoriesFailure.unknown(String message) = StoriesFailureUnknown; +} diff --git a/lib/features/stories/model/story.dart b/lib/features/stories/model/story.dart new file mode 100644 index 00000000..9e31cd97 --- /dev/null +++ b/lib/features/stories/model/story.dart @@ -0,0 +1,68 @@ +import 'dart:ui'; + +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +class Story { + final String title; + final bool userIsCreator; + final String storyId; + final String description; + final StoryCategory category; + final String coverImageUrl; + final String creatorId; + final String creatorName; + final String creatorImgUrl; + final DateTime creationDate; + final int likesCount; + final bool isLikedByCurrentUser; + final int playDuration; + final Color tintColor; + final List chapters; + final LiveChapterModel? liveChapter; + + const Story({ + required this.title, + required this.storyId, + required this.description, + required this.userIsCreator, + required this.category, + required this.coverImageUrl, + required this.creatorId, + required this.creatorName, + required this.creatorImgUrl, + required this.creationDate, + required this.likesCount, + required this.isLikedByCurrentUser, + required this.playDuration, + required this.tintColor, + required this.chapters, + this.liveChapter, + }); + + Story copyWith({ + int? likesCount, + bool? isLikedByCurrentUser, + int? playDuration, + List? chapters, + LiveChapterModel? liveChapter, + }) => Story( + title: title, + storyId: storyId, + description: description, + userIsCreator: userIsCreator, + category: category, + coverImageUrl: coverImageUrl, + creatorId: creatorId, + creatorName: creatorName, + creatorImgUrl: creatorImgUrl, + creationDate: creationDate, + likesCount: likesCount ?? this.likesCount, + isLikedByCurrentUser: isLikedByCurrentUser ?? this.isLikedByCurrentUser, + playDuration: playDuration ?? this.playDuration, + tintColor: tintColor, + chapters: chapters ?? this.chapters, + liveChapter: liveChapter ?? this.liveChapter, + ); +} diff --git a/lib/features/stories/model/story_detail_state.dart b/lib/features/stories/model/story_detail_state.dart new file mode 100644 index 00000000..a97f96f8 --- /dev/null +++ b/lib/features/stories/model/story_detail_state.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; + +part 'generated/story_detail_state.freezed.dart'; + +// The reactive slice of a story shown on the detail page +@freezed +abstract class StoryDetailState with _$StoryDetailState { + const factory StoryDetailState({ + @Default([]) List chapters, + @Default(0) int likesCount, + @Default(false) bool isLikedByCurrentUser, + LiveChapterModel? liveChapter, + }) = _StoryDetailState; +} diff --git a/lib/features/stories/model/story_search_state.dart b/lib/features/stories/model/story_search_state.dart new file mode 100644 index 00000000..994d0f46 --- /dev/null +++ b/lib/features/stories/model/story_search_state.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/models/resonate_user.dart'; + +part 'generated/story_search_state.freezed.dart'; + +@freezed +abstract class StorySearchState with _$StorySearchState { + const factory StorySearchState({ + @Default([]) List stories, + @Default([]) List users, + }) = _StorySearchState; +} diff --git a/lib/features/stories/stories_routes.dart b/lib/features/stories/stories_routes.dart new file mode 100644 index 00000000..64596a46 --- /dev/null +++ b/lib/features/stories/stories_routes.dart @@ -0,0 +1,26 @@ +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/stories/view/pages/create_story_page.dart'; +import 'package:resonate/features/stories/view/pages/explore_page.dart'; +import 'package:resonate/features/stories/view/pages/live_chapter_page.dart'; +import 'package:resonate/features/stories/view/pages/verify_chapter_details_page.dart'; +import 'package:resonate/routes/route_paths.dart'; + +final List storiesRoutes = [ + GoRoute( + path: RoutePaths.exploreScreen, + builder: (_, _) => const ExplorePage(), + ), + GoRoute( + path: RoutePaths.createStoryScreen, + builder: (_, _) => const CreateStoryPage(), + ), + GoRoute( + path: RoutePaths.liveChapterScreen, + builder: (_, _) => const LiveChapterPage(), + ), + GoRoute( + path: RoutePaths.verifyChapterDetails, + builder: (_, state) => + VerifyChapterDetailsPage(lyricsString: state.extra as String? ?? ''), + ), +]; diff --git a/lib/features/stories/view/pages/add_chapter_page.dart b/lib/features/stories/view/pages/add_chapter_page.dart new file mode 100644 index 00000000..fb854798 --- /dev/null +++ b/lib/features/stories/view/pages/add_chapter_page.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/pages/create_chapter_page.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class AddChapterPage extends ConsumerStatefulWidget { + final String storyName; + final String storyId; + final List currentChapters; + + const AddChapterPage({ + super.key, + required this.storyName, + required this.currentChapters, + required this.storyId, + }); + + @override + ConsumerState createState() => _AddChapterPageState(); +} + +class _AddChapterPageState extends ConsumerState { + final List newChapters = []; + + void _onChapterCreated(Chapter chapter) => + setState(() => newChapters.add(chapter)); + + Future _submit() async { + if (newChapters.isEmpty) return; + final navigator = Navigator.of(context); + await ref + .read(createStoryProvider.notifier) + .addChaptersToStory(newChapters, widget.storyId); + // Pop back past the add-chapter screen and the story screen + navigator.pop(); + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final sectionStyle = TextStyle( + fontSize: UiSizes.size_18, + fontWeight: FontWeight.bold, + ); + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar(title: Text(l10n.addNewChaptersToStory(widget.storyName))), + body: Padding( + padding: EdgeInsets.all(UiSizes.width_16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.currentChapters, style: sectionStyle), + SizedBox(height: UiSizes.height_10), + Expanded( + child: ListView.builder( + itemCount: widget.currentChapters.length, + itemBuilder: (context, index) => + _chapterCard(widget.currentChapters[index]), + ), + ), + SizedBox(height: UiSizes.height_10), + Text(l10n.newChapters, style: sectionStyle), + SizedBox(height: UiSizes.height_10), + Expanded( + child: ListView.builder( + itemCount: newChapters.length, + itemBuilder: (context, index) => + _chapterCard(newChapters[index]), + ), + ), + ], + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + CreateChapterPage(onChapterCreated: _onChapterCreated), + ), + ), + child: const Icon(Icons.add), + ), + SizedBox(height: UiSizes.height_20), + ElevatedButton( + onPressed: _submit, + child: Text(l10n.newChapters), + ), + ], + ), + ), + ); + } + + Widget _chapterCard(Chapter chapter) { + final isLocalFile = chapter.coverImageUrl.startsWith('/'); + return Card( + child: ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_5), + child: isLocalFile + ? Image.file( + File(chapter.coverImageUrl), + width: UiSizes.width_56, + height: UiSizes.width_56, + fit: BoxFit.cover, + ) + : Image.network( + chapter.coverImageUrl, + width: UiSizes.width_56, + height: UiSizes.width_56, + fit: BoxFit.cover, + ), + ), + title: Text(chapter.title), + subtitle: Text( + chapter.description.length > 30 + ? '${chapter.description.substring(0, 30)}...' + : chapter.description, + ), + trailing: Text(formatPlayDuration(chapter.playDuration)), + ), + ); + } +} diff --git a/lib/features/stories/view/pages/category_page.dart b/lib/features/stories/view/pages/category_page.dart new file mode 100644 index 00000000..437057da --- /dev/null +++ b/lib/features/stories/view/pages/category_page.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/app_images.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class CategoryPage extends ConsumerWidget { + const CategoryPage({super.key, required this.category}); + + final StoryCategory category; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final storiesAsync = ref.watch(categoryStoriesProvider(category)); + final label = capitalizeFirstLetter(category.name); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + backgroundColor: colorScheme.surface, + elevation: 0, + title: Text(label), + ), + body: storiesAsync.when( + loading: () => Center( + child: SizedBox( + height: UiSizes.height_200, + width: UiSizes.width_200, + child: LoadingIndicator( + indicatorType: Indicator.ballRotate, + colors: [colorScheme.primary], + ), + ), + ), + error: (e, _) => _empty(context, label), + data: (stories) => stories.isNotEmpty + ? Padding( + padding: EdgeInsets.only(top: UiSizes.height_20), + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + primary: true, + itemCount: stories.length, + itemBuilder: (context, index) => + StoryListTile(story: stories[index]), + ), + ) + : _empty(context, label), + ), + ); + } + + Widget _empty(BuildContext context, String label) { + return Padding( + padding: EdgeInsets.only(bottom: UiSizes.height_140), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + height: UiSizes.height_200, + width: UiSizes.width_200, + AppImages.emptyBoxImage, + ), + SizedBox(height: UiSizes.height_20), + Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_30), + child: Text( + AppLocalizations.of(context)!.noStoriesInCategory(label), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/screens/chapter_play_screen.dart b/lib/features/stories/view/pages/chapter_play_page.dart similarity index 51% rename from lib/views/screens/chapter_play_screen.dart rename to lib/features/stories/view/pages/chapter_play_page.dart index 49db988b..6049dcd7 100644 --- a/lib/views/screens/chapter_play_screen.dart +++ b/lib/features/stories/view/pages/chapter_play_page.dart @@ -1,119 +1,110 @@ import 'package:flutter/material.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:resonate/l10n/app_localizations.dart'; import 'package:flutter_lyric/flutter_lyric.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/chapter_player_controller.dart'; -import 'package:resonate/models/chapter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/widgets/chapter_player.dart'; +import 'package:resonate/features/stories/viewmodel/chapter_player_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/chapter_player.dart'; -class ChapterPlayScreen extends StatefulWidget { - const ChapterPlayScreen({super.key, required this.chapter}); +class ChapterPlayPage extends ConsumerStatefulWidget { + const ChapterPlayPage({super.key, required this.chapter}); final Chapter chapter; @override - State createState() => _ChapterPlayScreenState(); + ConsumerState createState() => _ChapterPlayPageState(); } -class _ChapterPlayScreenState extends State { +class _ChapterPlayPageState extends ConsumerState { late LyricStyle lyricStyle; - final ChapterPlayerController controller = Get.find(); + + @override + void initState() { + super.initState(); + ref + .read(chapterPlayerProvider(widget.chapter.chapterId).notifier) + .initialize( + audioUrl: widget.chapter.audioFileUrl, + lyrics: widget.chapter.lyrics, + duration: Duration(milliseconds: widget.chapter.playDuration), + ); + } @override void didChangeDependencies() { super.didChangeDependencies(); - bool themeIsDark = Theme.of(context).brightness == Brightness.dark; - final Color mainTextColor = themeIsDark - ? const Color.fromARGB(255, 223, 222, 222) - : Colors.grey[600]!; + final onSurface = Theme.of(context).colorScheme.onSurface; + final mainTextColor = onSurface.withValues(alpha: 0.7); lyricStyle = LyricStyles.default1.copyWith( - textStyle: TextStyle( - fontSize: UiSizes.size_18, - color: mainTextColor, - ), + textStyle: TextStyle(fontSize: UiSizes.size_18, color: mainTextColor), activeStyle: TextStyle( fontSize: UiSizes.size_20, fontWeight: FontWeight.bold, color: mainTextColor, ), - activeHighlightColor: themeIsDark ? Colors.white : Colors.black, - selectedColor: themeIsDark ? Colors.white : Colors.black, - contentPadding: const EdgeInsets.symmetric(horizontal: 16), + activeHighlightColor: onSurface, + selectedColor: onSurface, + contentPadding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), anchorPosition: 0.5, ); } - @override - void initState() { - super.initState(); - - controller.initialize( - AudioPlayer()..setSourceUrl(widget.chapter.audioFileUrl), - widget.chapter.lyrics, - Duration(milliseconds: widget.chapter.playDuration), - ); - // Tapping a lyric line seeks to it (replaces the old select-line flow). - controller.lyricController.setOnTapLineCallback((start) { - controller.audioPlayer?.seek(start); - }); - } - - @override - void dispose() { - Get.delete(); - super.dispose(); - } - @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final lyricController = ref + .read(chapterPlayerProvider(widget.chapter.chapterId).notifier) + .lyricController; + // Keeping the provider alive for the lifetime of this page + ref.watch(chapterPlayerProvider(widget.chapter.chapterId)); + final cardColor = colorScheme.onSurface.withValues(alpha: 0.06); + return Scaffold( body: SafeArea( child: CustomScrollView( slivers: [ SliverPersistentHeader( pinned: true, - delegate: ChapterPlayerHeaderDelegate(chapter: widget.chapter), + delegate: _ChapterPlayerHeaderDelegate(chapter: widget.chapter), ), SliverList( delegate: SliverChildListDelegate([ Container( - padding: const EdgeInsets.all(10), + padding: EdgeInsets.all(UiSizes.width_10), decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? const Color.fromARGB(106, 40, 39, 39) - : const Color.fromARGB(193, 232, 230, 230), - borderRadius: BorderRadius.circular(20), + color: cardColor, + borderRadius: BorderRadius.circular(UiSizes.width_20), ), width: double.infinity, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ Center( child: Container( clipBehavior: Clip.hardEdge, - width: 120, + width: UiSizes.width_111, + height: UiSizes.height_5, decoration: BoxDecoration( - color: Colors.yellow, - borderRadius: BorderRadius.circular(200), + color: colorScheme.primary, + borderRadius: BorderRadius.circular( + UiSizes.width_200, + ), ), - height: 5, ), ), Padding( - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_10, + ), child: Container( - height: 200, + height: UiSizes.height_200, decoration: BoxDecoration( - color: - Theme.of(context).brightness == - Brightness.dark - ? const Color.fromARGB(106, 40, 39, 39) - : const Color.fromARGB(193, 232, 230, 230), - borderRadius: BorderRadius.circular(10), + color: cardColor, + borderRadius: BorderRadius.circular( + UiSizes.width_10, + ), ), child: widget.chapter.lyrics.trim().isEmpty ? Center( @@ -125,33 +116,30 @@ class _ChapterPlayScreenState extends State { : Stack( children: [ LyricView( - controller: controller.lyricController, + controller: lyricController, style: lyricStyle, - height: 200, + height: UiSizes.height_200, ), LyricSelectionProgress( - controller: controller.lyricController, + controller: lyricController, style: lyricStyle, - onPlay: (state) { - controller.audioPlayer?.seek( - state.duration, - ); - }, + onPlay: (state) => ref + .read( + chapterPlayerProvider( + widget.chapter.chapterId, + ).notifier, + ) + .seek(state.duration), ), ], ), ), ), - - // added a second extra to cover up the error of the meta data library Container( - padding: const EdgeInsets.all(10), + padding: EdgeInsets.all(UiSizes.width_10), decoration: BoxDecoration( - color: - Theme.of(context).brightness == Brightness.dark - ? const Color.fromARGB(106, 40, 39, 39) - : const Color.fromARGB(193, 232, 230, 230), - borderRadius: BorderRadius.circular(10), + color: cardColor, + borderRadius: BorderRadius.circular(UiSizes.width_10), ), width: double.infinity, child: Column( @@ -161,39 +149,33 @@ class _ChapterPlayScreenState extends State { AppLocalizations.of(context)!.about, style: Theme.of(context).textTheme.bodyMedium! .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_17, fontFamily: 'Inter', ), ), - const SizedBox(height: 10), + SizedBox(height: UiSizes.height_10), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 5.0, + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_5, ), child: Text( widget.chapter.description, style: Theme.of(context).textTheme.bodyMedium! .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w300, - fontSize: 16, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_16, fontFamily: 'Inter', ), ), ), - const SizedBox(height: 5), + SizedBox(height: UiSizes.height_5), ], ), ), - const SizedBox(height: 20), + SizedBox(height: UiSizes.height_20), ], ), ), @@ -207,9 +189,9 @@ class _ChapterPlayScreenState extends State { } } -class ChapterPlayerHeaderDelegate extends SliverPersistentHeaderDelegate { +class _ChapterPlayerHeaderDelegate extends SliverPersistentHeaderDelegate { final Chapter chapter; - const ChapterPlayerHeaderDelegate({required this.chapter}); + const _ChapterPlayerHeaderDelegate({required this.chapter}); @override Widget build( @@ -218,7 +200,7 @@ class ChapterPlayerHeaderDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { final progress = shrinkOffset / maxExtent; - return ChapterPlayer(chapter: chapter, progress: progress); + return ChapterPlayerView(chapter: chapter, progress: progress); } @override diff --git a/lib/features/stories/view/pages/create_chapter_page.dart b/lib/features/stories/view/pages/create_chapter_page.dart new file mode 100644 index 00000000..1d1f0134 --- /dev/null +++ b/lib/features/stories/view/pages/create_chapter_page.dart @@ -0,0 +1,283 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/audio_format.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/enums/lyrics_format.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class CreateChapterPage extends ConsumerStatefulWidget { + const CreateChapterPage({super.key, required this.onChapterCreated}); + + final void Function(Chapter) onChapterCreated; + + @override + ConsumerState createState() => _CreateChapterPageState(); +} + +class _CreateChapterPageState extends ConsumerState { + final titleController = TextEditingController(); + final aboutController = TextEditingController(); + File? chapterCoverImage; + File? audioFile; + File? lyricsFile; + + @override + void dispose() { + titleController.dispose(); + aboutController.dispose(); + super.dispose(); + } + + Future _pickCoverImage() async { + final selected = await ImagePicker().pickImage(source: ImageSource.gallery); + if (selected != null) { + setState(() => chapterCoverImage = File(selected.path)); + } + } + + Future _pickAudioFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: AudioFormat.extensions, + ); + if (!mounted) return; + final file = result?.files.single; + if (file == null) return; + if (!_hasAllowedExtension(file, AudioFormat.extensions)) { + _showFormatError(AudioFormat.extensions); + return; + } + setState(() => audioFile = File(file.path!)); + } + + Future _pickLyricsFile() async { + final result = await FilePicker.platform.pickFiles(type: FileType.any); + if (!mounted) return; + final file = result?.files.single; + if (file == null) return; + if (!_hasAllowedExtension(file, LyricsFormat.extensions)) { + _showFormatError(LyricsFormat.extensions); + return; + } + setState(() => lyricsFile = File(file.path!)); + } + + bool _hasAllowedExtension(PlatformFile file, List allowed) { + final ext = file.extension?.toLowerCase(); + return ext != null && allowed.contains(ext); + } + + void _showFormatError(List allowed) { + customSnackbar( + AppLocalizations.of(context)!.invalidFormat, + allowed.map((e) => '.$e').join(', '), + LogType.error, + ); + } + + Future _createChapter() async { + final l10n = AppLocalizations.of(context)!; + if (titleController.text.isEmpty || + aboutController.text.isEmpty || + audioFile == null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.error), + content: Text(l10n.fillAllRequiredFields), + actions: [ + TextButton( + child: Text(l10n.ok), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + + final navigator = Navigator.of(context); + final chapter = await ref + .read(createStoryProvider.notifier) + .buildChapter( + title: titleController.text, + description: aboutController.text, + coverImgPath: chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, + audioFilePath: audioFile!.path, + lyricsFilePath: lyricsFile?.path ?? '', + ); + + widget.onChapterCreated(chapter); + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: () { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: colorScheme.surface, + appBar: AppBar(title: Text(l10n.createAChapter)), + body: Padding( + padding: EdgeInsets.all(UiSizes.width_16), + child: Column( + children: [ + TextField( + controller: titleController, + maxLines: 1, + maxLength: 20, + decoration: InputDecoration( + labelText: l10n.chapterTitle, + counterText: '', + ), + ), + SizedBox(height: UiSizes.height_20), + TextField( + controller: aboutController, + maxLength: 2000, + maxLines: 3, + decoration: InputDecoration( + labelText: l10n.aboutRequired, + counterText: '', + ), + ), + SizedBox(height: UiSizes.height_20), + _coverPicker(context), + SizedBox(height: UiSizes.height_30), + _filePicker( + context, + onTap: _pickAudioFile, + label: audioFile != null + ? l10n.audioFileSelected(audioFile!.path.split('/').last) + : l10n.uploadAudioFile, + ), + SizedBox(height: UiSizes.height_20), + _filePicker( + context, + onTap: _pickLyricsFile, + label: lyricsFile != null + ? l10n.lyricsFileSelected(lyricsFile!.path.split('/').last) + : l10n.uploadLyricsFile, + ), + SizedBox(height: UiSizes.height_40), + ElevatedButton( + onPressed: _createChapter, + child: Text(l10n.createChapter), + ), + ], + ), + ), + ), + ); + } + + Widget _coverPicker(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_20), + child: chapterCoverImage != null + ? Image.file( + chapterCoverImage!, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ) + : Image.network( + chapterCoverImagePlaceholderUrl, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ), + ), + ), + ), + SizedBox(width: UiSizes.width_10), + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: GestureDetector( + onTap: _pickCoverImage, + child: Container( + height: UiSizes.height_140, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(UiSizes.width_20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.change_circle, + size: UiSizes.size_40, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + Text(l10n.changeCoverImage, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _filePicker( + BuildContext context, { + required VoidCallback onTap, + required String label, + }) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + height: UiSizes.height_50, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.6), + ), + borderRadius: BorderRadius.circular(UiSizes.width_8), + ), + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_8), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/stories/view/pages/create_story_page.dart b/lib/features/stories/view/pages/create_story_page.dart new file mode 100644 index 00000000..a14ecc11 --- /dev/null +++ b/lib/features/stories/view/pages/create_story_page.dart @@ -0,0 +1,311 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/pages/create_chapter_page.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; +import 'package:resonate/utils/enums/log_type.dart'; + +class CreateStoryPage extends ConsumerStatefulWidget { + const CreateStoryPage({super.key}); + + @override + ConsumerState createState() => _CreateStoryPageState(); +} + +class _CreateStoryPageState extends ConsumerState { + final titleController = TextEditingController(); + final aboutController = TextEditingController(); + final List chapters = []; + StoryCategory selectedCategory = StoryCategory.drama; + File? coverImage; + bool _isCreating = false; + + @override + void dispose() { + titleController.dispose(); + aboutController.dispose(); + super.dispose(); + } + + void _addChapter(Chapter chapter) => setState(() => chapters.add(chapter)); + + Future _pickCoverImage() async { + final selected = await ImagePicker().pickImage(source: ImageSource.gallery); + if (selected != null) setState(() => coverImage = File(selected.path)); + } + + Future _createStory() async { + if (_isCreating) return; + final l10n = AppLocalizations.of(context)!; + if (titleController.text.isEmpty || + aboutController.text.isEmpty || + chapters.isEmpty) { + _showError(l10n.fillAllRequiredFieldsAndChapter); + return; + } + + final totalPlayDuration = chapters.fold( + 0, + (sum, chapter) => sum + chapter.playDuration, + ); + final router = GoRouter.of(context); + setState(() => _isCreating = true); + try { + await ref + .read(createStoryProvider.notifier) + .createStory( + title: titleController.text, + description: aboutController.text, + category: selectedCategory, + coverImgRef: coverImage?.path ?? storyCoverImagePlaceholderUrl, + storyPlayDuration: totalPlayDuration, + chapters: chapters, + ); + } catch (e) { + log('Story creation failed: $e'); + if (mounted) setState(() => _isCreating = false); + customSnackbar(l10n.error, e.toString(), LogType.error); + return; + } + router.go(RoutePaths.tabview); + } + + void _showError(String message) { + final l10n = AppLocalizations.of(context)!; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.error), + content: Text(message), + actions: [ + TextButton( + child: Text(l10n.ok), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final labelStyle = TextStyle(color: colorScheme.onSurfaceVariant); + + return GestureDetector( + onTap: () { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: colorScheme.surface, + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(l10n.createYourStory), + ), + body: Padding( + padding: EdgeInsets.all(UiSizes.width_16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: titleController, + decoration: InputDecoration( + labelText: l10n.titleRequired, + labelStyle: labelStyle, + enabledBorder: _border(colorScheme.inversePrimary), + focusedBorder: _border(colorScheme.primary), + counterText: '', + ), + maxLength: 100, + ), + SizedBox(height: UiSizes.height_30), + DropdownButtonFormField( + initialValue: selectedCategory, + decoration: InputDecoration( + labelText: l10n.category, + border: _border(colorScheme.inversePrimary), + enabledBorder: _border(colorScheme.inversePrimary), + focusedBorder: _border(colorScheme.primary), + ), + items: StoryCategory.values + .map( + (category) => DropdownMenuItem( + value: category, + child: Text( + l10n.storyCategory(category.name), + style: labelStyle, + ), + ), + ) + .toList(), + onChanged: (value) => + setState(() => selectedCategory = value!), + ), + SizedBox(height: UiSizes.height_30), + TextField( + controller: aboutController, + maxLength: 2000, + maxLines: 3, + decoration: InputDecoration( + labelText: l10n.aboutRequired, + labelStyle: labelStyle, + enabledBorder: _border(colorScheme.inversePrimary), + focusedBorder: _border(colorScheme.primary), + counterText: '', + ), + ), + SizedBox(height: UiSizes.height_20), + _coverPicker(context), + SizedBox(height: UiSizes.height_30), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: chapters.length, + itemBuilder: (context, index) { + final chapter = chapters[index]; + return Card( + margin: EdgeInsets.symmetric(vertical: UiSizes.height_8), + elevation: 2, + child: ListTile( + title: Text( + chapter.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + chapter.description, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + formatPlayDuration(chapter.playDuration), + ), + ), + ); + }, + ), + SizedBox(height: UiSizes.height_10), + Center( + child: ElevatedButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + CreateChapterPage(onChapterCreated: _addChapter), + ), + ), + icon: const Icon(Icons.add), + label: Text(l10n.addChapter), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + ), + ), + SizedBox(height: UiSizes.height_20), + Center( + child: ElevatedButton( + onPressed: _isCreating ? null : _createStory, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + child: _isCreating + ? SizedBox( + height: UiSizes.size_18, + width: UiSizes.size_18, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.createStory), + ), + ), + ], + ), + ), + ), + ), + ); + } + + OutlineInputBorder _border(Color color) => OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: color), + ); + + Widget _coverPicker(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_20), + child: coverImage != null + ? Image.file( + coverImage!, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ) + : Image.network( + storyCoverImagePlaceholderUrl, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ), + ), + ), + ), + SizedBox(width: UiSizes.width_10), + Expanded( + child: GestureDetector( + onTap: _pickCoverImage, + child: Container( + margin: EdgeInsets.all(UiSizes.width_8), + height: UiSizes.height_140, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(UiSizes.width_20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.change_circle, + size: UiSizes.size_40, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + Text(l10n.changeCoverImage, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/stories/view/pages/explore_page.dart b/lib/features/stories/view/pages/explore_page.dart new file mode 100644 index 00000000..1dccda8e --- /dev/null +++ b/lib/features/stories/view/pages/explore_page.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/view/widgets/category_card.dart'; +import 'package:resonate/features/stories/view/widgets/filtered_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/story_card.dart'; +import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/story_search_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/app_images.dart'; +import 'package:resonate/utils/colors.dart'; +import 'package:resonate/utils/debouncer.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/no_match_view.dart'; + +class ExplorePage extends ConsumerStatefulWidget { + const ExplorePage({super.key}); + + @override + ConsumerState createState() => _ExplorePageState(); +} + +class _ExplorePageState extends ConsumerState { + final _debouncer = Debouncer(milliseconds: 500); + bool _isSearching = false; + bool _searchBarIsEmpty = true; + + void _onSearchChanged(String value) { + setState(() { + _searchBarIsEmpty = value.isEmpty; + if (value.isNotEmpty) _isSearching = true; + }); + if (value.isEmpty) { + ref.read(storySearchProvider.notifier).clear(); + return; + } + _debouncer.run(() async { + await ref.read(storySearchProvider.notifier).search(value); + if (mounted) setState(() => _isSearching = false); + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return GestureDetector( + onTap: () { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Scaffold( + backgroundColor: colorScheme.surface, + body: SingleChildScrollView( + padding: EdgeInsets.only( + left: UiSizes.width_16, + right: UiSizes.width_16, + top: UiSizes.height_30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + style: TextStyle(color: colorScheme.onSecondary), + onChanged: _onSearchChanged, + decoration: InputDecoration( + contentPadding: EdgeInsets.symmetric( + horizontal: 0, + vertical: UiSizes.height_15, + ), + border: const OutlineInputBorder( + gapPadding: 4, + borderSide: BorderSide(style: BorderStyle.none, width: 0), + ), + fillColor: colorScheme.secondary, + filled: true, + hintText: l10n.whatDoYouWantToListenTo, + hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: colorScheme.onSecondary, + fontWeight: FontWeight.w600, + fontSize: UiSizes.size_17, + fontFamily: 'Inter', + ), + prefixIcon: Padding( + padding: EdgeInsets.only(left: UiSizes.width_16), + child: Icon( + Icons.search, + color: colorScheme.onSecondary, + size: UiSizes.size_35, + ), + ), + ), + ), + SizedBox(height: UiSizes.height_30), + if (_searchBarIsEmpty) + _ExploreContent() + else + _searchResults(context), + ], + ), + ), + ), + ); + } + + Widget _searchResults(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + if (_isSearching) { + return Center( + child: SizedBox( + height: UiSizes.height_56, + width: UiSizes.width_56, + child: LoadingIndicator( + indicatorType: Indicator.ballGridPulse, + colors: [colorScheme.primary], + ), + ), + ); + } + + final results = ref.watch(storySearchProvider); + if (results.stories.isEmpty && results.users.isEmpty) { + return const NoMatchView(); + } + + return SizedBox( + height: MediaQuery.of(context).size.height * .8, + width: double.infinity, + child: ListView.builder( + itemCount: results.stories.length + results.users.length, + itemBuilder: (context, index) { + if (index < results.stories.length) { + return FilteredListTile(story: results.stories[index], isStory: true); + } + final user = results.users[index - results.stories.length]; + return FilteredListTile(user: user, isStory: false); + }, + ), + ); + } +} + +class _ExploreContent extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final storiesAsync = ref.watch(exploreStoriesProvider); + + final sectionHeader = Theme.of(context).textTheme.bodyLarge!.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w900, + fontSize: UiSizes.size_20, + fontFamily: 'Inter', + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.categories, style: sectionHeader), + SizedBox(height: UiSizes.height_10), + // shrink-wrapped so the grid sizes to its content + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: StoryCategory.values.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10.0, + childAspectRatio: 1.68, + ), + itemBuilder: (context, index) { + final category = StoryCategory.values[index]; + return CategoryCard( + category: category, + color: AppColor.categoryColorList[category.name.toLowerCase()]!, + ); + }, + ), + SizedBox(height: UiSizes.height_20), + Text(l10n.stories, style: sectionHeader), + SizedBox(height: UiSizes.height_10), + storiesAsync.when( + loading: () => _loader(context), + error: (e, _) => _emptyStories(context), + data: (stories) => stories.isEmpty + ? _emptyStories(context) + : _recommended(context, stories), + ), + ], + ); + } + + Widget _recommended(BuildContext context, List stories) { + final l10n = AppLocalizations.of(context)!; + final topCount = stories.length > 4 ? 4 : stories.length; + final rest = stories.length > 4 ? stories.sublist(4) : []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: UiSizes.height_246, + width: double.infinity, + child: ListView.builder( + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: topCount, + itemBuilder: (context, index) => Container( + margin: EdgeInsets.all(UiSizes.width_10), + child: StoryCard(story: stories[index]), + ), + ), + ), + SizedBox(height: UiSizes.height_35), + Text( + l10n.someSuggestions, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w900, + fontSize: UiSizes.size_20, + fontFamily: 'Inter', + ), + ), + SizedBox(height: UiSizes.height_10), + if (rest.isNotEmpty) + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: rest.length, + itemBuilder: (context, index) => + StoryListTile(story: rest[index]), + ) + else + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: topCount, + itemBuilder: (context, index) => + StoryListTile(story: stories[index]), + ), + SizedBox(height: UiSizes.height_20), + ], + ); + } + + Widget _loader(BuildContext context) => Center( + child: SizedBox( + height: UiSizes.height_82, + width: UiSizes.width_80, + child: LoadingIndicator( + indicatorType: Indicator.ballRotate, + colors: [Theme.of(context).colorScheme.primary], + ), + ), + ); + + Widget _emptyStories(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + height: UiSizes.height_200, + width: UiSizes.width_200, + AppImages.emptyBoxImage, + ), + SizedBox(height: UiSizes.height_10), + Text(AppLocalizations.of(context)!.noStoriesExist), + ], + ); +} diff --git a/lib/features/stories/view/pages/live_chapter_page.dart b/lib/features/stories/view/pages/live_chapter_page.dart new file mode 100644 index 00000000..6141dffc --- /dev/null +++ b/lib/features/stories/view/pages/live_chapter_page.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/view/widgets/audio_selector_dialog.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_attendee_block.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_header.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class LiveChapterPage extends ConsumerWidget { + const LiveChapterPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final liveState = ref.watch(liveChapterProvider); + final model = liveState.model; + + if (model == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final isAdmin = model.authorUid == currentAuthUser?.uid; + + return Scaffold( + body: Padding( + padding: EdgeInsets.symmetric(vertical: UiSizes.height_10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_20), + child: LiveChapterHeader( + chapterName: model.chapterTitle, + chapterDescription: model.chapterDescription, + ), + ), + SizedBox(height: UiSizes.height_7), + Expanded(child: _participants(context, ref, isAdmin)), + ], + ), + ), + ); + } + + Widget _participants(BuildContext context, WidgetRef ref, bool isAdmin) { + final colorScheme = Theme.of(context).colorScheme; + final model = ref.watch(liveChapterProvider).model!; + final attendees = model.attendees?.users ?? const []; + + return Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiSizes.width_16), + color: colorScheme.onSecondary.withValues(alpha: 0.15), + ), + ), + SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.participants, + style: TextStyle( + fontSize: UiSizes.size_18, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: UiSizes.height_10), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: UiSizes.width_20, + mainAxisSpacing: UiSizes.height_5, + childAspectRatio: 2.5 / 3, + ), + itemCount: attendees.length + 1, + itemBuilder: (context, index) { + return LiveChapterAttendeeBlock( + user: index == 0 + ? { + 'profileImageUrl': model.authorProfileImageUrl, + 'name': model.authorName, + '\$id': model.authorUid, + } + : attendees[index - 1], + ); + }, + ), + ], + ), + ), + ), + _footer(context, ref, isAdmin), + ], + ); + } + + Widget _footer(BuildContext context, WidgetRef ref, bool isAdmin) { + final colorScheme = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.bottomCenter, + child: Container( + height: MediaQuery.of(context).size.height * 0.07, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiSizes.width_25), + color: colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _LeaveButton(isAdmin: isAdmin), + if (isAdmin) ...[const _MicButton(), const _RecordButton()], + FloatingActionButton( + heroTag: null, + onPressed: () => showAudioDeviceSelector(context), + backgroundColor: colorScheme.secondary, + child: const Icon(Icons.settings_voice), + ), + ], + ), + ), + ); + } +} + +// End-call / leave control. +class _LeaveButton extends ConsumerWidget { + const _LeaveButton({required this.isAdmin}); + final bool isAdmin; + + Future _confirm(BuildContext context, String action) async { + final l10n = AppLocalizations.of(context)!; + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.areYouSure), + content: Text(l10n.toRoomAction(action)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.confirm), + ), + ], + ), + ); + return result ?? false; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + return ElevatedButton( + onPressed: () async { + final notifier = ref.read(liveChapterProvider.notifier); + final router = GoRouter.of(context); + final confirmed = await _confirm( + context, + isAdmin ? l10n.delete : l10n.leave, + ); + if (!confirmed) return; + + if (isAdmin) { + if (ref.read(liveKitProvider).isRecording) { + final lyrics = await notifier.endLiveChapter(); + router.push(RoutePaths.verifyChapterDetails, extra: lyrics); + } else { + customSnackbar(l10n.error, l10n.noRecordingError, LogType.error); + } + } else { + await notifier.leaveRoom(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiSizes.width_20), + ), + ), + child: Icon(Icons.call_end, size: UiSizes.size_24), + ); + } +} + +class _MicButton extends ConsumerWidget { + const _MicButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMicOn = ref.watch(liveChapterProvider).isMicOn; + final notifier = ref.read(liveChapterProvider.notifier); + return FloatingActionButton( + heroTag: null, + onPressed: () => isMicOn ? notifier.turnOffMic() : notifier.turnOnMic(), + // Mic on/off is a semantic green/red control. + backgroundColor: isMicOn ? Colors.lightGreen : Colors.redAccent, + child: Icon( + isMicOn ? Icons.mic : Icons.mic_off, + color: Colors.black, + ), + ); + } +} + +class _RecordButton extends ConsumerWidget { + const _RecordButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final isRecording = ref.watch(liveKitProvider).isRecording; + return FloatingActionButton( + heroTag: null, + onPressed: () { + if (isRecording) { + customSnackbar( + l10n.actionBlocked, + l10n.cannotStopRecording, + LogType.info, + ); + } else { + ref.read(liveChapterProvider.notifier).setRecording(true); + } + }, + backgroundColor: isRecording ? Colors.red : Colors.green, + child: Icon(isRecording ? Icons.stop_circle : Icons.radio_button_checked), + ); + } +} diff --git a/lib/features/stories/view/pages/story_page.dart b/lib/features/stories/view/pages/story_page.dart new file mode 100644 index 00000000..4b902a94 --- /dev/null +++ b/lib/features/stories/view/pages/story_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/view/pages/add_chapter_page.dart'; +import 'package:resonate/features/stories/view/pages/chapter_play_page.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/view/widgets/chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/like_button.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/start_live_chapter_dialog.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/extensions/datetime_extension.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class StoryPage extends ConsumerStatefulWidget { + const StoryPage({super.key, required this.story}); + final Story story; + + @override + ConsumerState createState() => _StoryPageState(); +} + +class _StoryPageState extends ConsumerState { + bool descriptionIsExpanded = false; + + @override + Widget build(BuildContext context) { + final story = widget.story; + // Tint the status bar to match the story. + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: story.tintColor.withValues(alpha: 0.8), + statusBarIconBrightness: Brightness.light, + ), + ); + + final detailAsync = ref.watch(storyDetailProvider(story.storyId)); + + return Scaffold( + body: SafeArea( + child: detailAsync.when( + loading: () => Center( + child: SizedBox( + height: UiSizes.height_200, + width: UiSizes.width_200, + child: LoadingIndicator( + indicatorType: Indicator.ballRotate, + colors: [Theme.of(context).colorScheme.primary], + ), + ), + ), + error: (e, _) => Center( + child: Text(AppLocalizations.of(context)!.error), + ), + data: (detail) => _content(context, story, detail), + ), + ), + ); + } + + Widget _content(BuildContext context, Story story, StoryDetailState detail) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + _header(context, story, detail), + SizedBox(height: UiSizes.height_10), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + vertical: UiSizes.height_8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + child: Text( + l10n.about, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w900, + fontSize: UiSizes.size_20, + fontFamily: 'Inter', + ), + ), + ), + SizedBox(height: UiSizes.height_8), + GestureDetector( + onTap: () => setState( + () => descriptionIsExpanded = !descriptionIsExpanded, + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + ), + child: Text( + story.description, + maxLines: descriptionIsExpanded ? null : 10, + overflow: descriptionIsExpanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + ), + ), + SizedBox(height: UiSizes.height_40), + Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + child: Text( + l10n.chapters, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w900, + fontSize: UiSizes.size_20, + fontFamily: 'Inter', + ), + ), + ), + SizedBox(height: UiSizes.height_8), + _chaptersList(context, detail), + SizedBox(height: UiSizes.height_50), + if (story.userIsCreator) + _creatorActions(context, story, detail), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _header(BuildContext context, Story story, StoryDetailState detail) { + final l10n = AppLocalizations.of(context)!; + final tint = story.tintColor; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + tint.withValues(alpha: 0.8), + tint.withValues(alpha: 0.6), + tint.withValues(alpha: 0.4), + tint.withValues(alpha: 0.2), + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: EdgeInsets.all(UiSizes.width_20), + child: Column( + children: [ + SizedBox(height: UiSizes.height_20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.bottomRight, + children: [ + Padding( + padding: EdgeInsets.only( + right: UiSizes.width_10, + bottom: UiSizes.height_15, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_10), + child: Image.network( + story.coverImageUrl, + width: UiSizes.width_111, + height: UiSizes.width_111, + fit: BoxFit.cover, + ), + ), + ), + LikeButton( + isLikedByUser: detail.isLikedByCurrentUser, + tintColor: tint, + onLiked: (_) => ref + .read(storyDetailProvider(story.storyId).notifier) + .toggleLike(story), + ), + ], + ), + SizedBox(width: UiSizes.width_16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + story.title, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w500, + fontSize: UiSizes.size_35, + overflow: TextOverflow.ellipsis, + fontFamily: 'Inter', + ), + ), + SizedBox(height: UiSizes.height_8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${detail.likesCount} ${l10n.likes}', + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + SizedBox(width: UiSizes.width_16), + Text( + '${formatPlayDuration(story.playDuration)} ${l10n.lengthMinutes}', + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + ], + ), + SizedBox(height: UiSizes.height_8), + Row( + children: [ + Text( + l10n.by, + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + SizedBox(width: UiSizes.width_16), + CircleAvatar( + radius: UiSizes.width_16, + backgroundImage: NetworkImage(story.creatorImgUrl), + ), + SizedBox(width: UiSizes.width_8), + Expanded( + child: Text( + story.userIsCreator ? l10n.you : story.creatorName, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + SizedBox(height: UiSizes.height_40), + Text( + l10n.created(story.creationDate.formatDateTime(context)), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), + ], + ), + ), + ); + } + + Future _joinLiveChapter(LiveChapterModel live) async { + final router = GoRouter.of(context); + final l10n = AppLocalizations.of(context)!; + try { + await ref + .read(liveChapterProvider.notifier) + .joinLiveChapter(live.livekitRoomId, live); + router.push(RoutePaths.liveChapterScreen); + } catch (e) { + customSnackbar(l10n.error, e.toString(), LogType.error); + } + } + + Widget _chaptersList(BuildContext context, StoryDetailState detail) { + final hasLive = detail.liveChapter != null; + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: hasLive ? detail.chapters.length + 1 : detail.chapters.length, + itemBuilder: (context, index) { + if (index == 0 && hasLive) { + final live = detail.liveChapter!; + return GestureDetector( + onTap: () => _joinLiveChapter(live), + child: LiveChapterListTile(chapter: live), + ); + } + final chapter = detail.chapters[hasLive ? index - 1 : index]; + return GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => ChapterPlayPage(chapter: chapter)), + ), + child: ChapterListTile(chapter: chapter), + ); + }, + ); + } + + Widget _creatorActions( + BuildContext context, + Story story, + StoryDetailState detail, + ) { + final l10n = AppLocalizations.of(context)!; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AddChapterPage( + storyName: story.title, + storyId: story.storyId, + currentChapters: detail.chapters, + ), + ), + ), + child: Text(l10n.addChapter), + ), + ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => StartLiveChapterDialog(story: story), + ), + child: Text(l10n.liveChapter), + ), + ], + ), + SizedBox(height: UiSizes.height_20), + ElevatedButton( + onPressed: () async { + final navigator = Navigator.of(context); + await ref + .read(storyDetailProvider(story.storyId).notifier) + .deleteStory(story); + navigator.pop(); + }, + child: Text(l10n.deleteStory), + ), + ], + ); + } +} diff --git a/lib/features/stories/view/pages/verify_chapter_details_page.dart b/lib/features/stories/view/pages/verify_chapter_details_page.dart new file mode 100644 index 00000000..54577de6 --- /dev/null +++ b/lib/features/stories/view/pages/verify_chapter_details_page.dart @@ -0,0 +1,289 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class VerifyChapterDetailsPage extends ConsumerStatefulWidget { + const VerifyChapterDetailsPage({super.key, required this.lyricsString}); + final String lyricsString; + + @override + ConsumerState createState() => + _VerifyChapterDetailsPageState(); +} + +class _VerifyChapterDetailsPageState + extends ConsumerState { + final titleController = TextEditingController(); + final aboutController = TextEditingController(); + final lyricsController = TextEditingController(); + File? chapterCoverImage; + File? audioFile; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadInitialData()); + } + + @override + void dispose() { + titleController.dispose(); + aboutController.dispose(); + lyricsController.dispose(); + super.dispose(); + } + + Future _loadInitialData() async { + final model = ref.read(liveChapterProvider).model; + if (model == null) return; + final storagePath = await getApplicationDocumentsDirectory(); + audioFile = File( + "${storagePath.path}/recordings/${model.livekitRoomId}.wav", + ); + titleController.text = model.chapterTitle; + aboutController.text = model.chapterDescription; + lyricsController.text = widget.lyricsString; + setState(() {}); + } + + Future _pickCoverImage() async { + final selected = await ImagePicker().pickImage(source: ImageSource.gallery); + if (selected != null) { + setState(() => chapterCoverImage = File(selected.path)); + } + } + + Future _viewOrEditLyrics() async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.viewOrEditLyrics), + content: TextField( + controller: lyricsController, + maxLines: 10, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } + + Future _createChapter() async { + final l10n = AppLocalizations.of(context)!; + final model = ref.read(liveChapterProvider).model; + if (titleController.text.isEmpty || + aboutController.text.isEmpty || + audioFile == null || + model == null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.error), + content: Text(l10n.fillAllRequiredFields), + actions: [ + TextButton( + child: Text(l10n.ok), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + + final router = GoRouter.of(context); + final chapter = await ref + .read(storiesRepositoryProvider) + .buildRecordedChapter( + chapterId: model.id, + title: titleController.text, + description: aboutController.text, + coverImgPath: + chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, + audioFilePath: audioFile!.path, + lyrics: lyricsController.text, + ); + await ref + .read(createStoryProvider.notifier) + .addChaptersToStory([chapter], model.storyId); + + ref.read(liveChapterProvider.notifier).reset(); + router.go(RoutePaths.tabview); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: () { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: colorScheme.surface, + appBar: AppBar(title: Text(l10n.verifyChapterDetails)), + body: Padding( + padding: EdgeInsets.all(UiSizes.width_16), + child: Column( + children: [ + TextField( + controller: titleController, + maxLines: 1, + maxLength: 20, + decoration: InputDecoration( + labelText: l10n.chapterTitle, + counterText: '', + ), + ), + SizedBox(height: UiSizes.height_20), + TextField( + controller: aboutController, + maxLength: 2000, + maxLines: 3, + decoration: InputDecoration( + labelText: l10n.aboutRequired, + counterText: '', + ), + ), + SizedBox(height: UiSizes.height_20), + _coverPicker(context), + SizedBox(height: UiSizes.height_30), + _infoTile( + context, + onTap: null, + label: audioFile != null + ? l10n.audioFileSelected(audioFile!.path.split('/').last) + : l10n.uploadAudioFile, + ), + SizedBox(height: UiSizes.height_20), + _infoTile( + context, + onTap: _viewOrEditLyrics, + label: l10n.viewOrEditLyrics, + ), + SizedBox(height: UiSizes.height_40), + ElevatedButton( + onPressed: _createChapter, + child: Text(l10n.createChapter), + ), + ], + ), + ), + ), + ); + } + + Widget _coverPicker(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_20), + child: chapterCoverImage != null + ? Image.file( + chapterCoverImage!, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ) + : Image.network( + chapterCoverImagePlaceholderUrl, + fit: BoxFit.cover, + height: UiSizes.height_140, + width: UiSizes.height_140, + ), + ), + ), + ), + SizedBox(width: UiSizes.width_10), + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: GestureDetector( + onTap: _pickCoverImage, + child: Container( + height: UiSizes.height_140, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(UiSizes.width_20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.change_circle, + size: UiSizes.size_40, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + Text(l10n.changeCoverImage, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _infoTile( + BuildContext context, { + required VoidCallback? onTap, + required String label, + }) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + height: UiSizes.height_50, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.6), + ), + borderRadius: BorderRadius.circular(UiSizes.width_8), + ), + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_8), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/stories/view/story_format.dart b/lib/features/stories/view/story_format.dart new file mode 100644 index 00000000..a8881ffc --- /dev/null +++ b/lib/features/stories/view/story_format.dart @@ -0,0 +1,11 @@ +String formatPlayDuration(int milliseconds) { + final totalSeconds = (milliseconds / 1000).round(); + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return "$minutes:${seconds.toString().padLeft(2, '0')}"; +} + +String capitalizeFirstLetter(String input) { + if (input.isEmpty) return input; + return input[0].toUpperCase() + input.substring(1); +} diff --git a/lib/features/stories/view/widgets/category_card.dart b/lib/features/stories/view/widgets/category_card.dart new file mode 100644 index 00000000..c173c2cf --- /dev/null +++ b/lib/features/stories/view/widgets/category_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:resonate/features/stories/view/pages/category_page.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class CategoryCard extends StatelessWidget { + const CategoryCard({super.key, required this.category, required this.color}); + + final StoryCategory category; + final Color color; + + @override + Widget build(BuildContext context) { + return GestureDetector( + // CategoryPage fetches its stories from categoryStoriesProvider on build + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CategoryPage(category: category), + ), + ), + child: Stack( + children: [ + Container( + height: UiSizes.height_70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiSizes.width_5), + color: color, + ), + ), + Positioned( + left: UiSizes.width_16, + top: UiSizes.height_14, + child: Text( + AppLocalizations.of(context)!.storyCategory(category.name), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Colors.white, + fontFamily: 'Inter', + fontWeight: FontWeight.w400, + fontSize: UiSizes.size_17, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/widgets/chapter_list_tile.dart b/lib/features/stories/view/widgets/chapter_list_tile.dart similarity index 52% rename from lib/views/widgets/chapter_list_tile.dart rename to lib/features/stories/view/widgets/chapter_list_tile.dart index 31423590..1e0434a7 100644 --- a/lib/views/widgets/chapter_list_tile.dart +++ b/lib/features/stories/view/widgets/chapter_list_tile.dart @@ -1,33 +1,38 @@ import 'package:flutter/material.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; +import 'package:resonate/utils/ui_sizes.dart'; -class ChaperListTile extends StatelessWidget { - const ChaperListTile({super.key, required this.chapter}); +class ChapterListTile extends StatelessWidget { + const ChapterListTile({super.key, required this.chapter}); final Chapter chapter; @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(UiSizes.width_10), + color: colorScheme.secondary, ), - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - padding: const EdgeInsets.symmetric(horizontal: 16.0), + margin: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + vertical: UiSizes.height_8, + ), + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 5.0, + contentPadding: EdgeInsets.symmetric( + vertical: UiSizes.height_4, + horizontal: UiSizes.width_5, ), leading: ClipRRect( - borderRadius: BorderRadius.circular(10.0), + borderRadius: BorderRadius.circular(UiSizes.width_10), child: Image.network( chapter.coverImageUrl, - width: 50, - height: 50, + width: UiSizes.width_45, + height: UiSizes.width_45, fit: BoxFit.cover, ), ), @@ -36,10 +41,9 @@ class ChaperListTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_17, fontFamily: 'Inter', ), ), @@ -48,8 +52,7 @@ class ChaperListTile extends StatelessWidget { overflow: TextOverflow.ellipsis, maxLines: 2, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_12, fontFamily: 'Inter', ), ), diff --git a/lib/features/stories/view/widgets/chapter_player.dart b/lib/features/stories/view/widgets/chapter_player.dart new file mode 100644 index 00000000..e02411b5 --- /dev/null +++ b/lib/features/stories/view/widgets/chapter_player.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/viewmodel/chapter_player_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class ChapterPlayerView extends ConsumerWidget { + const ChapterPlayerView({ + super.key, + required this.chapter, + required this.progress, + }); + + final Chapter chapter; + final double progress; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playerState = ref.watch(chapterPlayerProvider(chapter.chapterId)); + final notifier = ref.read(chapterPlayerProvider(chapter.chapterId).notifier); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Center( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + chapter.tintColor.withAlpha((progress < 0.75 ? 0.8 : 1) * 255 ~/ 1), + chapter.tintColor.withAlpha((progress < 0.75 ? 0.3 : 1) * 255 ~/ 1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + top: 30 - (progress * 100) < 20 ? 20 : 30 - (progress * 100), + left: progress < 0.45 ? 100 + (progress * 100) : 30, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: progress > 0.65 ? 50 : 200 - (2 * (progress * 100)), + width: progress > 0.65 ? 50 : 200 - (2 * (progress * 100)), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_20), + child: Image.network( + chapter.coverImageUrl, + width: UiSizes.width_200, + height: UiSizes.width_200, + fit: BoxFit.cover, + ), + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + top: progress > 0.65 ? 25 : 250 - (2.5 * (progress * 100)), + left: 100, + right: 100, + child: Text( + chapter.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: UiSizes.size_26, + fontWeight: FontWeight.bold, + color: + isDark || + (ThemeData.estimateBrightnessForColor( + chapter.tintColor, + ) == + Brightness.dark && + progress > 0.75) + ? Colors.white + : Colors.black87, + ), + ), + ), + // Progress bar + time labels + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + top: progress > 0.65 ? 70 : 300 - (3 * (progress * 100)), + left: 0, + right: 0, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_10), + child: Column( + children: [ + Slider( + value: playerState.sliderProgress, + onChanged: notifier.onSliderChanged, + onChangeEnd: notifier.onSliderChangeEnd, + min: 0, + max: notifier.chapterDuration.inMilliseconds.toDouble() + + 1000, + activeColor: Colors.white, + inactiveColor: Colors.grey.shade300, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: progress > 0.70 ? 0 : 1, + child: Text( + "${formatPlayDuration(playerState.sliderProgress.toInt())} ${AppLocalizations.of(context)!.lengthMinutes}", + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: progress > 0.70 ? 0 : 1, + child: Text( + "${formatPlayDuration(chapter.playDuration)} ${AppLocalizations.of(context)!.lengthMinutes}", + ), + ), + ], + ), + ], + ), + ), + ), + // Expanded play button + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + top: 350 - (3.3 * (progress * 100)) < 200 + ? 200 + : 350 - (3.3 * (progress * 100)), + left: 175, + curve: Curves.easeInOut, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: progress > 0.45 ? 0 : 1, + child: _PlayButton( + isPlaying: playerState.isPlaying, + enabled: progress <= 0.45, + onPressed: notifier.togglePlayPause, + ), + ), + ), + // Collapsed play button + Positioned( + top: 20, + left: 320, + child: AnimatedOpacity( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 200), + opacity: progress > 0.45 ? 1 : 0, + child: _PlayButton( + isPlaying: playerState.isPlaying, + enabled: progress > 0.45, + onPressed: notifier.togglePlayPause, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PlayButton extends StatelessWidget { + const _PlayButton({ + required this.isPlaying, + required this.enabled, + required this.onPressed, + }); + + final bool isPlaying; + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + iconSize: UiSizes.size_35, + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + ), + onPressed: enabled ? onPressed : null, + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Theme.of(context).colorScheme.onPrimary, + ), + ); + } +} diff --git a/lib/views/widgets/filtered_list_tile.dart b/lib/features/stories/view/widgets/filtered_list_tile.dart similarity index 68% rename from lib/views/widgets/filtered_list_tile.dart rename to lib/features/stories/view/widgets/filtered_list_tile.dart index b71c63bc..ad319869 100644 --- a/lib/views/widgets/filtered_list_tile.dart +++ b/lib/features/stories/view/widgets/filtered_list_tile.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:resonate/features/profile/view/pages/profile_page.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/models/resonate_user.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/features/profile/view/pages/profile_page.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; -import 'package:resonate/views/screens/story_screen.dart'; +import 'package:resonate/utils/ui_sizes.dart'; class FilteredListTile extends StatelessWidget { final Story? story; @@ -25,61 +26,62 @@ class FilteredListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return GestureDetector( onTap: () { if (isStory) { Navigator.push( context, - MaterialPageRoute(builder: (context) => StoryScreen(story: story!)), + MaterialPageRoute(builder: (_) => StoryPage(story: story!)), ); } else { Navigator.push( context, MaterialPageRoute( - builder: (context) => - ProfilePage(creator: user, isCreatorProfile: true), + builder: (_) => ProfilePage(creator: user, isCreatorProfile: true), ), ); } }, child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(UiSizes.width_10), + color: colorScheme.secondary, + ), + margin: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + vertical: UiSizes.height_8, ), - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), child: ListTile( - contentPadding: const EdgeInsets.all(0), + contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundImage: NetworkImage( isStory ? story!.coverImageUrl : user!.profileImageUrl!, ), - radius: 25, + radius: UiSizes.width_25, ), trailing: isStory ? Text( formatPlayDuration(story!.playDuration), style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 14, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_14, fontFamily: 'Inter', ), ) : Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.star, color: Colors.amber), + const Icon(Icons.star, color: Colors.amber), Text(user!.userRating!.toStringAsFixed(1)), ], ), title: Text( isStory ? story!.title : user!.userName!, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_17, fontFamily: 'Inter', ), ), @@ -88,8 +90,7 @@ class FilteredListTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_12, fontFamily: 'Inter', ), ), diff --git a/lib/views/widgets/like_button.dart b/lib/features/stories/view/widgets/like_button.dart similarity index 76% rename from lib/views/widgets/like_button.dart rename to lib/features/stories/view/widgets/like_button.dart index 68fa4a2e..3222cf3b 100644 --- a/lib/views/widgets/like_button.dart +++ b/lib/features/stories/view/widgets/like_button.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + class LikeButton extends StatefulWidget { final Color tintColor; @@ -33,35 +35,20 @@ class _LikeButtonState extends State vsync: this, ); - // Bounce animation sequence _scaleAnimation = TweenSequence([ - TweenSequenceItem( - tween: Tween(begin: 1.0, end: 1.2), - weight: 40, - ), // Scale up - TweenSequenceItem( - tween: Tween(begin: 1.2, end: 0.9), - weight: 30, - ), // Overshoot - TweenSequenceItem( - tween: Tween(begin: 0.9, end: 1.0), - weight: 30, - ), // Settle back + TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.2), weight: 40), + TweenSequenceItem(tween: Tween(begin: 1.2, end: 0.9), weight: 30), + TweenSequenceItem(tween: Tween(begin: 0.9, end: 1.0), weight: 30), ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); - // Color transition animation _colorAnimation = ColorTween(begin: Colors.grey, end: widget.tintColor) .animate( CurvedAnimation( parent: _controller, - curve: const Interval( - 0.0, - 0.5, - ), // Color change happens in first half + curve: const Interval(0.0, 0.5), ), ); - // Initialize animation state based on initial favorite status if (_isFavorite) { _controller.value = 1.0; } @@ -71,9 +58,7 @@ class _LikeButtonState extends State void didUpdateWidget(LikeButton oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isLikedByUser != oldWidget.isLikedByUser) { - setState(() { - _isFavorite = widget.isLikedByUser; - }); + setState(() => _isFavorite = widget.isLikedByUser); if (_isFavorite) { _controller.forward(); } else { @@ -93,13 +78,11 @@ class _LikeButtonState extends State return GestureDetector( onTap: () async { setState(() => _isFavorite = !_isFavorite); - if (_isFavorite) { await _controller.forward(from: 0.0); } else { await _controller.reverse(); } - widget.onLiked(_isFavorite); }, child: AnimatedBuilder( @@ -109,7 +92,7 @@ class _LikeButtonState extends State scale: _scaleAnimation.value, child: Icon( _isFavorite ? Icons.favorite : Icons.favorite_border, - size: 40, + size: UiSizes.size_40, color: _colorAnimation.value, ), ); diff --git a/lib/features/stories/view/widgets/live_chapter_attendee_block.dart b/lib/features/stories/view/widgets/live_chapter_attendee_block.dart new file mode 100644 index 00000000..33eb7f71 --- /dev/null +++ b/lib/features/stories/view/widgets/live_chapter_attendee_block.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:focused_menu/focused_menu.dart'; +import 'package:focused_menu/modals.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class LiveChapterAttendeeBlock extends ConsumerWidget { + const LiveChapterAttendeeBlock({super.key, required this.user}); + + final Map user; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + final model = ref.watch(liveChapterProvider).model; + final isAuthorBlock = model?.authorUid == user['\$id']; + final viewerIsAdmin = model?.authorUid == currentAuthUser?.uid; + + return FocusedMenuHolder( + onPressed: () {}, + menuItemExtent: UiSizes.width_45, + menuWidth: UiSizes.width_200 * 1.05, + menuBoxDecoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(UiSizes.width_5), + border: Border.all(color: colorScheme.primary, width: UiSizes.width_1), + ), + duration: const Duration(milliseconds: 100), + animateMenuItems: true, + blurBackgroundColor: brightness == Brightness.light + ? Colors.white54 + : Colors.black54, + menuItems: const [], + openWithTap: viewerIsAdmin, + child: Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_2, + horizontal: UiSizes.width_2, + ), + alignment: Alignment.center, + child: Column( + children: [ + CircleAvatar( + radius: UiSizes.size_32, + backgroundColor: colorScheme.primary, + child: CircleAvatar( + backgroundImage: NetworkImage(user['profileImageUrl'] ?? ''), + radius: UiSizes.size_30, + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + (user["name"] ?? '').toString().split(' ').first, + style: TextStyle(fontSize: UiSizes.size_16), + ), + ], + ), + ), + Text( + isAuthorBlock + ? AppLocalizations.of(context)!.author + : AppLocalizations.of(context)!.listener, + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: UiSizes.size_14, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/widgets/live_chapter_header.dart b/lib/features/stories/view/widgets/live_chapter_header.dart similarity index 73% rename from lib/views/widgets/live_chapter_header.dart rename to lib/features/stories/view/widgets/live_chapter_header.dart index 78e8c7ee..4e2c094e 100644 --- a/lib/views/widgets/live_chapter_header.dart +++ b/lib/features/stories/view/widgets/live_chapter_header.dart @@ -12,21 +12,18 @@ class LiveChapterHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - chapterName, - style: TextStyle( - fontSize: UiSizes.size_20, - // color: Colors.black, - ), - ), + Text(chapterName, style: TextStyle(fontSize: UiSizes.size_20)), SizedBox(height: UiSizes.height_8), - Text( chapterDescription, - style: TextStyle(color: Colors.grey, fontSize: UiSizes.size_14), + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: UiSizes.size_14, + ), ), ], ); diff --git a/lib/views/widgets/live_chapter_list_tile.dart b/lib/features/stories/view/widgets/live_chapter_list_tile.dart similarity index 56% rename from lib/views/widgets/live_chapter_list_tile.dart rename to lib/features/stories/view/widgets/live_chapter_list_tile.dart index 286ee37b..9f228614 100644 --- a/lib/views/widgets/live_chapter_list_tile.dart +++ b/lib/features/stories/view/widgets/live_chapter_list_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/live_chapter_model.dart'; +import 'package:resonate/utils/ui_sizes.dart'; class LiveChapterListTile extends StatelessWidget { const LiveChapterListTile({super.key, required this.chapter}); @@ -9,28 +10,31 @@ class LiveChapterListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(UiSizes.width_10), + color: colorScheme.secondary, ), - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - padding: const EdgeInsets.symmetric(horizontal: 16.0), + margin: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + vertical: UiSizes.height_8, + ), + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 5.0, + contentPadding: EdgeInsets.symmetric( + vertical: UiSizes.height_4, + horizontal: UiSizes.width_5, ), - leading: Icon(Icons.play_circle_outline, color: Colors.red), + leading: Icon(Icons.play_circle_outline, color: colorScheme.error), title: Text( chapter.chapterTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_17, fontFamily: 'Inter', ), ), @@ -39,8 +43,7 @@ class LiveChapterListTile extends StatelessWidget { overflow: TextOverflow.ellipsis, maxLines: 2, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_12, fontFamily: 'Inter', ), ), diff --git a/lib/features/stories/view/widgets/start_live_chapter_dialog.dart b/lib/features/stories/view/widgets/start_live_chapter_dialog.dart new file mode 100644 index 00000000..5300c591 --- /dev/null +++ b/lib/features/stories/view/widgets/start_live_chapter_dialog.dart @@ -0,0 +1,134 @@ +import 'dart:developer'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:resonate/utils/enums/log_type.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; + +class StartLiveChapterDialog extends ConsumerStatefulWidget { + const StartLiveChapterDialog({super.key, required this.story}); + + final Story story; + + @override + ConsumerState createState() => + _StartLiveChapterDialogState(); +} + +class _StartLiveChapterDialogState + extends ConsumerState { + final nameController = TextEditingController(); + final descriptionController = TextEditingController(); + bool _isStarting = false; + + @override + void dispose() { + nameController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + + Future _start() async { + final l10n = AppLocalizations.of(context)!; + final chapterName = nameController.text.trim(); + final chapterDescription = descriptionController.text.trim(); + + if (chapterName.isEmpty || chapterDescription.isEmpty) { + customSnackbar(l10n.error, l10n.fillAllFields, LogType.error); + return; + } + + setState(() => _isStarting = true); + final navigator = Navigator.of(context); + final router = GoRouter.of(context); + try { + await ref + .read(liveChapterProvider.notifier) + .startLiveChapter( + roomId: ID.unique(), + chapterTitle: chapterName, + chapterDescription: chapterDescription, + storyId: widget.story.storyId, + storyName: widget.story.title, + ); + } catch (e) { + if (mounted) setState(() => _isStarting = false); + log('startLiveChapter failed: $e'); + final message = e is AppwriteException ? (e.message ?? e.toString()) : e.toString(); + customSnackbar(l10n.error, message, LogType.error); + return; + } + navigator.pop(); + router.push(RoutePaths.liveChapterScreen); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: Card( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: UiSizes.height_5, + children: [ + Text( + l10n.startLiveChapter, + style: TextStyle( + fontSize: UiSizes.size_18, + fontWeight: FontWeight.bold, + ), + ), + TextFormField( + controller: nameController, + maxLines: 1, + maxLength: 20, + decoration: InputDecoration(hintText: l10n.chapterTitle), + ), + TextFormField( + controller: descriptionController, + maxLines: 3, + maxLength: 2000, + decoration: InputDecoration(hintText: l10n.description), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _isStarting + ? null + : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: _isStarting ? null : _start, + child: _isStarting + ? SizedBox( + height: UiSizes.size_18, + width: UiSizes.size_18, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.start), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/widgets/story_card.dart b/lib/features/stories/view/widgets/story_card.dart similarity index 59% rename from lib/views/widgets/story_card.dart rename to lib/features/stories/view/widgets/story_card.dart index 9fd6a1a0..bc960f60 100644 --- a/lib/views/widgets/story_card.dart +++ b/lib/features/stories/view/widgets/story_card.dart @@ -1,42 +1,42 @@ import 'package:flutter/material.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/views/screens/story_screen.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; +import 'package:resonate/utils/ui_sizes.dart'; class StoryCard extends StatelessWidget { const StoryCard({super.key, required this.story}); final Story story; + @override Widget build(BuildContext context) { return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => StoryScreen(story: story)), - ); - }, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => StoryPage(story: story)), + ), child: Stack( children: [ Container( - width: 180, + width: UiSizes.width_180, decoration: BoxDecoration( image: DecorationImage( image: NetworkImage(story.coverImageUrl), fit: BoxFit.cover, ), - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(UiSizes.width_5), ), ), Positioned( - left: 14, - // top: 14, - bottom: 14, + left: UiSizes.width_16, + bottom: UiSizes.height_14, child: Text( '# ${story.title}', + // Overlaid on the cover image style: Theme.of(context).textTheme.bodyLarge!.copyWith( color: Colors.white, fontFamily: 'Inter', fontWeight: FontWeight.w900, - fontSize: 19, + fontSize: UiSizes.size_19, ), ), ), diff --git a/lib/views/widgets/story_list_tile.dart b/lib/features/stories/view/widgets/story_list_tile.dart similarity index 64% rename from lib/views/widgets/story_list_tile.dart rename to lib/features/stories/view/widgets/story_list_tile.dart index cf994b79..affd0cd7 100644 --- a/lib/views/widgets/story_list_tile.dart +++ b/lib/features/stories/view/widgets/story_list_tile.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/story.dart'; import 'package:resonate/utils/colors.dart'; -import 'package:resonate/views/screens/story_screen.dart'; +import 'package:resonate/utils/ui_sizes.dart'; class StoryListTile extends StatelessWidget { const StoryListTile({super.key, required this.story}); @@ -11,17 +12,19 @@ class StoryListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return ListTile( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiSizes.width_10), + ), title: Text( story.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + color: colorScheme.onSurface, fontWeight: FontWeight.w500, - fontSize: 17, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_17, fontFamily: 'Inter', ), ), @@ -33,18 +36,17 @@ class StoryListTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_12, fontFamily: 'Inter', ), ), Container( - margin: const EdgeInsets.only(top: 5), - padding: const EdgeInsets.all(3), + margin: EdgeInsets.only(top: UiSizes.height_5), + padding: EdgeInsets.all(UiSizes.width_3), decoration: BoxDecoration( color: AppColor.categoryColorList[story.category.name.toLowerCase()], - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(UiSizes.width_10), ), child: Text( AppLocalizations.of( @@ -54,8 +56,7 @@ class StoryListTile extends StatelessWidget { textAlign: TextAlign.left, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 12, - fontStyle: FontStyle.normal, + fontSize: UiSizes.size_12, fontFamily: 'Inter', fontWeight: FontWeight.bold, ), @@ -64,27 +65,23 @@ class StoryListTile extends StatelessWidget { ], ), leading: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(UiSizes.width_10), child: Image.network( story.coverImageUrl, fit: BoxFit.cover, - height: 60, - width: 60, + height: UiSizes.width_56, + width: UiSizes.width_56, ), ), trailing: Icon( Icons.play_arrow_rounded, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[400] - : Colors.black38, + color: colorScheme.onSurface.withValues(alpha: 0.45), ), tileColor: Colors.transparent, - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => StoryScreen(story: story)), - ); - }, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => StoryPage(story: story)), + ), ); } } diff --git a/lib/features/stories/viewmodel/category_stories_notifier.dart b/lib/features/stories/viewmodel/category_stories_notifier.dart new file mode 100644 index 00000000..6e2a108a --- /dev/null +++ b/lib/features/stories/viewmodel/category_stories_notifier.dart @@ -0,0 +1,19 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/category_stories_notifier.g.dart'; + +// Stories within a single category, fetched when the category page opens. +@riverpod +class CategoryStories extends _$CategoryStories { + @override + Future> build(StoryCategory category) async { + final uid = requireCurrentAuthUser.uid; + return ref + .watch(storiesRepositoryProvider) + .fetchStoriesByCategory(category, uid); + } +} diff --git a/lib/features/stories/viewmodel/chapter_player_notifier.dart b/lib/features/stories/viewmodel/chapter_player_notifier.dart new file mode 100644 index 00000000..3deb6dbf --- /dev/null +++ b/lib/features/stories/viewmodel/chapter_player_notifier.dart @@ -0,0 +1,69 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_lyric/flutter_lyric.dart'; +import 'package:resonate/features/stories/model/chapter_player_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/chapter_player_notifier.g.dart'; + +@riverpod +class ChapterPlayer extends _$ChapterPlayer { + AudioPlayer? _player; + final LyricController _lyricController = LyricController(); + Duration _chapterDuration = Duration.zero; + + LyricController get lyricController => _lyricController; + Duration get chapterDuration => _chapterDuration; + + @override + ChapterPlayerState build(String chapterId) { + ref.onDispose(() { + _player?.release(); + _lyricController.dispose(); + }); + return const ChapterPlayerState(); + } + + void initialize({ + required String audioUrl, + required String lyrics, + required Duration duration, + }) { + _chapterDuration = duration; + _lyricController.loadLyric(lyrics); + + final player = AudioPlayer()..setReleaseMode(ReleaseMode.stop); + _player = player; + player.setSourceUrl(audioUrl); + + _lyricController.setOnTapLineCallback((start) => player.seek(start)); + + player.onPositionChanged.listen((event) { + if (!ref.mounted) return; + state = state.copyWith(sliderProgress: event.inMilliseconds.toDouble()); + _lyricController.setProgress(event); + }); + player.onPlayerStateChanged.listen((s) { + if (!ref.mounted) return; + state = state.copyWith(isPlaying: s == PlayerState.playing); + }); + } + + void togglePlayPause() { + if (state.isPlaying) { + _player?.pause(); + } else { + _player?.resume(); + } + } + + void seek(Duration position) => _player?.seek(position); + + void onSliderChanged(double value) => + state = state.copyWith(sliderProgress: value); + + void onSliderChangeEnd(double value) { + final position = Duration(milliseconds: value.toInt()); + _lyricController.setProgress(position); + _player?.seek(position); + } +} diff --git a/lib/features/stories/viewmodel/create_story_notifier.dart b/lib/features/stories/viewmodel/create_story_notifier.dart new file mode 100644 index 00000000..5cc02d26 --- /dev/null +++ b/lib/features/stories/viewmodel/create_story_notifier.dart @@ -0,0 +1,60 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/utils/enums/story_category.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/create_story_notifier.g.dart'; + +@Riverpod(keepAlive: true) // kept alive as was dying mid upload +class CreateStory extends _$CreateStory { + @override + void build() {} + + Future buildChapter({ + required String title, + required String description, + required String coverImgPath, + required String audioFilePath, + required String lyricsFilePath, + }) => ref.read(storiesRepositoryProvider).buildChapterFromFiles( + title: title, + description: description, + coverImgPath: coverImgPath, + audioFilePath: audioFilePath, + lyricsFilePath: lyricsFilePath, + ); + + Future createStory({ + required String title, + required String description, + required StoryCategory category, + required String coverImgRef, + required int storyPlayDuration, + required List chapters, + }) async { + await ref + .read(storiesRepositoryProvider) + .createStory( + user: requireCurrentAuthUser, + title: title, + description: description, + category: category, + coverImgRef: coverImgRef, + storyPlayDuration: storyPlayDuration, + chapters: chapters, + ); + ref.invalidate(exploreStoriesProvider); + } + + Future addChaptersToStory( + List chapters, + String storyId, + ) async { + await ref + .read(storiesRepositoryProvider) + .addChaptersToStory(chapters, storyId); + ref.invalidate(exploreStoriesProvider); + } +} diff --git a/lib/features/stories/viewmodel/explore_stories_notifier.dart b/lib/features/stories/viewmodel/explore_stories_notifier.dart new file mode 100644 index 00000000..cd21107b --- /dev/null +++ b/lib/features/stories/viewmodel/explore_stories_notifier.dart @@ -0,0 +1,23 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/explore_stories_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class ExploreStories extends _$ExploreStories { + @override + Future> build() async { + final uid = requireCurrentAuthUser.uid; + return ref.watch(storiesRepositoryProvider).fetchRecommendedStories(uid); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() { + final uid = requireCurrentAuthUser.uid; + return ref.read(storiesRepositoryProvider).fetchRecommendedStories(uid); + }); + } +} diff --git a/lib/features/stories/viewmodel/generated/category_stories_notifier.g.dart b/lib/features/stories/viewmodel/generated/category_stories_notifier.g.dart new file mode 100644 index 00000000..cca185f0 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/category_stories_notifier.g.dart @@ -0,0 +1,99 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../category_stories_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CategoryStories) +final categoryStoriesProvider = CategoryStoriesFamily._(); + +final class CategoryStoriesProvider + extends $AsyncNotifierProvider> { + CategoryStoriesProvider._({ + required CategoryStoriesFamily super.from, + required StoryCategory super.argument, + }) : super( + retry: null, + name: r'categoryStoriesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$categoryStoriesHash(); + + @override + String toString() { + return r'categoryStoriesProvider' + '' + '($argument)'; + } + + @$internal + @override + CategoryStories create() => CategoryStories(); + + @override + bool operator ==(Object other) { + return other is CategoryStoriesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$categoryStoriesHash() => r'cffdfc6bc8af5d45313415334e25519e48b8aac1'; + +final class CategoryStoriesFamily extends $Family + with + $ClassFamilyOverride< + CategoryStories, + AsyncValue>, + List, + FutureOr>, + StoryCategory + > { + CategoryStoriesFamily._() + : super( + retry: null, + name: r'categoryStoriesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + CategoryStoriesProvider call(StoryCategory category) => + CategoryStoriesProvider._(argument: category, from: this); + + @override + String toString() => r'categoryStoriesProvider'; +} + +abstract class _$CategoryStories extends $AsyncNotifier> { + late final _$args = ref.$arg as StoryCategory; + StoryCategory get category => _$args; + + FutureOr> build(StoryCategory category); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/lib/features/stories/viewmodel/generated/chapter_player_notifier.g.dart b/lib/features/stories/viewmodel/generated/chapter_player_notifier.g.dart new file mode 100644 index 00000000..85fb7185 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/chapter_player_notifier.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../chapter_player_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ChapterPlayer) +final chapterPlayerProvider = ChapterPlayerFamily._(); + +final class ChapterPlayerProvider + extends $NotifierProvider { + ChapterPlayerProvider._({ + required ChapterPlayerFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'chapterPlayerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$chapterPlayerHash(); + + @override + String toString() { + return r'chapterPlayerProvider' + '' + '($argument)'; + } + + @$internal + @override + ChapterPlayer create() => ChapterPlayer(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ChapterPlayerState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ChapterPlayerProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$chapterPlayerHash() => r'008282749b0b5c29c6ef79c69caace9603946db0'; + +final class ChapterPlayerFamily extends $Family + with + $ClassFamilyOverride< + ChapterPlayer, + ChapterPlayerState, + ChapterPlayerState, + ChapterPlayerState, + String + > { + ChapterPlayerFamily._() + : super( + retry: null, + name: r'chapterPlayerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ChapterPlayerProvider call(String chapterId) => + ChapterPlayerProvider._(argument: chapterId, from: this); + + @override + String toString() => r'chapterPlayerProvider'; +} + +abstract class _$ChapterPlayer extends $Notifier { + late final _$args = ref.$arg as String; + String get chapterId => _$args; + + ChapterPlayerState build(String chapterId); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ChapterPlayerState, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart b/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart new file mode 100644 index 00000000..ce8e3744 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../create_story_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CreateStory) +final createStoryProvider = CreateStoryProvider._(); + +final class CreateStoryProvider extends $NotifierProvider { + CreateStoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'createStoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$createStoryHash(); + + @$internal + @override + CreateStory create() => CreateStory(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$createStoryHash() => r'75e19e0e0eb00672e88d369844517c267b6a752c'; + +abstract class _$CreateStory extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/stories/viewmodel/generated/explore_stories_notifier.g.dart b/lib/features/stories/viewmodel/generated/explore_stories_notifier.g.dart new file mode 100644 index 00000000..6391f6cf --- /dev/null +++ b/lib/features/stories/viewmodel/generated/explore_stories_notifier.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../explore_stories_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ExploreStories) +final exploreStoriesProvider = ExploreStoriesProvider._(); + +final class ExploreStoriesProvider + extends $AsyncNotifierProvider> { + ExploreStoriesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'exploreStoriesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$exploreStoriesHash(); + + @$internal + @override + ExploreStories create() => ExploreStories(); +} + +String _$exploreStoriesHash() => r'6548bf8fa54beba32389992b8b13bca6de6abf7e'; + +abstract class _$ExploreStories extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart new file mode 100644 index 00000000..d5893a11 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../live_chapter_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(LiveChapter) +final liveChapterProvider = LiveChapterProvider._(); + +final class LiveChapterProvider + extends $NotifierProvider { + LiveChapterProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'liveChapterProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$liveChapterHash(); + + @$internal + @override + LiveChapter create() => LiveChapter(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LiveChapterState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$liveChapterHash() => r'90a5bcf0bf35da52a7408a62dc26f4ffcf89fcd3'; + +abstract class _$LiveChapter extends $Notifier { + LiveChapterState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LiveChapterState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart b/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart new file mode 100644 index 00000000..f6cdf7c1 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../story_detail_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(StoryDetail) +final storyDetailProvider = StoryDetailFamily._(); + +final class StoryDetailProvider + extends $AsyncNotifierProvider { + StoryDetailProvider._({ + required StoryDetailFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'storyDetailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$storyDetailHash(); + + @override + String toString() { + return r'storyDetailProvider' + '' + '($argument)'; + } + + @$internal + @override + StoryDetail create() => StoryDetail(); + + @override + bool operator ==(Object other) { + return other is StoryDetailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$storyDetailHash() => r'cc34f136856e95bf25bcc782aa332c6737f80025'; + +final class StoryDetailFamily extends $Family + with + $ClassFamilyOverride< + StoryDetail, + AsyncValue, + StoryDetailState, + FutureOr, + String + > { + StoryDetailFamily._() + : super( + retry: null, + name: r'storyDetailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + StoryDetailProvider call(String storyId) => + StoryDetailProvider._(argument: storyId, from: this); + + @override + String toString() => r'storyDetailProvider'; +} + +abstract class _$StoryDetail extends $AsyncNotifier { + late final _$args = ref.$arg as String; + String get storyId => _$args; + + FutureOr build(String storyId); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, StoryDetailState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, StoryDetailState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/lib/features/stories/viewmodel/generated/story_search_notifier.g.dart b/lib/features/stories/viewmodel/generated/story_search_notifier.g.dart new file mode 100644 index 00000000..ca5bb189 --- /dev/null +++ b/lib/features/stories/viewmodel/generated/story_search_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of '../story_search_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(StorySearch) +final storySearchProvider = StorySearchProvider._(); + +final class StorySearchProvider + extends $NotifierProvider { + StorySearchProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'storySearchProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$storySearchHash(); + + @$internal + @override + StorySearch create() => StorySearch(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(StorySearchState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$storySearchHash() => r'f1343467233ed06ee17ec8753087e76678e3f3f5'; + +abstract class _$StorySearch extends $Notifier { + StorySearchState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + StorySearchState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/stories/viewmodel/live_chapter_notifier.dart b/lib/features/stories/viewmodel/live_chapter_notifier.dart new file mode 100644 index 00000000..102fc90c --- /dev/null +++ b/lib/features/stories/viewmodel/live_chapter_notifier.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/features/stories/data/repositories/live_chapter_repository.dart'; +import 'package:resonate/features/stories/data/services/whisper_transcription_service.dart'; +import 'package:resonate/features/stories/model/live_chapter_attendees_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_state.dart'; +import 'package:resonate/features/stories/viewmodel/whisper_model_notifier.dart'; +import 'package:resonate/routes/app_router.dart'; +import 'package:resonate/routes/route_paths.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/live_chapter_notifier.g.dart'; + + +@Riverpod(keepAlive: true) +class LiveChapter extends _$LiveChapter { + StreamSubscription? _attendeesSub; + + @override + LiveChapterState build() { + ref.onDispose(() => _attendeesSub?.cancel()); + return const LiveChapterState(); + } + + bool get isAdmin { + final model = state.model; + if (model == null) return false; + return model.authorUid == requireCurrentAuthUser.uid; + } + + bool checkUserIsAdmin(String uid) => state.model?.authorUid == uid; + + Future startLiveChapter({ + required String roomId, + required String chapterTitle, + required String chapterDescription, + required String storyId, + required String storyName, + }) async { + final user = requireCurrentAuthUser; + final model = LiveChapterModel( + livekitRoomId: roomId, + authorUid: user.uid, + authorProfileImageUrl: user.profileImageUrl ?? '', + authorName: user.displayName, + chapterTitle: chapterTitle, + chapterDescription: chapterDescription, + storyId: storyId, + followersFCMToken: user.followers.map((e) => e.fcmToken).toList(), + attendees: LiveChapterAttendeesModel( + liveChapterId: roomId, + users: const [], + userIds: const [], + ), + id: roomId, + ); + + final repo = ref.read(liveChapterRepositoryProvider); + await repo.createLiveChapterDocs(model); + final join = await repo.createLiveChapterRoom( + appwriteRoomId: roomId, + adminUid: user.uid, + ); + final connected = await ref + .read(liveKitProvider.notifier) + .connect( + liveKitUri: join.liveKitUri, + roomToken: join.roomToken, + isLiveChapter: true, + ); + if (!connected) { + await repo.deleteLiveChapterDocs(roomId); + await repo.deleteLiveChapterRoom(roomId); + throw StateError('Unable to connect to the live chapter room'); + } + + state = state.copyWith(model: model); + + if (user.followers.isNotEmpty) { + await repo.sendLiveChapterNotification( + creatorId: user.uid, + title: 'Live Chapter Starting!', + body: + "${user.displayName} is starting a Live Chapter in $storyName: $chapterTitle. Tune In!", + ); + } + + _listenForAttendees(roomId); + } + + Future joinLiveChapter(String roomId, LiveChapterModel data) async { + final user = requireCurrentAuthUser; + final attendees = data.attendees!; + final newAttendees = attendees.copyWith( + userIds: [ + ...attendees.users.map((e) => e["\$id"] as String), + user.uid, + ], + users: [ + ...attendees.users, + { + "\$id": user.uid, + "name": user.displayName, + "profileImageUrl": user.profileImageUrl, + }, + ], + ); + + final repo = ref.read(liveChapterRepositoryProvider); + await repo.updateAttendees(roomId, newAttendees); + state = state.copyWith(model: data.copyWith(attendees: newAttendees)); + + final join = await repo.joinLiveChapterRoom(roomId: roomId, userId: user.uid); + final connected = await ref + .read(liveKitProvider.notifier) + .connect( + liveKitUri: join.liveKitUri, + roomToken: join.roomToken, + isLiveChapter: true, + ); + if (!connected) { + throw StateError('Unable to connect to the live chapter room'); + } + + _listenForAttendees(roomId); + } + + void _listenForAttendees(String roomId) { + final repo = ref.read(liveChapterRepositoryProvider); + _attendeesSub?.cancel(); + _attendeesSub = repo.attendeesStream(roomId).listen((event) async { + final eventName = event.events.first; + if (eventName.endsWith('.update')) { + final model = state.model; + if (model == null || !ref.mounted) return; + final newAttendees = LiveChapterAttendeesModel.fromJson(event.payload); + state = state.copyWith(model: model.copyWith(attendees: newAttendees)); + } else if (eventName.endsWith('.delete')) { + if (!isAdmin) { + await _attendeesSub?.cancel(); + await ref.read(liveKitProvider.notifier).disconnect(); + appRouter.go(RoutePaths.tabview); + if (ref.mounted) state = const LiveChapterState(); + } + } + }); + } + + Future turnOnMic() async { + await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(true); + state = state.copyWith(isMicOn: true); + } + + Future turnOffMic() async { + await ref.read(liveKitProvider.notifier).setMicrophoneEnabled(false); + state = state.copyWith(isMicOn: false); + } + + Future setRecording(bool recording) => + ref.read(liveKitProvider.notifier).setRecording(recording); + + Future leaveRoom() async { + final model = state.model; + if (model == null) return; + final user = requireCurrentAuthUser; + await _attendeesSub?.cancel(); + + final attendees = model.attendees!; + final updated = attendees.copyWith( + users: attendees.users + .where((element) => element["\$id"] != user.uid) + .toList(), + userIds: (attendees.userIds ?? []) + .where((element) => element != user.uid) + .toList(), + ); + await ref.read(liveChapterRepositoryProvider).updateAttendees( + model.id, + updated, + ); + await ref.read(liveKitProvider.notifier).disconnect(); + state = const LiveChapterState(); + appRouter.go(RoutePaths.tabview); + } + + // Ends the chapter + Future endLiveChapter() async { + final model = state.model; + if (model == null) return ''; + final repo = ref.read(liveChapterRepositoryProvider); + + await ref.read(liveKitProvider.notifier).setRecording(false); + await repo.deleteLiveChapterDocs(model.id); + + final whisperModel = await ref.read(whisperModelSettingProvider.future); + final lyrics = await WhisperTranscriptionService( + model: whisperModel, + ).transcribeChapter(model.livekitRoomId); + + await repo.deleteLiveChapterRoom(model.livekitRoomId); + await _attendeesSub?.cancel(); + await ref.read(liveKitProvider.notifier).disconnect(); + return lyrics; + } + + void reset() { + _attendeesSub?.cancel(); + _attendeesSub = null; + state = const LiveChapterState(); + } +} diff --git a/lib/features/stories/viewmodel/story_detail_notifier.dart b/lib/features/stories/viewmodel/story_detail_notifier.dart new file mode 100644 index 00000000..d250fe94 --- /dev/null +++ b/lib/features/stories/viewmodel/story_detail_notifier.dart @@ -0,0 +1,56 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/story_detail_notifier.g.dart'; + +// The reactive slice of a single story +@riverpod +class StoryDetail extends _$StoryDetail { + @override + Future build(String storyId) async { + final uid = requireCurrentAuthUser.uid; + return ref.watch(storiesRepositoryProvider).loadStoryDetail(storyId, uid); + } + + Future toggleLike(Story story) async { + final current = state.value; + if (current == null) return; + final uid = requireCurrentAuthUser.uid; + final repo = ref.read(storiesRepositoryProvider); + final liveStory = story.copyWith(likesCount: current.likesCount); + + final willLike = !current.isLikedByCurrentUser; + state = AsyncData( + current.copyWith( + isLikedByCurrentUser: willLike, + likesCount: current.likesCount + (willLike ? 1 : -1), + ), + ); + + try { + if (willLike) { + await repo.likeStory(liveStory, uid); + } else { + await repo.unlikeStory(liveStory, uid); + } + final likes = await repo.fetchLikesCount(story.storyId); + final liked = await repo.checkIfStoryLikedByUser(story.storyId, uid); + if (ref.mounted) { + state = AsyncData( + current.copyWith(likesCount: likes, isLikedByCurrentUser: liked), + ); + } + } catch (_) { + if (ref.mounted) state = AsyncData(current); + } + } + + Future deleteStory(Story story) async { + await ref.read(storiesRepositoryProvider).deleteStory(story); + ref.invalidate(exploreStoriesProvider); + } +} diff --git a/lib/features/stories/viewmodel/story_search_notifier.dart b/lib/features/stories/viewmodel/story_search_notifier.dart new file mode 100644 index 00000000..a352ce3e --- /dev/null +++ b/lib/features/stories/viewmodel/story_search_notifier.dart @@ -0,0 +1,23 @@ +import 'package:resonate/core/container.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/story_search_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'generated/story_search_notifier.g.dart'; + +// Holds the explore search results stories + users +@riverpod +class StorySearch extends _$StorySearch { + @override + StorySearchState build() => const StorySearchState(); + + Future search(String query) async { + final uid = requireCurrentAuthUser.uid; + final results = await ref.read(storiesRepositoryProvider).search(query, uid); + if (ref.mounted) state = results; + } + + void clear() { + if (ref.mounted) state = const StorySearchState(); + } +} diff --git a/lib/features/stories/viewmodel/whisper_model_notifier.dart b/lib/features/stories/viewmodel/whisper_model_notifier.dart new file mode 100644 index 00000000..3caa6b58 --- /dev/null +++ b/lib/features/stories/viewmodel/whisper_model_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:whisper_flutter_new/whisper_flutter_new.dart'; + +const _whisperModelStorageKey = 'whisperModel'; + +WhisperModel _whisperModelFor(String? name) => WhisperModel.values.firstWhere( + (m) => m.modelName == (name ?? 'base'), + orElse: () => WhisperModel.base, +); + +final whisperModelSettingProvider = + AsyncNotifierProvider( + WhisperModelSetting.new, + ); + +class WhisperModelSetting extends AsyncNotifier { + @override + Future build() async { + final saved = await const FlutterSecureStorage().read( + key: _whisperModelStorageKey, + ); + return _whisperModelFor(saved); + } + + Future setModel(WhisperModel model) async { + await const FlutterSecureStorage().write( + key: _whisperModelStorageKey, + value: model.modelName, + ); + state = AsyncData(model); + } +} diff --git a/lib/main.dart b/lib/main.dart index f840a97f..92e34b1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,6 @@ import 'package:resonate/themes/theme_controller.dart'; import 'package:resonate/themes/theme_list.dart'; import 'package:resonate/utils/constants.dart'; import 'package:resonate/utils/ui_sizes.dart'; -import 'package:whisper_flutter_new/whisper_flutter_new.dart'; @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { @@ -64,13 +63,6 @@ Future main() async { setupLegacyGetXDependencies(); languageLocale = await FlutterSecureStorage().read(key: "languageLocale") ?? "en"; - final String? savedModel = await FlutterSecureStorage().read( - key: "whisperModel", - ); - currentWhisperModel.value = WhisperModel.values.firstWhere( - (model) => model.modelName == (savedModel ?? "base"), - orElse: () => WhisperModel.base, - ); runApp( UncontrolledProviderScope( container: rootContainer, diff --git a/lib/models/story.dart b/lib/models/story.dart deleted file mode 100644 index 1b2e20a1..00000000 --- a/lib/models/story.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:ui'; - -import 'package:get/get_rx/src/rx_types/rx_types.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/models/live_chapter_model.dart'; -import 'package:resonate/utils/enums/story_category.dart'; - -class Story { - final String title; - bool userIsCreator; - final String storyId; - String description; - StoryCategory category; - String coverImageUrl; - String creatorId; - String creatorName; - String creatorImgUrl; - final DateTime creationDate; - RxInt likesCount; // Changed to RxInt - RxBool isLikedByCurrentUser; // Changed to RxBool - int playDuration; - Color tintColor; - List chapters; - LiveChapterModel? liveChapter; - - Story({ - required this.title, - required this.storyId, - required this.description, - required this.userIsCreator, - required this.category, - required this.coverImageUrl, - required this.creatorId, - required this.creatorName, - required this.creatorImgUrl, - required this.creationDate, - required int likesCount, // Normal int for constructor - required bool isLikedByCurrentUser, // Normal bool for constructor - required this.playDuration, - required this.tintColor, - required this.chapters, - this.liveChapter, - }) : likesCount = likesCount.obs, // Observable - isLikedByCurrentUser = isLikedByCurrentUser.obs; // Observable -} diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index f9fe9f47..20dbb60b 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -9,17 +9,14 @@ import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/features/friends/friends_routes.dart'; import 'package:resonate/features/profile/profile_routes.dart'; import 'package:resonate/features/rooms/rooms_routes.dart'; +import 'package:resonate/features/stories/stories_routes.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/themes/theme_screen.dart'; import 'package:resonate/views/screens/about_app_screen.dart'; import 'package:resonate/views/screens/app_preferences_screen.dart'; import 'package:resonate/views/screens/contribute_screen.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; -import 'package:resonate/views/screens/explore_screen.dart'; import 'package:resonate/views/screens/home_screen.dart'; -import 'package:resonate/views/screens/live_chapter_screen.dart'; import 'package:resonate/views/screens/notifications_screen.dart'; -import 'package:resonate/views/screens/verify_chapter_details_screen.dart'; import 'package:resonate/views/screens/settings_screen.dart'; import 'package:resonate/views/screens/tabview_screen.dart'; import 'package:resonate/views/screens/user_account_screen.dart'; @@ -86,25 +83,8 @@ final routerProvider = Provider((ref) { // Pair chat / friend calls ...friendsRoutes, - // Stories - GoRoute( - path: RoutePaths.exploreScreen, - builder: (_, _) => const ExploreScreen(), - ), - GoRoute( - path: RoutePaths.createStoryScreen, - builder: (_, _) => const CreateStoryPage(), - ), - GoRoute( - path: RoutePaths.liveChapterScreen, - builder: (_, _) => LiveChapterScreen(), - ), - GoRoute( - path: RoutePaths.verifyChapterDetails, - builder: (_, state) => VerifyChapterDetailsScreen( - lyricsString: state.extra as String? ?? '', - ), - ), + // Stories / live chapters + ...storiesRoutes, ], ); }); diff --git a/lib/services/room_service.dart b/lib/services/room_service.dart deleted file mode 100644 index af0912cc..00000000 --- a/lib/services/room_service.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/services/api_service.dart'; -import 'package:resonate/utils/constants.dart'; - -// Legacy GetX-era helper still used by features that haven't been migrated - -class RoomService { - static ApiService apiService = ApiService(); - - static Future joinLiveKitRoom( - String livekitUri, - String roomToken, { - bool isLiveChapter = false, - }) async { - Get.put( - LiveKitController( - liveKitUri: livekitUri, - roomToken: roomToken, - isLiveChapter: isLiveChapter, - ), - permanent: true, - ); - } - - static Future> createLiveChapterRoom({ - required String appwriteRoomId, - required String adminUid, - }) async { - var response = await apiService.createLiveChapterRoom( - appwriteRoomId, - adminUid, - ); - String appwriteRoomDocId = response["livekit_room"]["name"]; - String livekitToken = response["access_token"]; - String livekitSocketUrl = - response["livekit_socket_url"] == "wss://host.docker.internal:7880" - ? localhostLivekitEndpoint - : response["livekit_socket_url"]; - - const storage = FlutterSecureStorage(); - await storage.write(key: "createdRoomAdminToken", value: livekitToken); - await storage.write(key: "createdRoomLivekitUrl", value: livekitSocketUrl); - - await joinLiveKitRoom(livekitSocketUrl, livekitToken, isLiveChapter: true); - - return [appwriteRoomDocId]; - } - - static Future joinLiveChapterRoom({ - required roomId, - required String userId, - }) async { - var response = await apiService.joinRoom(roomId, userId); - String livekitToken = response["access_token"]; - String livekitSocketUrl = - response["livekit_socket_url"] == "wss://host.docker.internal:7880" - ? localhostLivekitEndpoint - : response["livekit_socket_url"]; - - await joinLiveKitRoom(livekitSocketUrl, livekitToken, isLiveChapter: true); - } - - static Future deleteLiveChapterRoom({required roomId}) async { - const storage = FlutterSecureStorage(); - String? livekitToken = await storage.read(key: "createdRoomAdminToken"); - await apiService.deleteLiveChapterRoom(roomId, livekitToken!); - } -} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index b665168e..833edd7a 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -2,9 +2,6 @@ // Appwrite Project Constants -import 'package:get/get.dart'; -import 'package:whisper_flutter_new/whisper_flutter_new.dart'; - const String baseDomain = String.fromEnvironment( 'APPWRITE_BASE_DOMAIN', defaultValue: '10.12.78.30', @@ -107,4 +104,3 @@ const String playStoreUrl = const String userInvalidCredentials = 'user_invalid_credentials'; const String generalArgumentInvalid = 'general_argument_invalid'; String languageLocale = "en"; -final Rx currentWhisperModel = WhisperModel.base.obs; diff --git a/lib/utils/enums/audio_format.dart b/lib/utils/enums/audio_format.dart new file mode 100644 index 00000000..d2e65496 --- /dev/null +++ b/lib/utils/enums/audio_format.dart @@ -0,0 +1,12 @@ +enum AudioFormat { + wav, + aiff, + alac, + flac, + mp3, + aac, + wma, + ogg; + + static List get extensions => values.map((f) => f.name).toList(); +} diff --git a/lib/utils/enums/lyrics_format.dart b/lib/utils/enums/lyrics_format.dart new file mode 100644 index 00000000..878f161d --- /dev/null +++ b/lib/utils/enums/lyrics_format.dart @@ -0,0 +1,6 @@ +enum LyricsFormat { + lrc, + txt; + + static List get extensions => values.map((f) => f.name).toList(); +} diff --git a/lib/views/screens/add_chapter_screen.dart b/lib/views/screens/add_chapter_screen.dart deleted file mode 100644 index 7d6c9f54..00000000 --- a/lib/views/screens/add_chapter_screen.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/views/screens/create_chapter_screen.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; - -class AddNewChapterScreen extends StatefulWidget { - final String storyName; - final String storyId; - final List currentChapters; - - const AddNewChapterScreen({ - super.key, - required this.storyName, - required this.currentChapters, - required this.storyId, - }); - - @override - AddNewChapterScreenState createState() => AddNewChapterScreenState(); -} - -class AddNewChapterScreenState extends State { - List newChapters = []; - - void _onChapterCreated(Chapter chapter) { - setState(() { - newChapters.add(chapter); - }); - } - - final exploreStoryController = Get.put( - ExploreStoryController(), - ); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - title: Text( - AppLocalizations.of(context)!.addNewChaptersToStory(widget.storyName), - ), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.currentChapters, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - Expanded( - child: ListView.builder( - itemCount: widget.currentChapters.length, - itemBuilder: (context, index) { - final chapter = widget.currentChapters[index]; - return Card( - child: ListTile( - leading: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: Image.network( - chapter.coverImageUrl, - height: 60, - width: 60, - fit: BoxFit.cover, - ), - ), - title: Text(chapter.title), - subtitle: Text( - chapter.description.length > 30 - ? '${chapter.description.substring(0, 30)}...' - : chapter.description, - ), - trailing: Text(formatPlayDuration(chapter.playDuration)), - ), - ); - }, - ), - ), - const SizedBox(height: 10), - Text( - AppLocalizations.of(context)!.newChapters, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - Expanded( - child: ListView.builder( - itemCount: newChapters.length, - itemBuilder: (context, index) { - final chapter = newChapters[index]; - return Card( - child: ListTile( - leading: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: chapter.coverImageUrl.startsWith('/') - ? Image.file( - File(chapter.coverImageUrl), - width: 60, - height: 60, - fit: BoxFit.cover, - ) - : Image.network( - chapter.coverImageUrl, - width: 60, - height: 60, - fit: BoxFit.cover, - ), - ), - title: Text(chapter.title), - subtitle: Text( - chapter.description.length > 30 - ? '${chapter.description.substring(0, 30)}...' - : chapter.description, - ), - trailing: Text(formatPlayDuration(chapter.playDuration)), - ), - ); - }, - ), - ), - ], - ), - ), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: Padding( - padding: const EdgeInsets.symmetric(vertical: 18), - child: SizedBox( - height: 130, - child: Column( - children: [ - FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CreateChapterScreen( - onChapterCreated: _onChapterCreated, - ), - ), - ); - }, - child: const Icon(Icons.add), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () async { - if (newChapters.isNotEmpty) { - await exploreStoryController.pushChaptersToStory( - newChapters, - widget.storyId, - ); - await exploreStoryController - .updateStoriesPlayDurationLength([ - ...widget.currentChapters, - ...newChapters, - ], widget.storyId); - await exploreStoryController.fetchUserCreatedStories(); - Navigator.pop(Get.context!); - Navigator.pop(Get.context!); - } - }, - child: Text(AppLocalizations.of(context)!.newChapters), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/app_preferences_screen.dart b/lib/views/screens/app_preferences_screen.dart index 1d211db6..05d565db 100644 --- a/lib/views/screens/app_preferences_screen.dart +++ b/lib/views/screens/app_preferences_screen.dart @@ -1,21 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get/get.dart'; import 'package:language_picker/language_picker_dropdown.dart'; import 'package:language_picker/languages.dart'; +import 'package:resonate/features/stories/viewmodel/whisper_model_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/constants.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:whisper_flutter_new/whisper_flutter_new.dart'; -class AppPreferencesScreen extends StatefulWidget { +class AppPreferencesScreen extends ConsumerStatefulWidget { const AppPreferencesScreen({super.key}); @override - State createState() => _AppPreferencesScreenState(); + ConsumerState createState() => + _AppPreferencesScreenState(); } -class _AppPreferencesScreenState extends State { +class _AppPreferencesScreenState extends ConsumerState { List> _getWhisperModels(BuildContext context) { return [ { @@ -179,6 +181,9 @@ class _AppPreferencesScreenState extends State { Builder( builder: (context) { final whisperModels = _getWhisperModels(context); + final selectedModel = ref + .watch(whisperModelSettingProvider) + .value; return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -188,15 +193,12 @@ class _AppPreferencesScreenState extends State { ), itemCount: whisperModels.length, itemBuilder: (context, index) { - return Obx(() { - final modelData = whisperModels[index]; - final bool isSelected = - currentWhisperModel.value == modelData['model']; + final modelData = whisperModels[index]; + final bool isSelected = selectedModel == modelData['model']; + return Container( + margin: EdgeInsets.only(bottom: UiSizes.height_12), - return Container( - margin: EdgeInsets.only(bottom: UiSizes.height_12), - - child: ListTile( + child: ListTile( contentPadding: EdgeInsets.symmetric( vertical: UiSizes.height_8, horizontal: UiSizes.width_16, @@ -209,14 +211,9 @@ class _AppPreferencesScreenState extends State { : Theme.of( context, ).colorScheme.surfaceContainerHighest, - onTap: () async { - currentWhisperModel.value = modelData['model']; - - await FlutterSecureStorage().write( - key: "whisperModel", - value: modelData['model'].modelName, - ); - }, + onTap: () => ref + .read(whisperModelSettingProvider.notifier) + .setModel(modelData['model'] as WhisperModel), leading: Container( padding: EdgeInsets.all(UiSizes.width_10), decoration: BoxDecoration( @@ -276,7 +273,6 @@ class _AppPreferencesScreenState extends State { ), ), ); - }); }, ); }, diff --git a/lib/views/screens/category_screen.dart b/lib/views/screens/category_screen.dart deleted file mode 100644 index 8a4b652f..00000000 --- a/lib/views/screens/category_screen.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/utils/app_images.dart'; -import 'package:resonate/views/widgets/category_card.dart'; -import 'package:resonate/views/widgets/story_list_tile.dart'; - -class CategoryScreen extends StatelessWidget { - CategoryScreen({super.key, required this.categoryName}); - final String categoryName; - - final exploreStoryController = Get.put( - ExploreStoryController(), - ); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - backgroundColor: Theme.of(context).colorScheme.surface, - elevation: 0, - title: Text(capitalizeFirstLetter(categoryName)), - ), - body: Obx( - () => exploreStoryController.isLoadingCategoryPage.value - ? Center( - child: SizedBox( - height: 200, - width: 200, - child: LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ), - ), - ) - : exploreStoryController.openedCategotyStories.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(top: 20.0), - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.vertical, - padding: EdgeInsets.zero, - shrinkWrap: true, - primary: true, - itemCount: - exploreStoryController.openedCategotyStories.length, - itemBuilder: (context, index) { - final int storyIndex = index; - return StoryListTile( - story: exploreStoryController - .openedCategotyStories[storyIndex], - ); - }, - ), - ) - : Padding( - padding: const EdgeInsets.only(bottom: 150.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - height: 200, - width: 200, - AppImages.emptyBoxImage, - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 30.0), - child: Text( - AppLocalizations.of(context)!.noStoriesInCategory( - capitalizeFirstLetter(categoryName), - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/create_chapter_screen.dart b/lib/views/screens/create_chapter_screen.dart deleted file mode 100644 index defa4c1f..00000000 --- a/lib/views/screens/create_chapter_screen.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'dart:io'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class CreateChapterScreen extends StatefulWidget { - final Function(Chapter) onChapterCreated; - - const CreateChapterScreen({super.key, required this.onChapterCreated}); - - @override - CreateChapterScreenState createState() => CreateChapterScreenState(); -} - -class CreateChapterScreenState extends State { - final TextEditingController titleController = TextEditingController(); - final TextEditingController aboutController = TextEditingController(); - File? chapterCoverImage; - File? audioFile; // For audio file - File? lyricsFile; // For lyrics text file - ExploreStoryController exploreStoryController = - Get.find(); - - Future pickChapterCoverImage() async { - final ImagePicker picker = ImagePicker(); - final XFile? selectedImage = await picker.pickImage( - source: ImageSource.gallery, - ); - - if (selectedImage != null) { - setState(() { - chapterCoverImage = File(selectedImage.path); - }); - } - } - - Future pickAudioFile() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: [ - 'wav', - 'aiff', - 'alac', - 'flac', - 'mp3', - 'aac', - 'wma', - 'ogg', - ], - ); - - if (result != null) { - setState(() { - audioFile = File(result.files.single.path!); - }); - } - } - - Future pickLyricsFile() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['txt'], - ); - - if (result != null) { - setState(() { - lyricsFile = File(result.files.single.path!); - }); - } - } - - void createChapter() async { - if (titleController.text.isEmpty || - aboutController.text.isEmpty || - audioFile == null) { - // Show error if required fields are not filled - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.error), - content: Text(AppLocalizations.of(context)!.fillAllRequiredFields), - actions: [ - TextButton( - child: Text(AppLocalizations.of(context)!.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - - // Create a new chapter instance - Chapter newChapter = await exploreStoryController.createChapter( - titleController.text, - aboutController.text, - chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, - audioFile!.path, - lyricsFile?.path ?? '', - ); - - widget.onChapterCreated(newChapter); - - Navigator.pop(Get.context!); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && - currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.createAChapter), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - TextField( - controller: titleController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.chapterTitle, - counterText: '', - ), - maxLines: 1, - maxLength: 20, - ), - const SizedBox(height: 20), - TextField( - controller: aboutController, - maxLength: 2000, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.aboutRequired, - counterText: '', - ), - maxLines: 3, - ), - SizedBox(height: UiSizes.height_20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: chapterCoverImage != null - ? Image.file( - chapterCoverImage!, - fit: BoxFit.cover, - height: 150, - width: 150, - ) - : Image.network( - chapterCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: 150, - width: 150, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: pickChapterCoverImage, - child: Container( - width: 200, - height: 150, - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromARGB(84, 158, 158, 158), - ), - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.change_circle, - size: 50, - color: Colors.grey, - ), - Text( - AppLocalizations.of(context)!.changeCoverImage, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 30), - GestureDetector( - onTap: pickAudioFile, - child: Container( - width: double.infinity, - height: 50, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Center( - child: Text( - audioFile != null - ? AppLocalizations.of(context)!.audioFileSelected( - audioFile!.path.split('/').last, - ) - : AppLocalizations.of(context)!.uploadAudioFile, - style: const TextStyle(color: Colors.grey), - ), - ), - ), - ), - ), - ), - const SizedBox(height: 20), - GestureDetector( - onTap: pickLyricsFile, - child: Container( - width: double.infinity, - height: 50, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - lyricsFile != null - ? AppLocalizations.of(context)!.lyricsFileSelected( - lyricsFile!.path.split('/').last, - ) - : AppLocalizations.of(context)!.uploadLyricsFile, - style: const TextStyle(color: Colors.grey), - ), - ), - ), - ), - const SizedBox(height: 40), - ElevatedButton( - onPressed: createChapter, - child: Text(AppLocalizations.of(context)!.createChapter), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/create_story_screen.dart b/lib/views/screens/create_story_screen.dart deleted file mode 100644 index e8f86736..00000000 --- a/lib/views/screens/create_story_screen.dart +++ /dev/null @@ -1,363 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/story_category.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import '../../utils/ui_sizes.dart'; -import 'create_chapter_screen.dart'; - -class CreateStoryPage extends StatefulWidget { - const CreateStoryPage({super.key}); - - @override - CreateStoryPageState createState() => CreateStoryPageState(); -} - -class CreateStoryPageState extends State { - final TextEditingController titleController = TextEditingController(); - final TextEditingController aboutController = TextEditingController(); - final List chapters = []; - StoryCategory selectedCategory = StoryCategory.drama; - File? coverImage; // To hold the selected image - final exploreStoryController = Get.find(); - - void createStory() async { - if (titleController.text.isEmpty || - aboutController.text.isEmpty || - chapters.isEmpty) { - // Show error if required fields are not filled - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.error), - content: Text( - AppLocalizations.of(context)!.fillAllRequiredFieldsAndChapter, - ), - actions: [ - TextButton( - child: Text(AppLocalizations.of(context)!.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - - int totalPlayDuration = chapters.fold(0, (sum, chapter) { - return sum + chapter.playDuration; - }); - try { - await exploreStoryController.createStory( - titleController.text, - aboutController.text, - selectedCategory, - coverImage?.path ?? storyCoverImagePlaceholderUrl, - totalPlayDuration, - chapters, - ); - } catch (e) { - log('Story not posted: $e'); - return; - } - - appRouter.go(RoutePaths.tabview); - - log('Story Created'); - } - - void addChapter(Chapter chapter) { - setState(() { - chapters.add(chapter); - }); - } - - Future pickCoverImage() async { - final ImagePicker picker = ImagePicker(); - final XFile? selectedImage = await picker.pickImage( - source: ImageSource.gallery, - ); - - if (selectedImage != null) { - setState(() { - coverImage = File(selectedImage.path); // Update the cover image state - }); - } - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && - currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - automaticallyImplyLeading: false, - title: Text(AppLocalizations.of(context)!.createYourStory), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title Input - TextField( - controller: titleController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.titleRequired, - labelStyle: const TextStyle( - color: Color.fromARGB(255, 127, 130, 131), - ), - enabledBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.inversePrimary, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - counterText: '', - ), - maxLength: 100, - ), - const SizedBox(height: 30), - - // Category Dropdown - DropdownButtonFormField( - initialValue: selectedCategory, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.category, - border: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.inversePrimary, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.inversePrimary, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - items: StoryCategory.values.map((category) { - return DropdownMenuItem( - value: category, - child: Text( - AppLocalizations.of( - context, - )!.storyCategory(category.toString().split('.').last), - style: const TextStyle( - color: Color.fromARGB(255, 127, 130, 131), - ), - ), - ); - }).toList(), - onChanged: (value) { - setState(() { - selectedCategory = value!; - }); - }, - ), - const SizedBox(height: 30), - - // About Story Input - TextField( - controller: aboutController, - maxLength: 2000, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.aboutRequired, - labelStyle: const TextStyle( - color: Color.fromARGB(255, 127, 130, 131), - ), - enabledBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.inversePrimary, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - counterText: '', - ), - maxLines: 3, - ), - SizedBox(height: UiSizes.height_20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: coverImage != null - ? Image.file( - coverImage!, - fit: BoxFit.cover, - height: 150, - width: 150, - ) - : Image.network( - storyCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: 150, - width: 150, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: GestureDetector( - onTap: pickCoverImage, - child: Container( - margin: const EdgeInsets.all(8), - // width: 200, - height: 150, - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromARGB(84, 158, 158, 158), - ), - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.change_circle, - size: 50, - color: Colors.grey, - ), - Text( - AppLocalizations.of(context)!.changeCoverImage, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ], - ), - - const SizedBox(height: 30), - - // Chapter List with Add Chapter Button - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: chapters.length, - itemBuilder: (context, index) { - final chapter = chapters[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - elevation: 2, - child: ListTile( - title: Text( - chapter.title, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - chapter.description, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ), - trailing: Text( - formatPlayDuration(chapter.playDuration), - ), - ), - ); - }, - ), - const SizedBox(height: 10), - Center( - child: ElevatedButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - CreateChapterScreen(onChapterCreated: addChapter), - ), - ); - }, - icon: const Icon(Icons.add), - label: Text(AppLocalizations.of(context)!.addChapter), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, // button color - ), - ), - ), - const SizedBox(height: 20), - - // Create Story Button - Center( - child: ElevatedButton( - onPressed: createStory, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - ), - child: Text(AppLocalizations.of(context)!.createStory), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -// Extension to capitalize the first letter of each word -extension StringCasingExtension on String { - String capitalizeFirstOfEach() => split(' ') - .map( - (str) => str.isNotEmpty - ? str[0].toUpperCase() + str.substring(1).toLowerCase() - : '', - ) - .join(' '); -} - -String formatPlayDuration(int milliseconds) { - int totalSeconds = (milliseconds / 1000).round(); - int minutes = totalSeconds ~/ 60; - int seconds = totalSeconds % 60; - - return "$minutes:${seconds.toString().padLeft(2, '0')}"; -} diff --git a/lib/views/screens/explore_screen.dart b/lib/views/screens/explore_screen.dart deleted file mode 100644 index eca472ff..00000000 --- a/lib/views/screens/explore_screen.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/utils/app_images.dart'; -import 'package:resonate/utils/colors.dart'; -import 'package:resonate/utils/debouncer.dart'; -import 'package:resonate/utils/enums/story_category.dart'; -import 'package:resonate/views/widgets/category_card.dart'; -import 'package:resonate/views/widgets/filtered_list_tile.dart'; -import 'package:resonate/views/widgets/no_match_view.dart'; -import 'package:resonate/views/widgets/story_card.dart'; -import 'package:resonate/views/widgets/story_list_tile.dart'; -import 'package:resonate/l10n/app_localizations.dart'; - -class ExploreScreen extends StatelessWidget { - const ExploreScreen({super.key}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && - currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: const ExplorePageBody(), - ), - ); - } -} - -class ExplorePageBody extends StatefulWidget { - const ExplorePageBody({super.key}); - - @override - State createState() => _ExplorePageBodyState(); -} - -class _ExplorePageBodyState extends State { - final exploreStoryController = Get.put( - ExploreStoryController(), - ); - - final debouncer = Debouncer(milliseconds: 500); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.only(left: 15, right: 15, top: 30), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - style: const TextStyle(color: Colors.black), - onChanged: (value) { - if (value.isNotEmpty) { - exploreStoryController.isSearching.value = true; - exploreStoryController.searchBarIsEmpty.value = false; - } else { - exploreStoryController.searchBarIsEmpty.value = true; - } - debouncer.run(() async { - await exploreStoryController.searchStories(value); - await exploreStoryController.searchUsers(value); - exploreStoryController.isSearching.value = false; - }); - }, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 0, - vertical: 15, - ), - border: const OutlineInputBorder( - gapPadding: 4, - borderSide: BorderSide( - color: Colors.white, - width: 0, - style: BorderStyle.none, - ), - ), - fillColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.secondary, - filled: true, - hintText: AppLocalizations.of(context)!.whatDoYouWantToListenTo, - hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 17, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - prefixIcon: const Padding( - padding: EdgeInsets.only(left: 16.0), - child: Icon(Icons.search, color: Colors.black, size: 35), - ), - ), - ), - const SizedBox(height: 30), - Obx( - () => exploreStoryController.searchBarIsEmpty.value - ? ExplorePageContent( - exploreStoryController: exploreStoryController, - ) - : SearchResultContent( - exploreStoryController: exploreStoryController, - ), - ), - ], - ), - ); - } -} - -class ExplorePageContent extends StatelessWidget { - const ExplorePageContent({super.key, required this.exploreStoryController}); - - final ExploreStoryController exploreStoryController; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.categories, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: 20, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - const SizedBox(height: 10), - SizedBox( - height: 330, - child: GridView.builder( - physics: const NeverScrollableScrollPhysics(), - itemCount: StoryCategory.values.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 0.0, - crossAxisSpacing: 10.0, - childAspectRatio: 1.68, - ), - itemBuilder: (context, index) { - return CategoryCard( - name: StoryCategory.values[index].name, - color: - AppColor.categoryColorList[StoryCategory.values[index].name - .toLowerCase()]!, - exploreStoryController: exploreStoryController, - ); - }, - ), - ), - Text( - AppLocalizations.of(context)!.stories, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: 20, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - SizedBox( - height: 300, - width: double.infinity, - child: Obx( - () => !exploreStoryController.isLoadingRecommendedStories.value - ? exploreStoryController.recommendedStories.isNotEmpty - ? ListView.builder( - physics: const BouncingScrollPhysics(), - scrollDirection: Axis.horizontal, - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: - exploreStoryController.recommendedStories.length > - 4 - ? 4 - : exploreStoryController - .recommendedStories - .length, - itemBuilder: (context, index) { - return Container( - height: 300, - margin: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.white, - ), - child: StoryCard( - story: exploreStoryController - .recommendedStories[index], - ), - ); - }, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - height: 200, - width: 200, - AppImages.emptyBoxImage, - ), - const SizedBox(height: 10), - Text(AppLocalizations.of(context)!.noStoriesExist), - ], - ) - : Center( - child: SizedBox( - height: 100, - width: 100, - child: LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ), - ), - ), - ), - ), - const SizedBox(height: 35), - Text( - AppLocalizations.of(context)!.someSuggestions, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: 20, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: Obx( - () => !exploreStoryController.isLoadingRecommendedStories.value - ? exploreStoryController.recommendedStories.isNotEmpty - ? ListView.builder( - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.vertical, - padding: EdgeInsets.zero, - shrinkWrap: true, - primary: true, - itemCount: - exploreStoryController.recommendedStories.length > - 4 - ? exploreStoryController - .recommendedStories - .length - - 4 - : exploreStoryController - .recommendedStories - .length, - itemBuilder: (context, index) { - final int storyIndex = - exploreStoryController - .recommendedStories - .length > - 4 - ? index + 4 - : index; - return StoryListTile( - story: exploreStoryController - .recommendedStories[storyIndex], - ); - }, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - height: 200, - width: 200, - AppImages.emptyBoxImage, - ), - const SizedBox(height: 10), - Text(AppLocalizations.of(context)!.noStoriesExist), - ], - ) - : Center( - child: SizedBox( - height: 100, - width: 100, - child: LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ), - ), - ), - ), - ), - const SizedBox(height: 20), - ], - ); - } -} - -class SearchResultContent extends StatelessWidget { - const SearchResultContent({super.key, required this.exploreStoryController}); - - final ExploreStoryController exploreStoryController; - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: [ - Obx( - () => exploreStoryController.isSearching.value - ? LoadingIndicator( - indicatorType: Indicator.ballGridPulse, - colors: [Theme.of(context).colorScheme.primary], - ) - : exploreStoryController.searchResponseStories.isEmpty && - exploreStoryController.searchResponseUsers.isEmpty - ? const NoMatchView() - : SizedBox( - height: MediaQuery.of(context).size.height * .8, - width: double.infinity, - child: ListView.builder( - itemCount: - exploreStoryController.searchResponseStories.length + - exploreStoryController.searchResponseUsers.length, - itemBuilder: (context, index) { - final isStory = - index < - exploreStoryController.searchResponseStories.length; - if (isStory) { - final story = exploreStoryController - .searchResponseStories[index]; - return FilteredListTile(story: story, isStory: true); - } else { - final user = - exploreStoryController.searchResponseUsers[index - - exploreStoryController - .searchResponseStories - .length]; - return FilteredListTile(user: user, isStory: false); - } - }, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/views/screens/followers_screen.dart b/lib/views/screens/followers_screen.dart index 91dc80f6..204e20d8 100644 --- a/lib/views/screens/followers_screen.dart +++ b/lib/views/screens/followers_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/views/widgets/filtered_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/filtered_list_tile.dart'; class FollowersScreen extends StatelessWidget { final List followers; diff --git a/lib/views/screens/live_chapter_screen.dart b/lib/views/screens/live_chapter_screen.dart deleted file mode 100644 index b085a5fd..00000000 --- a/lib/views/screens/live_chapter_screen.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:resonate/controllers/live_chapter_controller.dart'; -import 'package:resonate/controllers/livekit_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/utils/ui_sizes.dart'; -import 'package:resonate/views/widgets/live_chapter_attendee_block.dart'; -import 'package:resonate/views/widgets/live_chapter_header.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; -import 'package:resonate/views/widgets/audio_selector_dialog.dart'; - -class LiveChapterScreen extends StatefulWidget { - const LiveChapterScreen({super.key}); - - @override - LiveChapterScreenState createState() => LiveChapterScreenState(); -} - -class LiveChapterScreenState extends State { - Future _deleteRoomDialog(String text, Function() onTap) async { - return await Get.defaultDialog( - title: AppLocalizations.of(context)!.areYouSure, - buttonColor: Theme.of(context).colorScheme.primary, - middleText: AppLocalizations.of(context)!.toRoomAction(text), - cancelTextColor: Theme.of(context).colorScheme.primary, - textConfirm: AppLocalizations.of(context)!.confirm, - textCancel: AppLocalizations.of(context)!.cancel, - onConfirm: onTap, - onCancel: () => log(AppLocalizations.of(context)!.canceled), - ); - } - - @override - Widget build(BuildContext context) { - LiveChapterController liveChapterController = - Get.find(); - return Scaffold( - body: Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: LiveChapterHeader( - chapterName: - liveChapterController.liveChapterModel.value!.chapterTitle, - chapterDescription: liveChapterController - .liveChapterModel - .value! - .chapterDescription, - ), - ), - SizedBox(height: UiSizes.height_7), - Expanded(child: _buildParticipantsList()), - ], - ), - ), - ); - } - - Widget _buildParticipantsList() { - return Obx(() { - return Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of( - context, - ).colorScheme.onSecondary.withValues(alpha: 0.15), - ), - ), - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildParticipantsSection( - title: AppLocalizations.of(context)!.participants, - ), - ], - ), - ), - _buildFooter(), - ], - ); - }); - } - - Widget _buildParticipantsSection({required String title}) { - final controller = Get.find(); - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: UiSizes.size_18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Obx(() { - return SizedBox( - height: double.maxFinite, - width: 400, - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: UiSizes.width_20, - mainAxisSpacing: UiSizes.height_5, - childAspectRatio: 2.5 / 3, - ), - itemCount: - (controller - .liveChapterModel - .value - ?.attendees - ?.users - .length ?? - 0) + - 1, - itemBuilder: (ctx, index) { - return LiveChapterAttendeeBlock( - user: index == 0 - ? { - 'profileImageUrl': controller - .liveChapterModel - .value - ?.authorProfileImageUrl, - 'name': - controller.liveChapterModel.value?.authorName, - '\$id': - controller.liveChapterModel.value?.authorUid, - } - : controller - .liveChapterModel - .value - ?.attendees - ?.users[index - 1] ?? - {}, - ); - }, - ), - ); - }), - ], - ), - ); - } - - Widget _buildFooter() { - final controller = Get.find(); - return Align( - alignment: Alignment.bottomCenter, - child: Container( - height: MediaQuery.of(context).size.height * 0.07, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadiusDirectional.circular(24), - color: Theme.of(context).colorScheme.surface, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildLeaveButton(), - if (controller.isAdmin) ...[ - _buildMicButton(), - _buildRecordButton(), - ], - _buildAudioSettingsButton(), - ], - ), - ), - ); - } - - Widget _buildLeaveButton() { - final controller = Get.find(); - final LiveKitController liveKitController = Get.find(); - return ElevatedButton( - onPressed: () async { - await _deleteRoomDialog( - controller.isAdmin - ? AppLocalizations.of(context)!.delete - : AppLocalizations.of(context)!.leave, - () async { - if (controller.isAdmin) { - if (liveKitController.isRecording.value == true) { - await controller.endLiveChapter(); - } else { - customSnackbar( - AppLocalizations.of(context)!.error, - AppLocalizations.of(context)!.noRecordingError, - LogType.error, - ); - } - } else { - await controller.leaveRoom(); - } - }, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - child: const Icon(Icons.call_end, size: 24), - ); - } - - Widget _buildRecordButton() { - return Obx(() { - bool isRecording = Get.find().isRecording.value; - return FloatingActionButton( - onPressed: () { - if (isRecording) { - customSnackbar( - AppLocalizations.of(context)!.actionBlocked, - AppLocalizations.of(context)!.cannotStopRecording, - LogType.info, - ); - } else { - Get.find().isRecording.value = true; - } - }, - backgroundColor: isRecording ? Colors.red : Colors.green, - child: Icon( - isRecording ? Icons.stop_circle : Icons.radio_button_checked, - ), - ); - }); - } - - Widget _buildMicButton() { - return Obx(() { - { - final controller = Get.find(); - final bool isMicOn = controller.isMicOn.value; - final bool isAdmin = controller.isAdmin; - - return FloatingActionButton( - onPressed: () { - if (isAdmin) { - if (isMicOn) { - controller.turnOffMic(); - } else { - controller.turnOnMic(); - } - } - }, - backgroundColor: isMicOn ? Colors.lightGreen : Colors.redAccent, - child: Icon(isMicOn ? Icons.mic : Icons.mic_off, color: Colors.black), - ); - } - }); - } - - Widget _buildAudioSettingsButton() { - return FloatingActionButton( - onPressed: () async => await showAudioDeviceSelector(context), - backgroundColor: Theme.of(context).colorScheme.secondary, - child: const Icon(Icons.settings_voice), - ); - } -} diff --git a/lib/views/screens/story_screen.dart b/lib/views/screens/story_screen.dart deleted file mode 100644 index 15b68ca0..00000000 --- a/lib/views/screens/story_screen.dart +++ /dev/null @@ -1,488 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/controllers/chapter_player_controller.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/controllers/live_chapter_controller.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/utils/extensions/datetime_extension.dart'; -import 'package:resonate/views/screens/add_chapter_screen.dart'; -import 'package:resonate/views/screens/chapter_play_screen.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; -import 'package:resonate/views/widgets/chapter_list_tile.dart'; -import 'package:resonate/views/widgets/like_button.dart'; - -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/views/widgets/live_chapter_list_tile.dart'; -import 'package:resonate/views/widgets/start_live_chapter_dialog.dart'; - -class StoryScreen extends StatefulWidget { - final Story story; - const StoryScreen({super.key, required this.story}); - - @override - State createState() => _StoryScreenState(); -} - -class _StoryScreenState extends State { - bool descriptionIsExpanded = false; - final exploreStoryController = Get.put( - ExploreStoryController(), - ); - - @override - void initState() { - super.initState(); - exploreStoryController.fetchMoreDetailsForSelectedStory(widget.story); - } - - @override - Widget build(BuildContext context) { - // Set the status bar color to match the story's tint color - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: widget.story.tintColor.withValues(alpha: 0.8), - statusBarIconBrightness: Brightness.light, - ), - ); - - return Scaffold( - body: Obx( - () => SafeArea( - child: exploreStoryController.isLoadingStoryPage.value - ? Center( - child: SizedBox( - height: 200, - width: 200, - child: LoadingIndicator( - indicatorType: Indicator.ballRotate, - colors: [Theme.of(context).colorScheme.primary], - ), - ), - ) - : Column( - children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - widget.story.tintColor.withValues(alpha: 0.8), - widget.story.tintColor.withValues(alpha: 0.6), - widget.story.tintColor.withValues(alpha: 0.4), - widget.story.tintColor.withValues(alpha: 0.2), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Story Image - Stack( - alignment: Alignment.bottomRight, - children: [ - Padding( - padding: const EdgeInsets.only( - right: 10.0, - bottom: 15, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - widget.story.coverImageUrl, - width: 120, - height: 120, - fit: BoxFit.cover, - ), - ), - ), - Obx( - () => LikeButton( - isLikedByUser: widget - .story - .isLikedByCurrentUser - .value, - tintColor: widget.story.tintColor, - onLiked: (isFavorite) async { - isFavorite - ? await exploreStoryController - .likeStoryFromUserAccount( - widget.story, - ) - : await exploreStoryController - .unlikeStoryFromUserAccount( - widget.story, - ); - - await exploreStoryController - .updateLikesCountAndUserLikeStatus( - widget.story, - ); - await exploreStoryController - .fetchUserLikedStories(); - }, - ), - ), - ], - ), - - const SizedBox(width: 16), - // Story Details - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // Story Title - Text( - widget.story.title, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontWeight: FontWeight.w500, - fontSize: 35, - overflow: TextOverflow.ellipsis, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Obx( - () => Text( - '${widget.story.likesCount} ${AppLocalizations.of(context)!.likes}', - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontSize: 16, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ), - const SizedBox(width: 16), - // Duration - Text( - '${formatPlayDuration(widget.story.playDuration)} ${AppLocalizations.of(context)!.lengthMinutes}', - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontSize: 16, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ], - ), - const SizedBox(height: 8), - // Creator Info - Row( - children: [ - Text( - AppLocalizations.of(context)!.by, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontSize: 16, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - const SizedBox(width: 15), - CircleAvatar( - radius: 14, - backgroundImage: NetworkImage( - widget.story.coverImageUrl, - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - child: Text( - widget.story.userIsCreator - ? AppLocalizations.of( - context, - )!.you - : widget.story.creatorName, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - fontSize: 16, - fontStyle: - FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - ], - ), - ), - ], - ), - const SizedBox(height: 40), - Text( - AppLocalizations.of(context)!.created( - widget.story.creationDate.formatDateTime( - context, - ), - ), - style: Theme.of(context).textTheme.bodyLarge! - .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontSize: 16, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ], - ), - ), - ), - const SizedBox(height: 10), - // Chapters Section (Scrollable) - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // About Section - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Text( - AppLocalizations.of(context)!.about, - style: Theme.of(context).textTheme.bodyLarge! - .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: 20, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () => setState(() { - descriptionIsExpanded = - !descriptionIsExpanded; - }), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Text( - widget.story.description, - maxLines: descriptionIsExpanded ? null : 10, - overflow: descriptionIsExpanded - ? TextOverflow.visible - : TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith( - fontSize: 16, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ), - ), - const SizedBox(height: 40), - // Chapters Section - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Text( - AppLocalizations.of(context)!.chapters, - style: Theme.of(context).textTheme.bodyLarge! - .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: 20, - fontStyle: FontStyle.normal, - fontFamily: 'Inter', - ), - ), - ), - const SizedBox(height: 8), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.story.liveChapter != null - ? widget.story.chapters.length + 1 - : widget.story.chapters.length, - itemBuilder: (context, index) { - log(index.toString()); - log(widget.story.chapters.length.toString()); - if (index == 0 && - widget.story.liveChapter != null) { - return GestureDetector( - onTap: () { - Get.put( - LiveChapterController(), - ).joinLiveChapter( - widget - .story - .liveChapter! - .livekitRoomId, - widget.story.liveChapter!, - ); - }, - child: LiveChapterListTile( - chapter: widget.story.liveChapter!, - ), - ); - } else { - final chapter = - widget.story.chapters[widget - .story - .liveChapter == - null - ? index - : index - 1]; - return GestureDetector( - onTap: () { - Get.put(ChapterPlayerController()); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ChapterPlayScreen( - chapter: chapter, - ), - ), - ); - }, - child: ChaperListTile(chapter: chapter), - ); - } - }, - ), - const SizedBox(height: 50), - - if (widget.story.userIsCreator) ...[ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - Center( - child: ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - AddNewChapterScreen( - storyName: - widget.story.title, - storyId: - widget.story.storyId, - currentChapters: - widget.story.chapters, - ), - ), - ); - }, - child: Text( - AppLocalizations.of( - context, - )!.addChapter, - ), - ), - ), - Center( - child: ElevatedButton( - onPressed: () async { - Get.dialog( - StartLiveChapterDialog( - story: widget.story, - ), - ); - }, - child: Text( - AppLocalizations.of( - context, - )!.liveChapter, - ), - ), - ), - ], - ), - - const SizedBox(height: 20), - Center( - child: ElevatedButton( - onPressed: () async { - await exploreStoryController.deleteStory( - widget.story, - ); - await exploreStoryController - .fetchUserCreatedStories(); - if (widget - .story - .isLikedByCurrentUser - .value) { - await exploreStoryController - .fetchUserLikedStories(); - } - await exploreStoryController - .fetchStoryRecommendation(); - Navigator.pop(Get.context!); - }, - child: Text( - AppLocalizations.of(context)!.deleteStory, - ), - ), - ), - ], - ], - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/screens/tabview_screen.dart b/lib/views/screens/tabview_screen.dart index 40859ad7..9bb571b6 100644 --- a/lib/views/screens/tabview_screen.dart +++ b/lib/views/screens/tabview_screen.dart @@ -17,7 +17,7 @@ import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/utils/utils.dart'; -import 'package:resonate/views/screens/explore_screen.dart'; +import 'package:resonate/features/stories/view/pages/explore_page.dart'; import 'package:resonate/views/screens/home_screen.dart'; import 'package:resonate/views/widgets/profile_avatar.dart'; @@ -192,7 +192,7 @@ class _TabViewScreenState extends ConsumerState { ? const HomeScreen() : (_tabController.getIndex() == 2) ? CreateRoomPage() - : const ExploreScreen(), + : const ExplorePage(), ), ); } diff --git a/lib/views/screens/verify_chapter_details_screen.dart b/lib/views/screens/verify_chapter_details_screen.dart deleted file mode 100644 index a33aeeea..00000000 --- a/lib/views/screens/verify_chapter_details_screen.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:resonate/controllers/live_chapter_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/routes/app_router.dart'; -import 'package:resonate/routes/route_paths.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class VerifyChapterDetailsScreen extends StatefulWidget { - const VerifyChapterDetailsScreen({super.key, required this.lyricsString}); - final String lyricsString; - - @override - VerifyChapterDetailsScreenState createState() => - VerifyChapterDetailsScreenState(); -} - -class VerifyChapterDetailsScreenState - extends State { - final TextEditingController titleController = TextEditingController(); - final TextEditingController aboutController = TextEditingController(); - final TextEditingController lyricsController = TextEditingController(); - File? chapterCoverImage; - File? audioFile; // For audio file - - ExploreStoryController exploreStoryController = - Get.find(); - LiveChapterController liveChapterController = - Get.find(); - Future pickChapterCoverImage() async { - final ImagePicker picker = ImagePicker(); - final XFile? selectedImage = await picker.pickImage( - source: ImageSource.gallery, - ); - - if (selectedImage != null) { - setState(() { - chapterCoverImage = File(selectedImage.path); - }); - } - } - - @override - initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await loadInitialData(); - }); - } - - @override - void dispose() { - titleController.dispose(); - aboutController.dispose(); - lyricsController.dispose(); - super.dispose(); - } - - Future loadInitialData() async { - final storagePath = await getApplicationDocumentsDirectory(); - final String audioFilePath = - "${storagePath.path}/recordings/${liveChapterController.liveChapterModel.value!.livekitRoomId}.wav"; - audioFile = File(audioFilePath); - titleController.text = liveChapterController - .liveChapterModel - .value! - .chapterTitle; // Pre-fill title - aboutController.text = liveChapterController - .liveChapterModel - .value! - .chapterDescription; // Pre-fill description - lyricsController.text = widget.lyricsString; - setState(() {}); - } - - Future viewOrEditLyrics() async { - await Get.dialog( - AlertDialog( - title: Text(AppLocalizations.of(context)!.viewOrEditLyrics), - content: TextField( - controller: lyricsController, - maxLines: 10, - decoration: InputDecoration(border: OutlineInputBorder()), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text(AppLocalizations.of(context)!.close), - ), - ], - ), - ); - } - - void createChapter() async { - if (titleController.text.isEmpty || - aboutController.text.isEmpty || - audioFile == null) { - // Show error if required fields are not filled - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.error), - content: Text(AppLocalizations.of(context)!.fillAllRequiredFields), - actions: [ - TextButton( - child: Text(AppLocalizations.of(context)!.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - - Chapter newChapter = await liveChapterController - .createChapterFromLiveChapter( - chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, - audioFile!.path, - lyricsController.text, - ); - - await exploreStoryController.pushChaptersToStory([ - newChapter, - ], liveChapterController.liveChapterModel.value!.storyId); - - await exploreStoryController.updateStoriesPlayDurationLength([ - ...exploreStoryController.userCreatedStories - .firstWhere( - (story) => - story.storyId == - liveChapterController.liveChapterModel.value!.storyId, - ) - .chapters, - newChapter, - ], liveChapterController.liveChapterModel.value!.storyId); - await exploreStoryController.fetchUserCreatedStories(); - - appRouter.go(RoutePaths.tabview); - Get.delete(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && - currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.verifyChapterDetails), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - TextField( - controller: titleController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.chapterTitle, - counterText: '', - ), - maxLines: 1, - maxLength: 20, - ), - const SizedBox(height: 20), - TextField( - controller: aboutController, - maxLength: 2000, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.aboutRequired, - counterText: '', - ), - maxLines: 3, - ), - SizedBox(height: UiSizes.height_20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: chapterCoverImage != null - ? Image.file( - chapterCoverImage!, - fit: BoxFit.cover, - height: 150, - width: 150, - ) - : Image.network( - chapterCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: 150, - width: 150, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: pickChapterCoverImage, - child: Container( - width: 200, - height: 150, - decoration: BoxDecoration( - border: Border.all( - color: const Color.fromARGB(84, 158, 158, 158), - ), - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.change_circle, - size: 50, - color: Colors.grey, - ), - Text( - AppLocalizations.of(context)!.changeCoverImage, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 30), - Container( - width: double.infinity, - height: 50, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Center( - child: Text( - audioFile != null - ? AppLocalizations.of(context)!.audioFileSelected( - audioFile!.path.split('/').last, - ) - : AppLocalizations.of(context)!.uploadAudioFile, - style: const TextStyle(color: Colors.grey), - ), - ), - ), - ), - ), - const SizedBox(height: 20), - GestureDetector( - onTap: viewOrEditLyrics, - child: Container( - width: double.infinity, - height: 50, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - AppLocalizations.of(context)!.viewOrEditLyrics, - style: const TextStyle(color: Colors.grey), - ), - ), - ), - ), - const SizedBox(height: 40), - ElevatedButton( - onPressed: createChapter, - child: Text(AppLocalizations.of(context)!.createChapter), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/widgets/audio_selector_dialog.dart b/lib/views/widgets/audio_selector_dialog.dart deleted file mode 100644 index 752c4011..00000000 --- a/lib/views/widgets/audio_selector_dialog.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/audio_device_controller.dart'; -import 'package:resonate/models/audio_device.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class AudioDeviceSelectorDialog extends StatelessWidget { - final AudioDeviceController controller; - const AudioDeviceSelectorDialog({super.key, required this.controller}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiSizes.width_20), - ), - ), - padding: EdgeInsets.only( - left: UiSizes.width_20, - right: UiSizes.width_20, - top: UiSizes.height_8, - bottom: MediaQuery.of(context).viewInsets.bottom + UiSizes.height_20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Drag handle - Center( - child: Container( - width: UiSizes.width_40, - height: UiSizes.height_4, - margin: EdgeInsets.only(bottom: UiSizes.height_20), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(UiSizes.width_2), - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.audioOutput, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - SizedBox(height: UiSizes.height_4), - Text( - AppLocalizations.of(context)!.selectPreferredSpeaker, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Get.back(), - ), - ], - ), - SizedBox(height: UiSizes.height_8), - const Divider(), - SizedBox(height: UiSizes.height_12), - Flexible( - child: Obx( - () => SingleChildScrollView( - child: _buildDeviceList( - context, - controller, - controller.audioOutputDevices, - controller.selectedAudioOutput.value, - ), - ), - ), - ), - SizedBox(height: UiSizes.height_12), - const Divider(), - SizedBox(height: UiSizes.height_8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => controller.refreshDevices(), - icon: Icon(Icons.refresh, size: UiSizes.size_18), - label: Text(AppLocalizations.of(context)!.refresh), - ), - SizedBox(width: UiSizes.width_8), - ElevatedButton( - onPressed: () => Get.back(), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - child: Text(AppLocalizations.of(context)!.done), - ), - ], - ), - ], - ), - ); - } - - Widget _buildDeviceList( - BuildContext context, - AudioDeviceController controller, - List devices, - AudioDevice? selectedDevice, - ) { - if (devices.isEmpty) { - return Padding( - padding: EdgeInsets.symmetric(vertical: UiSizes.height_8), - child: Text( - AppLocalizations.of(context)!.noAudioOutputDevices, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - fontStyle: FontStyle.italic, - ), - ), - ); - } - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: devices.length, - itemBuilder: (context, index) { - final device = devices[index]; - final isSelected = selectedDevice?.deviceId == device.deviceId; - final friendlyName = controller.getDeviceName(device); - final iconName = device.deviceType.iconName; - return _buildDeviceItem( - context, - device, - friendlyName, - iconName, - isSelected, - () => controller.selectAudioOutput(device), - ); - }, - ); - } - - Widget _buildDeviceItem( - BuildContext context, - AudioDevice device, - String displayName, - String iconName, - bool isSelected, - VoidCallback onTap, - ) { - IconData icon; - switch (iconName) { - case 'bluetooth_audio': - icon = Icons.bluetooth_audio; - break; - case 'phone': - icon = Icons.phone; - break; - case 'headset': - icon = Icons.headset; - break; - case 'speaker': - icon = Icons.speaker; - break; - default: - icon = Icons.volume_up; - } - - return Card( - margin: EdgeInsets.only(bottom: UiSizes.height_8), - elevation: isSelected ? 2 : 0, - color: isSelected - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) - : Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiSizes.width_10), - side: BorderSide( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), - width: isSelected ? 2 : 1, - ), - ), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(UiSizes.width_10), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: UiSizes.width_10, - vertical: UiSizes.height_12, - ), - child: Row( - children: [ - Icon( - icon, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - size: UiSizes.size_24, - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: Text( - displayName, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (isSelected) - Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - size: UiSizes.size_20, - ), - ], - ), - ), - ), - ); - } -} - -Future showAudioDeviceSelector(BuildContext context) async { - final controller = Get.put(AudioDeviceController(), permanent: true); - await controller.refreshDevices(); - - Get.bottomSheet( - AudioDeviceSelectorDialog(controller: controller), - isScrollControlled: true, - backgroundColor: Colors.transparent, - ); -} diff --git a/lib/views/widgets/category_card.dart b/lib/views/widgets/category_card.dart deleted file mode 100644 index 9dae96bf..00000000 --- a/lib/views/widgets/category_card.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/utils/enums/story_category.dart'; -import 'package:resonate/views/screens/category_screen.dart'; - -String capitalizeFirstLetter(String input) { - if (input.isEmpty) return input; - return input[0].toUpperCase() + input.substring(1); -} - -class CategoryCard extends StatelessWidget { - const CategoryCard({ - super.key, - required this.name, - required this.color, - required this.exploreStoryController, - }); - final String name; - final Color color; - final ExploreStoryController exploreStoryController; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - exploreStoryController.fetchStoryByCategory( - StoryCategory.values.byName(name), - ); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CategoryScreen(categoryName: name), - ), - ); - }, - child: Stack( - children: [ - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: color, - ), - ), - Positioned( - left: 14, - top: 14, - child: Text( - AppLocalizations.of(context)!.storyCategory(name), - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Colors.white, - fontFamily: 'Inter', - fontWeight: FontWeight.w400, - fontSize: 16.9, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/views/widgets/chapter_player.dart b/lib/views/widgets/chapter_player.dart deleted file mode 100644 index c6aa029b..00000000 --- a/lib/views/widgets/chapter_player.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; -import 'package:resonate/controllers/chapter_player_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/chapter.dart'; -import 'package:resonate/views/screens/create_story_screen.dart'; - -class ChapterPlayer extends StatelessWidget { - final Chapter chapter; - final double progress; - ChapterPlayer({super.key, required this.chapter, required this.progress}); - - final ChapterPlayerController controller = Get.find(); - - //currentPage - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - chapter.tintColor.withAlpha( - (progress < 0.75 ? 0.8 : 1) * 255 ~/ 1, - ), - chapter.tintColor.withAlpha( - (progress < 0.75 ? 0.3 : 1) * 255 ~/ 1, - ), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - child: Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - top: 30 - (progress * 100) < 20 ? 20 : 30 - (progress * 100), - left: progress < 0.45 ? 100 + (progress * 100) : 30, - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: progress > 0.65 ? 50 : 200 - (2 * (progress * 100)), - width: progress > 0.65 ? 50 : 200 - (2 * (progress * 100)), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Image.network( - chapter.coverImageUrl, - width: 200, - height: 200, - fit: BoxFit.cover, - ), - ), - ), - ), - - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - top: progress > 0.65 ? 25 : 250 - (2.5 * (progress * 100)), - left: 100, - right: 100, - child: Text( - chapter.title, - maxLines: 1, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: - Theme.of(context).brightness == Brightness.dark || - (ThemeData.estimateBrightnessForColor( - chapter.tintColor, - ) == - Brightness.dark && - progress > 0.75) - ? Colors.white - : Colors.black87, - ), - ), - ), - // Play Controls and Progress Bar - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - top: progress > 0.65 ? 70 : 300 - (3 * (progress * 100)), - left: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: Column( - children: [ - Obx( - () => Slider( - value: controller.sliderProgress.value, - onChanged: (value) { - controller.sliderProgress.value = value; - }, - onChangeEnd: (double value) { - controller.lyricController.setProgress( - Duration(milliseconds: value.toInt()), - ); - - controller.audioPlayer?.seek( - Duration(milliseconds: value.toInt()), - ); - }, - min: 0, - max: - controller.chapterDuration.inMilliseconds - .toDouble() + - 1000, - - activeColor: Colors.white, - // activeColor: widget.chapter.tintColor, - inactiveColor: Colors.grey.shade300, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: progress > 0.70 ? 0 : 1, - child: Obx( - () => Text( - "${formatPlayDuration(controller.sliderProgress.value.toInt())} ${AppLocalizations.of(context)!.lengthMinutes}", - ), - ), - ), - AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: progress > 0.70 ? 0 : 1, - child: Text( - "${formatPlayDuration(chapter.playDuration)} ${AppLocalizations.of(context)!.lengthMinutes}", - ), - ), - ], - ), - ], - ), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - top: 350 - (3.3 * (progress * 100)) < 200 - ? 200 - : 350 - (3.3 * (progress * 100)), - left: 175, - curve: Curves.easeInOut, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: progress > 0.45 ? 0 : 1, - child: Obx( - () => IconButton( - iconSize: 34, - style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - ), - onPressed: progress > 0.45 - ? null - : () { - if (controller.isPlaying.value) { - controller.audioPlayer?.pause(); - } else { - controller.audioPlayer?.resume(); - } - }, - icon: Icon( - controller.isPlaying.value - ? Icons.pause - : Icons.play_arrow, - color: Colors.white, - ), - ), - ), - ), - ), - Positioned( - top: 20, - left: 320, - child: AnimatedOpacity( - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 200), - opacity: progress > 0.45 ? 1 : 0, - child: Obx( - () => IconButton( - iconSize: 34, - style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - ), - onPressed: progress > 0.45 - ? () { - if (controller.isPlaying.value) { - controller.audioPlayer?.pause(); - } else { - controller.audioPlayer?.resume(); - } - } - : null, - icon: Icon( - controller.isPlaying.value - ? Icons.pause - : Icons.play_arrow, - color: Colors.white, - ), - ), - ), - ), - ), - - //const SizedBox(height: 40), - ], - ), - ), - ); - } -} diff --git a/lib/views/widgets/live_chapter_attendee_block.dart b/lib/views/widgets/live_chapter_attendee_block.dart deleted file mode 100644 index 58bcbbae..00000000 --- a/lib/views/widgets/live_chapter_attendee_block.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/live_chapter_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:focused_menu/focused_menu.dart'; -import 'package:focused_menu/modals.dart'; -import 'package:resonate/utils/ui_sizes.dart'; - -class FocusedMenuItemData { - final String textContent; - final Function action; - - FocusedMenuItemData(this.textContent, this.action); -} - -class LiveChapterAttendeeBlock extends StatelessWidget { - LiveChapterAttendeeBlock({super.key, required this.user}); - - final Map user; - final LiveChapterController controller = Get.find(); - String getUserRole(BuildContext context) { - if (controller.checkUserIsAdmin(user['\$id'])) { - return AppLocalizations.of(context)!.author; - } else { - return AppLocalizations.of(context)!.listener; - } - } - - List makeMenuItems( - List items, - Brightness currentBrightness, - ) { - return items - .map( - (item) => FocusedMenuItem( - title: Text( - item.textContent, - style: TextStyle(fontSize: UiSizes.size_14), - ), - trailingIcon: Icon( - Icons.remove_circle_outline, - color: Colors.red, - size: UiSizes.size_18, - ), - onPressed: item.action, - backgroundColor: currentBrightness == Brightness.light - ? Colors.white - : Colors.black, - ), - ) - .toList(); - } - - List getMenuItems( - Brightness currentBrightness, - BuildContext context, - ) { - if (!controller.isAdmin) { - return []; - } - - //Host Controls not required as Listeners have no additional permissions - // if (controller.isAdmin) { - // return makeMenuItems([ - // FocusedMenuItemData(AppLocalizations.of(context)!.kickOut, () { - // // controller.kickOutParticipant(participant); - // }), - // FocusedMenuItemData( - // AppLocalizations.of(context)!.reportParticipant, - // () { - // // controller.reportParticipant(participant); - // }, - // ), - // ], currentBrightness); - // } - - return []; - } - - @override - Widget build(BuildContext context) { - Brightness currentBrightness = Theme.of(context).brightness; - - return FocusedMenuHolder( - onPressed: () {}, - menuItemExtent: UiSizes.width_45, - menuWidth: UiSizes.width_200 * 1.05, - menuBoxDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(5.0), - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: UiSizes.width_1, - ), - ), - duration: const Duration(milliseconds: 100), - animateMenuItems: true, - blurBackgroundColor: currentBrightness == Brightness.light - ? Colors.white54 - : Colors.black54, - menuItems: getMenuItems(currentBrightness, context), - openWithTap: controller.isAdmin ? true : false, - child: Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_2, - horizontal: UiSizes.width_2, - ), - alignment: Alignment.center, - child: Column( - children: [ - CircleAvatar( - radius: UiSizes.size_32, - backgroundColor: Theme.of(context).colorScheme.primary, - child: CircleAvatar( - backgroundImage: NetworkImage(user['profileImageUrl'] ?? ''), - radius: UiSizes.size_30, - ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - user["name"].split(' ').first, - style: TextStyle(fontSize: UiSizes.size_16), - ), - ], - ), - ), - Text( - getUserRole(context), - style: TextStyle(color: Colors.grey, fontSize: UiSizes.size_14), - ), - ], - ), - ), - ); - } -} diff --git a/lib/views/widgets/start_live_chapter_dialog.dart b/lib/views/widgets/start_live_chapter_dialog.dart deleted file mode 100644 index 6e290777..00000000 --- a/lib/views/widgets/start_live_chapter_dialog.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:appwrite/appwrite.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/live_chapter_controller.dart'; -import 'package:resonate/l10n/app_localizations.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/utils/enums/log_type.dart'; -import 'package:resonate/views/widgets/loading_dialog.dart'; -import 'package:resonate/views/widgets/snackbar.dart'; - -class StartLiveChapterDialog extends StatelessWidget { - const StartLiveChapterDialog({super.key, required this.story}); - - final Story story; - @override - Widget build(BuildContext context) { - final nameController = TextEditingController(); - final descriptionController = TextEditingController(); - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 5, - children: [ - Text( - AppLocalizations.of(context)!.startLiveChapter, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - TextFormField( - controller: nameController, - maxLines: 1, - maxLength: 20, - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.chapterTitle, - ), - ), - TextFormField( - controller: descriptionController, - maxLines: 3, - maxLength: 2000, - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.description, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: () { - Get.back(); - }, - child: Text(AppLocalizations.of(context)!.cancel), - ), - ElevatedButton( - onPressed: () async { - // Handle start live chapter logic here - String chapterName = nameController.text.trim(); - String chapterDescription = descriptionController.text - .trim(); - if (chapterName.isNotEmpty && - chapterDescription.isNotEmpty) { - try { - loadingDialog(context); - await Get.put( - LiveChapterController(), - ).startLiveChapter( - ID.unique(), - chapterName, - chapterDescription, - story.storyId, - story.title, - ); - } catch (e) { - customSnackbar( - AppLocalizations.of(context)!.error, - e.toString(), - LogType.error, - ); - } - } else { - customSnackbar( - AppLocalizations.of(context)!.error, - AppLocalizations.of(context)!.fillAllFields, - LogType.error, - ); - } - }, - child: Text(AppLocalizations.of(context)!.start), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/test/controllers/audio_device_controller_test.dart b/test/controllers/audio_device_controller_test.dart deleted file mode 100644 index e552715d..00000000 --- a/test/controllers/audio_device_controller_test.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; -import 'package:resonate/controllers/audio_device_controller.dart'; -import 'package:resonate/models/audio_device.dart'; -import 'package:resonate/utils/enums/audio_device_enum.dart'; - -void main() { - late AudioDeviceController controller; - - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - setUp(() { - Get.testMode = true; - controller = AudioDeviceController(); - }); - - tearDown(() { - Get.reset(); - }); - - group('AudioDeviceController Tests', () { - test('should initialize with correct default values', () { - expect(controller.audioOutputDevices, isEmpty); - expect(controller.selectedAudioOutput.value, isNull); - }); - - test('should filter only output devices from enumerated devices', () async { - final mockDevices = [ - webrtc.MediaDeviceInfo( - deviceId: 'output1', - label: 'Speaker', - kind: 'audiooutput', - groupId: 'group1', - ), - webrtc.MediaDeviceInfo( - deviceId: 'input1', - label: 'Microphone', - kind: 'audioinput', - groupId: 'group2', - ), - webrtc.MediaDeviceInfo( - deviceId: 'output2', - label: 'Headphones', - kind: 'audiooutput', - groupId: 'group3', - ), - ]; - controller.audioOutputDevices.clear(); - for (var device in mockDevices) { - final audioDevice = AudioDevice.fromMediaDeviceInfo(device); - if (audioDevice.isAudioOutput) { - controller.audioOutputDevices.add(audioDevice); - } - } - - expect(controller.audioOutputDevices.length, 2); - expect( - controller.audioOutputDevices.every((d) => d.kind == 'audiooutput'), - true, - ); - }); - - test('should select audio output device', () async { - final testDevice = AudioDevice( - deviceId: 'test-output', - label: 'Test Speaker', - kind: 'audiooutput', - groupId: 'test-group', - deviceType: AudioDeviceType.speaker, - ); - - controller.audioOutputDevices.add(testDevice); - await controller.selectAudioOutput(testDevice); - - expect(controller.selectedAudioOutput.value, testDevice); - expect(controller.selectedAudioOutput.value?.deviceId, 'test-output'); - }); - - test('should return correct device names', () { - expect( - controller.getDeviceName( - AudioDevice( - deviceId: '1', - label: 'Earpiece', - kind: 'audiooutput', - groupId: 'g1', - deviceType: AudioDeviceType.phone, - ), - ), - 'Phone Earpiece', - ); - expect( - controller.getDeviceName( - AudioDevice( - deviceId: '2', - label: 'Speaker', - kind: 'audiooutput', - groupId: 'g2', - deviceType: AudioDeviceType.speaker, - ), - ), - 'Loudspeaker', - ); - expect( - controller.getDeviceName( - AudioDevice( - deviceId: '3', - label: 'Bluetooth Headset', - kind: 'audiooutput', - groupId: 'g3', - deviceType: AudioDeviceType.bluetoothAudio, - ), - ), - 'Bluetooth Headset', - ); - }); - - test('should return correct device icon names', () { - expect( - AudioDeviceType.fromLabel('Bluetooth Speaker').iconName, - 'bluetooth_audio', - ); - expect(AudioDeviceType.fromLabel('Earpiece').iconName, 'phone'); - expect(AudioDeviceType.fromLabel('Wired Headset').iconName, 'headset'); - expect(AudioDeviceType.fromLabel('Speaker').iconName, 'speaker'); - }); - - test('should update selected device', () async { - final device1 = AudioDevice( - deviceId: 'device1', - label: 'Speaker 1', - kind: 'audiooutput', - groupId: 'group1', - deviceType: AudioDeviceType.speaker, - ); - - final device2 = AudioDevice( - deviceId: 'device2', - label: 'Speaker 2', - kind: 'audiooutput', - groupId: 'group2', - deviceType: AudioDeviceType.speaker, - ); - - await controller.selectAudioOutput(device1); - expect(controller.selectedAudioOutput.value?.deviceId, 'device1'); - - await controller.selectAudioOutput(device2); - expect(controller.selectedAudioOutput.value?.deviceId, 'device2'); - }); - }); - - group('AudioDevice Model Tests', () { - test('should create AudioDevice from MediaDeviceInfo', () { - final mediaDeviceInfo = webrtc.MediaDeviceInfo( - deviceId: 'test-id', - label: 'Test Device', - kind: 'audiooutput', - groupId: 'test-group', - ); - - final audioDevice = AudioDevice.fromMediaDeviceInfo(mediaDeviceInfo); - - expect(audioDevice.deviceId, 'test-id'); - expect(audioDevice.label, 'Test Device'); - expect(audioDevice.kind, 'audiooutput'); - expect(audioDevice.groupId, 'test-group'); - }); - - test('should correctly identify audio output device', () { - final outputDevice = AudioDevice( - deviceId: 'id', - label: 'Speaker', - kind: 'audiooutput', - groupId: 'group', - deviceType: AudioDeviceType.speaker, - ); - - final inputDevice = AudioDevice( - deviceId: 'id', - label: 'Microphone', - kind: 'audioinput', - groupId: 'group', - deviceType: AudioDeviceType.unknown, - ); - - expect(outputDevice.isAudioOutput, true); - expect(inputDevice.isAudioOutput, false); - }); - }); -} diff --git a/test/controllers/chapter_player_controller_test.dart b/test/controllers/chapter_player_controller_test.dart deleted file mode 100644 index 7b57294b..00000000 --- a/test/controllers/chapter_player_controller_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lyric/flutter_lyric.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get/get.dart'; -import 'package:resonate/controllers/chapter_player_controller.dart'; - -void main() { - ChapterPlayerController chapterPlayerController = ChapterPlayerController(); - test('check initial values', () { - expect(chapterPlayerController.currentPage.value, 0.0); - expect(chapterPlayerController.sliderProgress.value, 0.0); - expect(chapterPlayerController.isPlaying.value, false); - expect(chapterPlayerController.lyricController.lyricNotifier.value, null); - }); - - testWidgets('check initialize', (WidgetTester tester) async { - await tester.pumpWidget(GetMaterialApp(home: Container())); - await tester.pumpAndSettle(); - chapterPlayerController.initialize( - AudioPlayer(), - '', - Duration(minutes: 3), - ); - - expect(chapterPlayerController.lyricController, isA()); - expect(chapterPlayerController.audioPlayer, isA()); - expect(chapterPlayerController.audioPlayer?.releaseMode, ReleaseMode.stop); - expect(chapterPlayerController.chapterDuration.inMinutes, 3); - }); - - testWidgets('check togglePlayPause', (WidgetTester tester) async { - await tester.pumpWidget(GetMaterialApp(home: Container())); - await tester.pumpAndSettle(); - chapterPlayerController.initialize( - AudioPlayer(), - '', - Duration(minutes: 3), - ); - - expect(chapterPlayerController.isPlaying.value, false); - chapterPlayerController.togglePlayPause(); - expect(chapterPlayerController.isPlaying.value, true); - }); -} diff --git a/test/controllers/explore_story_controller_test.dart b/test/controllers/explore_story_controller_test.dart deleted file mode 100644 index 139d89f3..00000000 --- a/test/controllers/explore_story_controller_test.dart +++ /dev/null @@ -1,341 +0,0 @@ -import 'dart:ui'; - -import 'package:appwrite/appwrite.dart'; -import 'package:appwrite/models.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:resonate/controllers/explore_story_controller.dart'; -import 'package:resonate/features/auth/model/auth_state.dart'; -import 'package:resonate/models/story.dart'; -import 'package:resonate/utils/constants.dart'; -import 'package:resonate/utils/enums/story_category.dart'; - -import '../helpers/test_root_container.dart'; -import 'explore_story_controller_test.mocks.dart'; - -@GenerateMocks([TablesDB, Storage, Functions]) -List mockStoryDocuments = [ - Row( - $id: 'doc1', - $tableId: storyTableId, - $databaseId: storyDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - 'title': 'Story 1', - 'description': 'Description of Story 1', - 'category': 'comedy', - 'coverImgUrl': 'https://example.com/image1.jpg', - 'creatorId': 'id1', - 'creatorName': 'Creator 1', - 'creatorImgUrl': 'https://example.com/profile1.jpg', - 'likes': 10, - 'tintColor': '0000FF', - 'playDuration': 120, - }, - $sequence: 0, - ), - Row( - $id: 'doc2', - $tableId: storyTableId, - $databaseId: storyDatabaseId, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - 'title': 'Story 2', - 'description': 'Description of Story 2', - 'category': 'thriller', - 'coverImgUrl': 'https://example.com/image2.jpg', - 'creatorId': 'id2', - 'creatorName': 'Creator 2', - 'creatorImgUrl': 'https://example.com/profile2.jpg', - 'likes': 10, - 'tintColor': '0000FF', - 'playDuration': 120, - }, - $sequence: 1, - ), -]; -List mockUsersDocuments = [ - Row( - $id: 'doc1', - $tableId: usersTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - 'name': 'Test User 1', - 'dob': '2000-01-01', - 'username': 'testuser1', - 'profileImageUrl': 'https://example.com/profile1.jpg', - 'email': 'testuser1@example.com', - 'profileImageId': 'profileImageId1', - 'ratingCount': 7, - 'ratingTotal': 25, - }, - $sequence: 0, - ), - Row( - $id: 'doc2', - $tableId: usersTableID, - $databaseId: userDatabaseID, - $createdAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $updatedAt: DateTime.fromMillisecondsSinceEpoch(1754337186).toIso8601String(), - $permissions: ['any'], - data: { - 'name': 'Test User 2', - 'dob': '2000-01-01', - 'username': 'testuser2', - 'profileImageUrl': 'https://example.com/profile2.jpg', - 'email': 'testuser2@example.com', - 'profileImageId': 'profileImageId2', - 'ratingCount': 5, - 'ratingTotal': 15, - }, - $sequence: 1, - ), -]; - -List> mockMeilisearchStoryResults = mockStoryDocuments - .map( - (doc) => doc.data - ..['\$id'] = doc.$id - ..['\$createdAt'] = doc.$createdAt, - ) - .toList(); -List> mockMeilisearchUserResults = mockUsersDocuments - .map((doc) => doc.data..['\$id'] = doc.$id) - .toList(); - -List mockStoriesList = [ - Story( - title: 'Story 1', - storyId: 'doc1', - description: 'Description of Story 1', - userIsCreator: false, - category: StoryCategory.comedy, - coverImageUrl: 'https://example.com/image1.jpg', - creatorId: 'id1', - creatorName: 'Creator 1', - creatorImgUrl: 'https://example.com/profile1.jpg', - creationDate: DateTime.fromMillisecondsSinceEpoch(1754337186), - likesCount: 10, - isLikedByCurrentUser: false, - playDuration: 120, - tintColor: const Color(0xff0000FF), - chapters: [], - ), - Story( - title: 'Story 2', - storyId: 'doc2', - description: 'Description of Story 2', - userIsCreator: true, - category: StoryCategory.thriller, - coverImageUrl: 'https://example.com/image2.jpg', - creatorId: 'id2', - creatorName: 'Creator 2', - creatorImgUrl: 'https://example.com/profile2.jpg', - creationDate: DateTime.fromMillisecondsSinceEpoch(1754337186), - likesCount: 10, - isLikedByCurrentUser: false, - playDuration: 120, - tintColor: const Color(0xff0000FF), - chapters: [], - ), -]; - -void main() { - late MockTablesDB tables; - late ExploreStoryController exploreStoryController; - - setUp(() async { - await installTestRootContainer( - authState: AuthState.authenticated(fakeAuthUser(uid: 'id2')), - ); - - tables = MockTablesDB(); - exploreStoryController = ExploreStoryController( - tables: tables, - storage: MockStorage(), - functions: MockFunctions(), - ); - - when( - tables.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', 'id2')], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 2), - () => RowList(total: 1, rows: [mockStoryDocuments[1]]), - ), - ); - when( - tables.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.equal('creatorId', 'id1')], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 2), - () => RowList(total: 1, rows: [mockStoryDocuments[0]]), - ), - ); - when( - tables.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [Query.limit(10)], - ), - ).thenAnswer( - (_) => Future.delayed( - const Duration(seconds: 2), - () => RowList(total: 2, rows: mockStoryDocuments), - ), - ); - }); - - test('convertAppwriteDocListToStoryList maps fields correctly', () async { - final stories = await exploreStoryController - .convertAppwriteDocListToStoryList(mockStoryDocuments); - expect(stories.length, 2); - expect(stories[0].title, 'Story 1'); - expect(stories[1].title, 'Story 2'); - expect(stories[0].storyId, 'doc1'); - expect(stories[1].storyId, 'doc2'); - expect(stories[0].category, StoryCategory.comedy); - expect(stories[1].category, StoryCategory.thriller); - expect(stories[0].creatorId, 'id1'); - expect(stories[1].creatorId, 'id2'); - expect(stories[0].creatorName, 'Creator 1'); - expect(stories[1].creatorName, 'Creator 2'); - expect(stories[0].creatorImgUrl, 'https://example.com/profile1.jpg'); - expect(stories[1].creatorImgUrl, 'https://example.com/profile2.jpg'); - expect(stories[0].likesCount.value, 10); - expect(stories[1].likesCount.value, 10); - expect(stories[0].playDuration, 120); - expect(stories[1].playDuration, 120); - expect(stories[0].tintColor, const Color(0xff0000FF)); - expect(stories[1].tintColor, const Color(0xff0000FF)); - expect(stories[0].chapters, isEmpty); - expect(stories[1].chapters, isEmpty); - expect(stories[0].userIsCreator, false); - // userIsCreator depends on auth uid; we set it to id2, story[1].creatorId == id2. - expect(stories[1].userIsCreator, true); - }); - - test('fetchStoryRecommendation populates recommendedStories', () async { - exploreStoryController.fetchStoryRecommendation(); - expect(exploreStoryController.isLoadingRecommendedStories.value, true); - await Future.delayed(const Duration(seconds: 3)); - expect(exploreStoryController.isLoadingRecommendedStories.value, false); - expect(exploreStoryController.recommendedStories.length, 2); - for (var i = 0; i < exploreStoryController.recommendedStories.length; i++) { - expect( - mockStoriesList[i].storyId, - exploreStoryController.recommendedStories[i].storyId, - ); - expect( - mockStoriesList[i].title, - exploreStoryController.recommendedStories[i].title, - ); - expect( - mockStoriesList[i].description, - exploreStoryController.recommendedStories[i].description, - ); - expect( - mockStoriesList[i].userIsCreator, - exploreStoryController.recommendedStories[i].userIsCreator, - ); - } - }); - - test('fetchUserCreatedStories populates userCreatedStories', () async { - await exploreStoryController.fetchUserCreatedStories(); - expect(exploreStoryController.userCreatedStories.length, 1); - expect(exploreStoryController.userCreatedStories[0].storyId, 'doc2'); - expect(exploreStoryController.userCreatedStories[0].title, 'Story 2'); - expect( - exploreStoryController.userCreatedStories[0].description, - 'Description of Story 2', - ); - expect(exploreStoryController.userCreatedStories[0].userIsCreator, true); - }); - - test('convertAppwriteDocListToUserList maps fields correctly', () async { - final users = exploreStoryController.convertAppwriteDocListToUserList( - mockUsersDocuments, - ); - expect(users.length, 2); - expect(users[0].name, 'Test User 1'); - expect(users[1].name, 'Test User 2'); - expect(users[0].email, 'testuser1@example.com'); - expect(users[1].email, 'testuser2@example.com'); - expect(users[0].profileImageUrl, 'https://example.com/profile1.jpg'); - expect(users[1].profileImageUrl, 'https://example.com/profile2.jpg'); - expect(users[0].userRating, 25 / 7); - expect(users[1].userRating, 15 / 5); - expect(users[0].dateOfBirth, '2000-01-01'); - expect(users[1].dateOfBirth, '2000-01-01'); - expect(users[0].docId, 'doc1'); - expect(users[1].docId, 'doc2'); - expect(users[0].uid, 'doc1'); - expect(users[1].uid, 'doc2'); - }); - - test('convertMeilisearchResultsToStoryList maps fields correctly', - () async { - final stories = await exploreStoryController - .convertMeilisearchResultsToStoryList(mockMeilisearchStoryResults); - expect(stories.length, 2); - expect(stories[0].title, 'Story 1'); - expect(stories[1].title, 'Story 2'); - expect(stories[0].storyId, 'doc1'); - expect(stories[1].storyId, 'doc2'); - expect(stories[0].category, StoryCategory.comedy); - expect(stories[1].category, StoryCategory.thriller); - expect(stories[0].creatorId, 'id1'); - expect(stories[1].creatorId, 'id2'); - expect(stories[0].creatorName, 'Creator 1'); - expect(stories[1].creatorName, 'Creator 2'); - expect(stories[0].creatorImgUrl, 'https://example.com/profile1.jpg'); - expect(stories[1].creatorImgUrl, 'https://example.com/profile2.jpg'); - expect(stories[0].likesCount.value, 10); - expect(stories[1].likesCount.value, 10); - expect(stories[0].playDuration, 120); - expect(stories[1].playDuration, 120); - expect(stories[0].tintColor, const Color(0xff0000FF)); - expect(stories[1].tintColor, const Color(0xff0000FF)); - expect(stories[0].chapters, isEmpty); - expect(stories[1].chapters, isEmpty); - expect(stories[0].userIsCreator, false); - expect(stories[1].userIsCreator, true); - }); - - test('convertMeilisearchResultsToUserList maps fields correctly', () { - final users = exploreStoryController - .convertMeilisearchResultsToUserList(mockMeilisearchUserResults); - expect(users.length, 2); - expect(users[0].name, 'Test User 1'); - expect(users[1].name, 'Test User 2'); - expect(users[0].email, 'testuser1@example.com'); - expect(users[1].email, 'testuser2@example.com'); - expect(users[0].profileImageUrl, 'https://example.com/profile1.jpg'); - expect(users[1].profileImageUrl, 'https://example.com/profile2.jpg'); - expect(users[0].userRating, 25 / 7); - expect(users[1].userRating, 15 / 5); - expect(users[0].dateOfBirth, '2000-01-01'); - expect(users[1].dateOfBirth, '2000-01-01'); - expect(users[0].docId, 'doc1'); - expect(users[1].docId, 'doc2'); - expect(users[0].uid, 'doc1'); - expect(users[1].uid, 'doc2'); - }); -} diff --git a/test/controllers/explore_story_controller_test.mocks.dart b/test/controllers/explore_story_controller_test.mocks.dart deleted file mode 100644 index 5f1acef3..00000000 --- a/test/controllers/explore_story_controller_test.mocks.dart +++ /dev/null @@ -1,762 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in resonate/test/controllers/explore_story_controller_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; -import 'dart:typed_data' as _i8; - -import 'package:appwrite/appwrite.dart' as _i4; -import 'package:appwrite/enums.dart' as _i9; -import 'package:appwrite/models.dart' as _i3; -import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/input_file.dart' as _i6; -import 'package:appwrite/src/upload_progress.dart' as _i7; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -class _FakeClient_0 extends _i1.SmartFake implements _i2.Client { - _FakeClient_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransactionList_1 extends _i1.SmartFake - implements _i3.TransactionList { - _FakeTransactionList_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransaction_2 extends _i1.SmartFake implements _i3.Transaction { - _FakeTransaction_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRowList_3 extends _i1.SmartFake implements _i3.RowList { - _FakeRowList_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeRow_4 extends _i1.SmartFake implements _i3.Row { - _FakeRow_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFileList_5 extends _i1.SmartFake implements _i3.FileList { - _FakeFileList_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeFile_6 extends _i1.SmartFake implements _i3.File { - _FakeFile_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecutionList_7 extends _i1.SmartFake implements _i3.ExecutionList { - _FakeExecutionList_7(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeExecution_8 extends _i1.SmartFake implements _i3.Execution { - _FakeExecution_8(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [TablesDB]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockTablesDB extends _i1.Mock implements _i4.TablesDB { - MockTablesDB() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i5.Future<_i3.TransactionList> listTransactions({List? queries}) => - (super.noSuchMethod( - Invocation.method(#listTransactions, [], {#queries: queries}), - returnValue: _i5.Future<_i3.TransactionList>.value( - _FakeTransactionList_1( - this, - Invocation.method(#listTransactions, [], {#queries: queries}), - ), - ), - ) - as _i5.Future<_i3.TransactionList>); - - @override - _i5.Future<_i3.Transaction> createTransaction({int? ttl}) => - (super.noSuchMethod( - Invocation.method(#createTransaction, [], {#ttl: ttl}), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createTransaction, [], {#ttl: ttl}), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.Transaction> getTransaction({ - required String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#getTransaction, [], { - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.Transaction> updateTransaction({ - required String? transactionId, - bool? commit, - bool? rollback, - }) => - (super.noSuchMethod( - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#updateTransaction, [], { - #transactionId: transactionId, - #commit: commit, - #rollback: rollback, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future deleteTransaction({required String? transactionId}) => - (super.noSuchMethod( - Invocation.method(#deleteTransaction, [], { - #transactionId: transactionId, - }), - returnValue: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i3.Transaction> createOperations({ - required String? transactionId, - List>? operations, - }) => - (super.noSuchMethod( - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - returnValue: _i5.Future<_i3.Transaction>.value( - _FakeTransaction_2( - this, - Invocation.method(#createOperations, [], { - #transactionId: transactionId, - #operations: operations, - }), - ), - ), - ) - as _i5.Future<_i3.Transaction>); - - @override - _i5.Future<_i3.RowList> listRows({ - required String? databaseId, - required String? tableId, - List? queries, - String? transactionId, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - returnValue: _i5.Future<_i3.RowList>.value( - _FakeRowList_3( - this, - Invocation.method(#listRows, [], { - #databaseId: databaseId, - #tableId: tableId, - #queries: queries, - #transactionId: transactionId, - #total: total, - }), - ), - ), - ) - as _i5.Future<_i3.RowList>); - - @override - _i5.Future<_i3.Row> createRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - required Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#createRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); - - @override - _i5.Future<_i3.Row> getRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - List? queries, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#getRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #queries: queries, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); - - @override - _i5.Future<_i3.Row> upsertRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#upsertRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); - - @override - _i5.Future<_i3.Row> updateRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - Map? data, - List? permissions, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#updateRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #data: data, - #permissions: permissions, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); - - @override - _i5.Future deleteRow({ - required String? databaseId, - required String? tableId, - required String? rowId, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteRow, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #transactionId: transactionId, - }), - returnValue: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i3.Row> decrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? min, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#decrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #min: min, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); - - @override - _i5.Future<_i3.Row> incrementRowColumn({ - required String? databaseId, - required String? tableId, - required String? rowId, - required String? column, - double? value, - double? max, - String? transactionId, - }) => - (super.noSuchMethod( - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - returnValue: _i5.Future<_i3.Row>.value( - _FakeRow_4( - this, - Invocation.method(#incrementRowColumn, [], { - #databaseId: databaseId, - #tableId: tableId, - #rowId: rowId, - #column: column, - #value: value, - #max: max, - #transactionId: transactionId, - }), - ), - ), - ) - as _i5.Future<_i3.Row>); -} - -/// A class which mocks [Storage]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockStorage extends _i1.Mock implements _i4.Storage { - MockStorage() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i5.Future<_i3.FileList> listFiles({ - required String? bucketId, - List? queries, - String? search, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listFiles, [], { - #bucketId: bucketId, - #queries: queries, - #search: search, - #total: total, - }), - returnValue: _i5.Future<_i3.FileList>.value( - _FakeFileList_5( - this, - Invocation.method(#listFiles, [], { - #bucketId: bucketId, - #queries: queries, - #search: search, - #total: total, - }), - ), - ), - ) - as _i5.Future<_i3.FileList>); - - @override - _i5.Future<_i3.File> createFile({ - required String? bucketId, - required String? fileId, - required _i6.InputFile? file, - List? permissions, - dynamic Function(_i7.UploadProgress)? onProgress, - }) => - (super.noSuchMethod( - Invocation.method(#createFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #file: file, - #permissions: permissions, - #onProgress: onProgress, - }), - returnValue: _i5.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#createFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #file: file, - #permissions: permissions, - #onProgress: onProgress, - }), - ), - ), - ) - as _i5.Future<_i3.File>); - - @override - _i5.Future<_i3.File> getFile({ - required String? bucketId, - required String? fileId, - }) => - (super.noSuchMethod( - Invocation.method(#getFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - returnValue: _i5.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#getFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - ), - ), - ) - as _i5.Future<_i3.File>); - - @override - _i5.Future<_i3.File> updateFile({ - required String? bucketId, - required String? fileId, - String? name, - List? permissions, - }) => - (super.noSuchMethod( - Invocation.method(#updateFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #name: name, - #permissions: permissions, - }), - returnValue: _i5.Future<_i3.File>.value( - _FakeFile_6( - this, - Invocation.method(#updateFile, [], { - #bucketId: bucketId, - #fileId: fileId, - #name: name, - #permissions: permissions, - }), - ), - ), - ) - as _i5.Future<_i3.File>); - - @override - _i5.Future deleteFile({ - required String? bucketId, - required String? fileId, - }) => - (super.noSuchMethod( - Invocation.method(#deleteFile, [], { - #bucketId: bucketId, - #fileId: fileId, - }), - returnValue: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i8.Uint8List> getFileDownload({ - required String? bucketId, - required String? fileId, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFileDownload, [], { - #bucketId: bucketId, - #fileId: fileId, - #token: token, - }), - returnValue: _i5.Future<_i8.Uint8List>.value(_i8.Uint8List(0)), - ) - as _i5.Future<_i8.Uint8List>); - - @override - _i5.Future<_i8.Uint8List> getFilePreview({ - required String? bucketId, - required String? fileId, - int? width, - int? height, - _i9.ImageGravity? gravity, - int? quality, - int? borderWidth, - String? borderColor, - int? borderRadius, - double? opacity, - int? rotation, - String? background, - _i9.ImageFormat? output, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFilePreview, [], { - #bucketId: bucketId, - #fileId: fileId, - #width: width, - #height: height, - #gravity: gravity, - #quality: quality, - #borderWidth: borderWidth, - #borderColor: borderColor, - #borderRadius: borderRadius, - #opacity: opacity, - #rotation: rotation, - #background: background, - #output: output, - #token: token, - }), - returnValue: _i5.Future<_i8.Uint8List>.value(_i8.Uint8List(0)), - ) - as _i5.Future<_i8.Uint8List>); - - @override - _i5.Future<_i8.Uint8List> getFileView({ - required String? bucketId, - required String? fileId, - String? token, - }) => - (super.noSuchMethod( - Invocation.method(#getFileView, [], { - #bucketId: bucketId, - #fileId: fileId, - #token: token, - }), - returnValue: _i5.Future<_i8.Uint8List>.value(_i8.Uint8List(0)), - ) - as _i5.Future<_i8.Uint8List>); -} - -/// A class which mocks [Functions]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFunctions extends _i1.Mock implements _i4.Functions { - MockFunctions() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.Client get client => - (super.noSuchMethod( - Invocation.getter(#client), - returnValue: _FakeClient_0(this, Invocation.getter(#client)), - ) - as _i2.Client); - - @override - _i5.Future<_i3.ExecutionList> listExecutions({ - required String? functionId, - List? queries, - bool? total, - }) => - (super.noSuchMethod( - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - returnValue: _i5.Future<_i3.ExecutionList>.value( - _FakeExecutionList_7( - this, - Invocation.method(#listExecutions, [], { - #functionId: functionId, - #queries: queries, - #total: total, - }), - ), - ), - ) - as _i5.Future<_i3.ExecutionList>); - - @override - _i5.Future<_i3.Execution> createExecution({ - required String? functionId, - String? body, - bool? xasync, - String? path, - _i9.ExecutionMethod? method, - Map? headers, - String? scheduledAt, - }) => - (super.noSuchMethod( - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - returnValue: _i5.Future<_i3.Execution>.value( - _FakeExecution_8( - this, - Invocation.method(#createExecution, [], { - #functionId: functionId, - #body: body, - #xasync: xasync, - #path: path, - #method: method, - #headers: headers, - #scheduledAt: scheduledAt, - }), - ), - ), - ) - as _i5.Future<_i3.Execution>); - - @override - _i5.Future<_i3.Execution> getExecution({ - required String? functionId, - required String? executionId, - }) => - (super.noSuchMethod( - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - returnValue: _i5.Future<_i3.Execution>.value( - _FakeExecution_8( - this, - Invocation.method(#getExecution, [], { - #functionId: functionId, - #executionId: executionId, - }), - ), - ), - ) - as _i5.Future<_i3.Execution>); -} diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 753d10ab..91f04085 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -134,7 +134,7 @@ void main() { expect(stories[0].storyId, 'doc1'); expect(stories[0].category, StoryCategory.comedy); expect(stories[1].category, StoryCategory.thriller); - expect(stories[0].likesCount.value, 10); + expect(stories[0].likesCount, 10); expect(stories[0].tintColor, const Color(0xff0000FF)); expect(stories[0].userIsCreator, false); expect(stories[0].chapters, isEmpty); diff --git a/test/features/profile/fake_profile_repository.dart b/test/features/profile/fake_profile_repository.dart index e3d8e43d..f3e4d812 100644 --- a/test/features/profile/fake_profile_repository.dart +++ b/test/features/profile/fake_profile_repository.dart @@ -1,6 +1,6 @@ import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; +import 'package:resonate/features/stories/model/story.dart'; import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; class FakeProfileRepository implements ProfileRepository { // Configurable returns. diff --git a/test/features/profile/viewmodel/profile_view_notifier_test.dart b/test/features/profile/viewmodel/profile_view_notifier_test.dart index b112245e..1d9d8ab8 100644 --- a/test/features/profile/viewmodel/profile_view_notifier_test.dart +++ b/test/features/profile/viewmodel/profile_view_notifier_test.dart @@ -7,8 +7,8 @@ import 'package:resonate/features/auth/model/auth_state.dart'; import 'package:resonate/features/auth/viewmodel/auth_notifier.dart'; import 'package:resonate/features/profile/data/repositories/profile_repository.dart'; import 'package:resonate/features/profile/viewmodel/profile_view_notifier.dart'; +import 'package:resonate/features/stories/model/story.dart'; import 'package:resonate/models/follower_user_model.dart'; -import 'package:resonate/models/story.dart'; import 'package:resonate/utils/enums/story_category.dart'; import '../../../helpers/test_root_container.dart'; From 837ccb672b80dc921c2e995c91d8d05c83222c89 Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:36:01 +0530 Subject: [PATCH 11/12] feat: Added tests for stories and chapters --- .../generated/live_chapter_notifier.g.dart | 2 +- .../data/live_chapter_repository_test.dart | 250 +++++++ .../stories/data/stories_repository_test.dart | 631 ++++++++++++++++++ .../stories/view/stories_test_helpers.dart | 47 ++ .../stories/view/stories_widgets_test.dart | 343 ++++++++++ .../stories/view/story_format_test.dart | 35 + .../category_stories_notifier_test.dart | 65 ++ .../chapter_player_notifier_test.dart | 30 + .../viewmodel/create_story_notifier_test.dart | 115 ++++ .../explore_stories_notifier_test.dart | 80 +++ .../viewmodel/live_chapter_notifier_test.dart | 131 ++++ .../viewmodel/story_detail_notifier_test.dart | 179 +++++ .../viewmodel/story_search_notifier_test.dart | 105 +++ .../whisper_model_notifier_test.dart | 30 + test/helpers/test_root_container.dart | 321 +++++---- test/helpers/test_root_container.mocks.dart | 305 +++++++-- 16 files changed, 2499 insertions(+), 170 deletions(-) create mode 100644 test/features/stories/data/live_chapter_repository_test.dart create mode 100644 test/features/stories/data/stories_repository_test.dart create mode 100644 test/features/stories/view/stories_test_helpers.dart create mode 100644 test/features/stories/view/stories_widgets_test.dart create mode 100644 test/features/stories/view/story_format_test.dart create mode 100644 test/features/stories/viewmodel/category_stories_notifier_test.dart create mode 100644 test/features/stories/viewmodel/chapter_player_notifier_test.dart create mode 100644 test/features/stories/viewmodel/create_story_notifier_test.dart create mode 100644 test/features/stories/viewmodel/explore_stories_notifier_test.dart create mode 100644 test/features/stories/viewmodel/live_chapter_notifier_test.dart create mode 100644 test/features/stories/viewmodel/story_detail_notifier_test.dart create mode 100644 test/features/stories/viewmodel/story_search_notifier_test.dart create mode 100644 test/features/stories/viewmodel/whisper_model_notifier_test.dart diff --git a/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart index d5893a11..fbb90940 100644 --- a/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart +++ b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart @@ -41,7 +41,7 @@ final class LiveChapterProvider } } -String _$liveChapterHash() => r'90a5bcf0bf35da52a7408a62dc26f4ffcf89fcd3'; +String _$liveChapterHash() => r'e3e38a3fa75b5b026cc9513adb59b39e96533d08'; abstract class _$LiveChapter extends $Notifier { LiveChapterState build(); diff --git a/test/features/stories/data/live_chapter_repository_test.dart b/test/features/stories/data/live_chapter_repository_test.dart new file mode 100644 index 00000000..e5a6f246 --- /dev/null +++ b/test/features/stories/data/live_chapter_repository_test.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/stories/data/repositories/live_chapter_repository.dart'; +import 'package:resonate/services/api_service.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +const _secureStorageChannel = + MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + + +void stubSecureStorage({String? readValue}) { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _secureStorageChannel, + (call) async => call.method == 'read' ? readValue : null, + ); +} + +MockExecution _execution(String body) { + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn(body); + return exec; +} + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late MockRealtimeSubscription subscription; + late StreamController events; + late LiveChapterRepository repo; + + setUp(() { + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + subscription = MockRealtimeSubscription(); + events = StreamController.broadcast(); + stubSecureStorage(); + repo = LiveChapterRepository( + tables: tables, + realtime: realtime, + functions: functions, + apiService: ApiService(functions: functions), + ); + }); + + tearDown(() => events.close()); + + group('createLiveChapterRoom', () { + test('returns the LiveKit join params from the cloud function', () async { + when(functions.createExecution( + functionId: createLiveChapterRoomFunctionId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution( + '{"livekit_socket_url":"wss://example.com","access_token":"tok"}', + )); + + final join = await repo.createLiveChapterRoom( + appwriteRoomId: 'room-1', + adminUid: 'me', + ); + + expect(join.liveKitUri, 'wss://example.com'); + expect(join.roomToken, 'tok'); + }); + }); + + group('joinLiveChapterRoom', () { + test('returns the LiveKit join params from the join function', () async { + when(functions.createExecution( + functionId: joinRoomServiceId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution( + '{"livekit_socket_url":"wss://example.com","access_token":"jtok"}', + )); + + final join = await repo.joinLiveChapterRoom(roomId: 'room-1', userId: 'me'); + + expect(join.roomToken, 'jtok'); + }); + }); + + group('deleteLiveChapterRoom', () { + test('calls the delete function when an admin token is stored', () async { + stubSecureStorage(readValue: 'admin-token'); + when(functions.createExecution( + functionId: deleteLiveChapterRoomFunctionId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution('{"status":"deleted"}')); + + await repo.deleteLiveChapterRoom('room-1'); + + verify(functions.createExecution( + functionId: deleteLiveChapterRoomFunctionId, + body: anyNamed('body'), + )).called(1); + }); + + test('is a no-op when no admin token is stored', () async { + stubSecureStorage(); // read → null + + await repo.deleteLiveChapterRoom('room-1'); + + verifyNever(functions.createExecution( + functionId: deleteLiveChapterRoomFunctionId, + body: anyNamed('body'), + )); + }); + }); + + group('createLiveChapterDocs', () { + test('writes the live-chapter row and the attendees row', () async { + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow(id: 'x', data: const {})); + + await repo.createLiveChapterDocs( + fakeLiveChapterModel(authorUid: 'me', attendees: fakeLiveChapterAttendees()), + ); + + verify(tables.createRow( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + rowId: 'room-1', + data: anyNamed('data'), + )).called(1); + verify(tables.createRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: 'room-1', + data: anyNamed('data'), + )).called(1); + }); + }); + + group('updateAttendees', () { + test('updates the attendees row', () async { + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow(id: 'room-1', data: const {})); + + await repo.updateAttendees( + 'room-1', + fakeLiveChapterAttendees(userIds: const ['me']), + ); + + verify(tables.updateRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: 'room-1', + data: anyNamed('data'), + )).called(1); + }); + }); + + group('deleteLiveChapterDocs', () { + test('deletes both the live-chapter and attendees rows', () async { + when(tables.deleteRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + )).thenAnswer((_) async => ''); + + await repo.deleteLiveChapterDocs('room-1'); + + verify(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + rowId: 'room-1', + )).called(1); + verify(tables.deleteRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: 'room-1', + )).called(1); + }); + }); + + group('attendeesStream', () { + test('forwards events that carry a payload and skips empty ones', () async { + when(realtime.subscribe(any)).thenReturn(subscription); + when(subscription.stream).thenAnswer((_) => events.stream); + when(subscription.close).thenReturn(() async {}); + + final received = []; + final sub = repo.attendeesStream('room-1').listen(received.add); + + final channel = LiveChapterRepository.attendeesChannel('room-1'); + events.add(RealtimeMessage( + events: ['$channel.update'], + payload: {'liveChapterId': 'room-1'}, + channels: [channel], + timestamp: DateTime.now().toIso8601String(), + )); + events.add(RealtimeMessage( + events: ['$channel.update'], + payload: const {}, + channels: [channel], + timestamp: DateTime.now().toIso8601String(), + )); + await pumpEventQueue(); + + expect(received, hasLength(1)); + await sub.cancel(); + }); + + test('builds the attendees channel string', () { + expect( + LiveChapterRepository.attendeesChannel('room-1'), + 'databases.$userDatabaseID.tables.$liveChapterAttendeesTableId.rows.room-1', + ); + }); + }); + + group('sendLiveChapterNotification', () { + test('invokes the story notification function', () async { + when(functions.createExecution( + functionId: sendStoryNotificationFunctionID, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution('{}')); + + await repo.sendLiveChapterNotification( + creatorId: 'me', + title: 'Live Chapter Starting!', + body: 'Tune in', + ); + + verify(functions.createExecution( + functionId: sendStoryNotificationFunctionID, + body: anyNamed('body'), + )).called(1); + }); + }); +} diff --git a/test/features/stories/data/stories_repository_test.dart b/test/features/stories/data/stories_repository_test.dart new file mode 100644 index 00000000..dcb50038 --- /dev/null +++ b/test/features/stories/data/stories_repository_test.dart @@ -0,0 +1,631 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/model/stories_failure.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row storyRow({ + String id = 'story-1', + String title = 'A Story', + String description = 'desc', + String category = 'drama', + String creatorId = 'creator-1', + String creatorName = 'Creator', + String creatorImgUrl = 'https://example.com/a.jpg', + String coverImgUrl = 'https://example.com/c.jpg', + int likes = 0, + int playDuration = 1000, + String tintColor = 'cbc6c6', +}) => + buildRow( + id: id, + tableId: storyTableId, + databaseId: storyDatabaseId, + data: { + 'title': title, + 'description': description, + 'category': category, + 'creatorId': creatorId, + 'creatorName': creatorName, + 'creatorImgUrl': creatorImgUrl, + 'coverImgUrl': coverImgUrl, + 'likes': likes, + 'playDuration': playDuration, + 'tintColor': tintColor, + }, + ); + +Row chapterRow({ + String id = 'chapter-1', + String title = 'A Chapter', + String description = 'desc', + String lyrics = '', + String coverImgUrl = 'https://example.com/cc.jpg', + String audioFileUrl = 'https://example.com/audio.mp3', + int playDuration = 250, + String tintColor = 'cbc6c6', +}) => + buildRow( + id: id, + tableId: chapterTableId, + databaseId: storyDatabaseId, + data: { + 'title': title, + 'description': description, + 'lyrics': lyrics, + 'coverImgUrl': coverImgUrl, + 'audioFileUrl': audioFileUrl, + 'playDuration': playDuration, + 'tintColor': tintColor, + }, + ); + +Row likeRow({String id = 'like-1', String uid = 'me', String storyId = 'story-1'}) => + buildRow( + id: id, + tableId: likeTableId, + databaseId: storyDatabaseId, + data: {'uId': uid, 'storyId': storyId}, + ); + +Row userRow({ + String id = 'user-1', + String name = 'Alice', + String username = 'alice', + String profileImageUrl = 'https://example.com/u.jpg', + int ratingCount = 1, + double ratingTotal = 4, +}) => + buildRow( + id: id, + tableId: usersTableID, + databaseId: userDatabaseID, + data: { + 'name': name, + 'username': username, + 'profileImageUrl': profileImageUrl, + 'ratingCount': ratingCount, + 'ratingTotal': ratingTotal, + }, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + late StoriesRepository repo; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + repo = StoriesRepository( + tables: tables, + storage: storage, + functions: functions, + ); + }); + + void stubStoryList(List rows) { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: rows.length, rows: rows)); + } + + group('fetchRecommendedStories', () { + test('parses rows and flags the current user as creator', () async { + stubStoryList([storyRow(id: 's1', creatorId: 'me')]); + + final stories = await repo.fetchRecommendedStories('me'); + + expect(stories, hasLength(1)); + expect(stories.first.storyId, 's1'); + expect(stories.first.userIsCreator, isTrue); + }); + + test('skips malformed rows instead of throwing', () async { + stubStoryList([ + storyRow(id: 'ok'), + storyRow(id: 'bad', category: 'not-a-real-category'), + ]); + + final stories = await repo.fetchRecommendedStories('me'); + + expect(stories, hasLength(1)); + expect(stories.first.storyId, 'ok'); + }); + + test('returns an empty list on AppwriteException', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenThrow(AppwriteException('boom', 500)); + + expect(await repo.fetchRecommendedStories('me'), isEmpty); + }); + }); + + group('fetchStoriesByCategory', () { + test('returns stories for the requested category', () async { + stubStoryList([storyRow(category: 'horror')]); + + final stories = await repo.fetchStoriesByCategory(StoryCategory.horror, 'me'); + + expect(stories.single.category, StoryCategory.horror); + }); + }); + + group('fetchCreatedStories', () { + test('marks all returned stories as owned by the creator', () async { + stubStoryList([storyRow(creatorId: 'creator-9')]); + + final stories = await repo.fetchCreatedStories('creator-9'); + + expect(stories.single.userIsCreator, isTrue); + }); + }); + + group('fetchLikedStories', () { + test('resolves each like into its story and skips missing ones', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 2, + rows: [ + likeRow(id: 'l1', storyId: 's1'), + likeRow(id: 'l2', storyId: 'missing'), + ], + )); + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).thenAnswer((_) async => storyRow(id: 's1')); + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 'missing', + )).thenThrow(AppwriteException('not found', 404)); + + final stories = await repo.fetchLikedStories('me'); + + expect(stories, hasLength(1)); + expect(stories.first.storyId, 's1'); + }); + }); + + group('fetchChaptersForStory', () { + test('maps chapter rows into Chapter objects', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 1, + rows: [chapterRow(id: 'c1', title: 'Chapter 1')], + )); + + final chapters = await repo.fetchChaptersForStory('s1'); + + expect(chapters, hasLength(1)); + expect(chapters.first.chapterId, 'c1'); + expect(chapters.first.title, 'Chapter 1'); + }); + }); + + group('fetchLikesCount', () { + test('reads the likes attribute off the story row', () async { + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: {'likes': 7}, + )); + + expect(await repo.fetchLikesCount('s1'), 7); + }); + }); + + group('checkIfStoryLikedByUser', () { + test('returns true when a like row exists', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [likeRow()])); + + expect(await repo.checkIfStoryLikedByUser('s1', 'me'), isTrue); + }); + + test('returns false when no like row exists', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + expect(await repo.checkIfStoryLikedByUser('s1', 'me'), isFalse); + }); + }); + + group('fetchLiveChapterForStory', () { + test('returns null when there is no live chapter', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + expect(await repo.fetchLiveChapterForStory('s1'), isNull); + }); + + test('builds the model with attendees when one exists', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 1, + rows: [ + buildRow( + id: 'lc1', + tableId: liveChaptersTableId, + databaseId: storyDatabaseId, + data: { + '\$id': 'lc1', + 'livekitRoomId': 'room-1', + 'authorUid': 'creator-1', + 'authorProfileImageUrl': 'https://example.com/a.jpg', + 'authorName': 'Creator', + 'chapterTitle': 'Live One', + 'chapterDescription': 'desc', + 'storyId': 's1', + 'followersFCMToken': [], + }, + ), + ], + )); + when(tables.getRow( + databaseId: userDatabaseID, + tableId: liveChapterAttendeesTableId, + rowId: 'lc1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 'lc1', + tableId: liveChapterAttendeesTableId, + databaseId: userDatabaseID, + data: { + 'liveChapterId': 'lc1', + 'users': [ + {'\$id': 'u1', 'name': 'U', 'profileImageUrl': ''}, + ], + }, + )); + + final live = await repo.fetchLiveChapterForStory('s1'); + + expect(live, isNotNull); + expect(live!.id, 'lc1'); + expect(live.chapterTitle, 'Live One'); + expect(live.attendees!.users, hasLength(1)); + }); + }); + + group('loadStoryDetail', () { + test('aggregates chapters, likes, like-status and live chapter', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [chapterRow()])); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: {'likes': 3}, + )); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + final state = await repo.loadStoryDetail('s1', 'me'); + + expect(state.chapters, hasLength(1)); + expect(state.likesCount, 3); + expect(state.isLikedByCurrentUser, isFalse); + expect(state.liveChapter, isNull); + }); + }); + + group('search (Appwrite path)', () { + test('returns matching stories and users', () async { + stubStoryList([storyRow(title: 'Adventure')]); + when(tables.listRows( + databaseId: userDatabaseID, + tableId: usersTableID, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [userRow()])); + + final state = await repo.search('adv', 'me'); + + expect(state.stories, hasLength(1)); + expect(state.users, hasLength(1)); + expect(state.users.first.userName, 'alice'); + }); + }); + + group('likeStory', () { + test('inserts a like row and increments the story counter', () async { + final story = fakeStory(storyId: 's1', likesCount: 5); + when(tables.createRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => likeRow()); + when(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: anyNamed('data'), + )).thenAnswer((_) async => storyRow(id: 's1')); + + await repo.likeStory(story, 'me'); + + verify(tables.createRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + verify(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: {'likes': 6}, + )).called(1); + }); + + test('throws StoriesFailure.unknown when the SDK errors', () async { + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenThrow(AppwriteException('nope', 500)); + + expect( + repo.likeStory(fakeStory(storyId: 's1'), 'me'), + throwsA(isA()), + ); + }); + }); + + group('unlikeStory', () { + test('deletes the like row and decrements the counter', () async { + final story = fakeStory(storyId: 's1', likesCount: 5); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [likeRow(id: 'like-7')])); + when(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: 'like-7', + )).thenAnswer((_) async => ''); + when(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: anyNamed('data'), + )).thenAnswer((_) async => storyRow(id: 's1')); + + await repo.unlikeStory(story, 'me'); + + verify(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: 'like-7', + )).called(1); + verify(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: {'likes': 4}, + )).called(1); + }); + }); + + group('createStory', () { + test('writes the story row when the cover is already a URL', () async { + when(tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => storyRow()); + + await repo.createStory( + user: fakeAuthUser(uid: 'me'), + title: 'My Story', + description: 'desc', + category: StoryCategory.drama, + coverImgRef: 'https://example.com/cover.jpg', + storyPlayDuration: 100, + chapters: const [], + ); + + verify(tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + verifyNever(functions.createExecution( + functionId: anyNamed('functionId'), + body: anyNamed('body'), + )); + }); + + test('throws StoriesFailure.unknown when the row write fails', () async { + when(tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenThrow(AppwriteException('invalid structure', 400)); + + expect( + repo.createStory( + user: fakeAuthUser(uid: 'me'), + title: 'My Story', + description: 'desc', + category: StoryCategory.drama, + coverImgRef: 'https://example.com/cover.jpg', + storyPlayDuration: 100, + chapters: const [], + ), + throwsA(isA()), + ); + }); + }); + + group('addChaptersToStory', () { + test('recomputes play duration from the persisted chapters', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 2, + rows: [ + chapterRow(id: 'c1', playDuration: 200), + chapterRow(id: 'c2', playDuration: 300), + ], + )); + when(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: anyNamed('data'), + )).thenAnswer((_) async => storyRow(id: 's1')); + + await repo.addChaptersToStory(const [], 's1'); + + verify(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: {'playDuration': 500}, + )).called(1); + }); + }); + + group('deleteStory', () { + test('removes cover, chapters, likes and the story row', () async { + final story = fakeStory(storyId: 's1'); + when(storage.deleteFile( + bucketId: storyBucketId, + fileId: anyNamed('fileId'), + )).thenAnswer((_) async => null); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).thenAnswer((_) async => ''); + + await repo.deleteStory(story); + + verify(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).called(1); + }); + + test('throws StoriesFailure.unknown when the row delete fails', () async { + final story = fakeStory(storyId: 's1'); + when(storage.deleteFile( + bucketId: storyBucketId, + fileId: anyNamed('fileId'), + )).thenAnswer((_) async => null); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: anyNamed('tableId'), + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).thenThrow(AppwriteException('locked', 500)); + + expect(repo.deleteStory(story), throwsA(isA())); + }); + }); + + group('deleteChapter', () { + test('removes the cover, audio and chapter row', () async { + final chapter = fakeChapter(chapterId: 'c1'); + when(storage.deleteFile( + bucketId: storyBucketId, + fileId: anyNamed('fileId'), + )).thenAnswer((_) async => null); + when(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: chapterTableId, + rowId: 'c1', + )).thenAnswer((_) async => ''); + + await repo.deleteChapter(chapter); + + verify(storage.deleteFile(bucketId: storyBucketId, fileId: 'c1')).called(1); + verify(storage.deleteFile(bucketId: storyBucketId, fileId: 'audioForc1')) + .called(1); + verify(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: chapterTableId, + rowId: 'c1', + )).called(1); + }); + }); +} diff --git a/test/features/stories/view/stories_test_helpers.dart b/test/features/stories/view/stories_test_helpers.dart new file mode 100644 index 00000000..3db87c96 --- /dev/null +++ b/test/features/stories/view/stories_test_helpers.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +Widget storiesTestApp(Widget child) { + return MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + home: Builder( + builder: (context) { + UiSizes.init(context); + return Scaffold(body: child); + }, + ), + ); +} + +/// Image.network can't load under flutter_test +void clearImageLoadErrors(WidgetTester tester) { + while (tester.takeException() != null) {} +} + +Future pumpStoriesPage( + WidgetTester tester, + Widget child, { + required ProviderContainer container, +}) async { + addTearDown(container.dispose); + tester.view.physicalSize = const Size(1080, 2340); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: storiesTestApp(child), + ), + ); +} diff --git a/test/features/stories/view/stories_widgets_test.dart b/test/features/stories/view/stories_widgets_test.dart new file mode 100644 index 00000000..a575cf22 --- /dev/null +++ b/test/features/stories/view/stories_widgets_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/view/pages/category_page.dart'; +import 'package:resonate/features/stories/view/pages/explore_page.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; +import 'package:resonate/features/stories/view/widgets/category_card.dart'; +import 'package:resonate/features/stories/view/widgets/chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/filtered_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/like_button.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_header.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/story_card.dart'; +import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import '../../../helpers/test_root_container.dart'; +import 'stories_test_helpers.dart'; + +// Fake notifiers +class _FakeExploreStories extends ExploreStories { + _FakeExploreStories(this._future); + final Future> _future; + @override + Future> build() => _future; +} + +class _FakeCategoryStories extends CategoryStories { + _FakeCategoryStories(this._future); + final Future> _future; + @override + Future> build(StoryCategory category) => _future; +} + +class _FakeStoryDetail extends StoryDetail { + _FakeStoryDetail(this._state); + final StoryDetailState _state; + @override + Future build(String storyId) async => _state; +} + +final _chapter = fakeChapter( + chapterId: 'c1', + title: 'My Chapter', + coverImageUrl: 'http://example.com/cover.png', + description: 'A chapter description', + audioFileUrl: 'http://example.com/audio.mp3', + playDuration: 65000, // 1:05 +); + +final _live = fakeLiveChapterModel( + id: 'r1', + authorUid: 'u1', + authorProfileImageUrl: '', + authorName: 'Author Name', + chapterTitle: 'Live Chapter One', + // chapterDescription defaults to Live description +); + +final _user = fakeResonateUser( + uid: 'u9', + userName: 'janedoe', + name: 'Jane Doe', + profileImageUrl: 'http://example.com/u.png', + userRating: 4.5, +); + +final _story = fakeStory( + title: 'Story A', + storyId: 's1', + description: 'story description', + coverImageUrl: 'http://example.com/c.png', + creatorId: 'u1', + creatorName: 'Creator Name', + creatorImgUrl: 'http://example.com/p.png', + likesCount: 3, + playDuration: 65000, +); + +void main() { + group('LikeButton', () { + testWidgets('renders the outline icon when not liked', (tester) async { + await tester.pumpWidget( + storiesTestApp( + LikeButton( + tintColor: const Color(0xffEE0000), + isLikedByUser: false, + onLiked: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.byIcon(Icons.favorite), findsNothing); + }); + + testWidgets('renders the filled icon when already liked', (tester) async { + await tester.pumpWidget( + storiesTestApp( + LikeButton( + tintColor: const Color(0xffEE0000), + isLikedByUser: true, + onLiked: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); + + testWidgets('tapping toggles to liked and reports true', (tester) async { + bool? reported; + await tester.pumpWidget( + storiesTestApp( + LikeButton( + tintColor: const Color(0xffEE0000), + isLikedByUser: false, + onLiked: (v) => reported = v, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(LikeButton)); + await tester.pumpAndSettle(); + + expect(reported, isTrue); + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); + }); + + group('LiveChapterHeader', () { + testWidgets('shows the chapter name and description', (tester) async { + await tester.pumpWidget( + storiesTestApp( + const LiveChapterHeader( + chapterName: 'Header Title', + chapterDescription: 'Header description', + ), + ), + ); + expect(find.text('Header Title'), findsOneWidget); + expect(find.text('Header description'), findsOneWidget); + }); + }); + + group('LiveChapterListTile', () { + testWidgets('shows title, description and the Live label', (tester) async { + await tester.pumpWidget(storiesTestApp(LiveChapterListTile(chapter: _live))); + expect(find.text('Live Chapter One'), findsOneWidget); + expect(find.text('Live description'), findsOneWidget); + expect(find.text('Live'), findsOneWidget); + }); + }); + + group('CategoryCard', () { + testWidgets('shows the localized category name', (tester) async { + await tester.pumpWidget( + storiesTestApp( + const CategoryCard( + category: StoryCategory.drama, + color: Color(0xff336699), + ), + ), + ); + expect(find.text('Drama'), findsOneWidget); + }); + }); + + group('ChapterListTile', () { + testWidgets('shows title, description and formatted duration', (tester) async { + await tester.pumpWidget(storiesTestApp(ChapterListTile(chapter: _chapter))); + await tester.pump(); + + expect(find.text('My Chapter'), findsOneWidget); + expect(find.text('A chapter description'), findsOneWidget); + expect(find.text('1:05 min'), findsOneWidget); // formatPlayDuration + lengthMinutes + + clearImageLoadErrors(tester); + }); + }); + + group('StoryListTile', () { + testWidgets('shows title, creator and category', (tester) async { + await tester.pumpWidget(storiesTestApp(StoryListTile(story: _story))); + await tester.pump(); + + expect(find.text('Story A'), findsOneWidget); + expect(find.text('Creator Name'), findsOneWidget); + expect(find.text('Drama'), findsOneWidget); + + clearImageLoadErrors(tester); + }); + }); + + group('StoryCard', () { + testWidgets('shows the hashed story title', (tester) async { + await tester.pumpWidget(storiesTestApp(StoryCard(story: _story))); + await tester.pump(); + expect(find.text('# Story A'), findsOneWidget); + clearImageLoadErrors(tester); + }); + }); + + group('FilteredListTile (story)', () { + testWidgets('shows the story title and creator', (tester) async { + await tester.pumpWidget( + storiesTestApp(FilteredListTile(story: _story, isStory: true)), + ); + await tester.pump(); + expect(find.text('Story A'), findsOneWidget); + expect(find.textContaining('Creator Name'), findsOneWidget); + clearImageLoadErrors(tester); + }); + }); + + group('FilteredListTile (user)', () { + testWidgets('shows the username, name and rating', (tester) async { + await tester.pumpWidget( + storiesTestApp(FilteredListTile(user: _user, isStory: false)), + ); + await tester.pump(); + expect(find.text('janedoe'), findsOneWidget); // title + expect(find.textContaining('Test User'), findsOneWidget); // "User: Test User" + expect(find.text('4.5'), findsOneWidget); // rating + expect(find.byIcon(Icons.star), findsOneWidget); + clearImageLoadErrors(tester); + }); + }); + + group('ExplorePage', () { + testWidgets('shows a loader while recommended stories resolve', (tester) async { + final completer = Completer>(); + await pumpStoriesPage( + tester, + const ExplorePage(), + container: ProviderContainer( + overrides: [ + exploreStoriesProvider.overrideWith( + () => _FakeExploreStories(completer.future), + ), + ], + ), + ); + await tester.pump(); + expect(find.byType(LoadingIndicator), findsOneWidget); + + completer.complete(const []); + await tester.pumpAndSettle(); + clearImageLoadErrors(tester); + }); + + testWidgets('renders recommended stories', (tester) async { + await pumpStoriesPage( + tester, + const ExplorePage(), + container: ProviderContainer( + overrides: [ + exploreStoriesProvider.overrideWith( + () => _FakeExploreStories(Future.value([_story])), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Story A'), findsWidgets); + clearImageLoadErrors(tester); + }); + }); + + group('CategoryPage', () { + testWidgets('renders the category stories', (tester) async { + await pumpStoriesPage( + tester, + const CategoryPage(category: StoryCategory.drama), + container: ProviderContainer( + overrides: [ + categoryStoriesProvider(StoryCategory.drama).overrideWith( + () => _FakeCategoryStories(Future.value([_story])), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Story A'), findsOneWidget); + clearImageLoadErrors(tester); + }); + + testWidgets('shows the empty state when there are no stories', (tester) async { + await pumpStoriesPage( + tester, + const CategoryPage(category: StoryCategory.drama), + container: ProviderContainer( + overrides: [ + categoryStoriesProvider(StoryCategory.drama).overrideWith( + () => _FakeCategoryStories(Future.value(const [])), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(StoryListTile), findsNothing); + expect(find.textContaining('Drama'), findsWidgets); + clearImageLoadErrors(tester); + }); + }); + + group('StoryPage', () { + testWidgets('renders the header and chapters from the detail state', ( + tester, + ) async { + await pumpStoriesPage( + tester, + StoryPage(story: _story), + container: ProviderContainer( + overrides: [ + storyDetailProvider(_story.storyId).overrideWith( + () => _FakeStoryDetail( + StoryDetailState( + chapters: [_chapter], + likesCount: 5, + isLikedByCurrentUser: false, + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Story A'), findsOneWidget); // header + expect(find.text('My Chapter'), findsOneWidget); // chapter + clearImageLoadErrors(tester); + }); + }); +} diff --git a/test/features/stories/view/story_format_test.dart b/test/features/stories/view/story_format_test.dart new file mode 100644 index 00000000..9f014cd4 --- /dev/null +++ b/test/features/stories/view/story_format_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/stories/view/story_format.dart'; + +void main() { + group('formatPlayDuration', () { + test('formats a sub-minute duration with zero-padded seconds', () { + expect(formatPlayDuration(5000), '0:05'); + expect(formatPlayDuration(65000), '1:05'); + }); + + test('rounds milliseconds to the nearest second', () { + expect(formatPlayDuration(65400), '1:05'); + expect(formatPlayDuration(65600), '1:06'); + }); + + test('handles zero and multi-minute durations', () { + expect(formatPlayDuration(0), '0:00'); + expect(formatPlayDuration(600000), '10:00'); + }); + }); + + group('capitalizeFirstLetter', () { + test('capitalizes the first character', () { + expect(capitalizeFirstLetter('drama'), 'Drama'); + }); + + test('leaves an already-capitalized string unchanged', () { + expect(capitalizeFirstLetter('Horror'), 'Horror'); + }); + + test('returns an empty string unchanged', () { + expect(capitalizeFirstLetter(''), ''); + }); + }); +} diff --git a/test/features/stories/viewmodel/category_stories_notifier_test.dart b/test/features/stories/viewmodel/category_stories_notifier_test.dart new file mode 100644 index 00000000..eaa0d865 --- /dev/null +++ b/test/features/stories/viewmodel/category_stories_notifier_test.dart @@ -0,0 +1,65 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _storyRow({String id = 's1', String category = 'horror'}) => buildRow( + id: id, + tableId: storyTableId, + databaseId: storyDatabaseId, + data: { + 'title': 'Story $id', + 'description': 'desc', + 'category': category, + 'creatorId': 'creator-1', + 'creatorName': 'Creator', + 'creatorImgUrl': 'https://example.com/a.jpg', + 'coverImgUrl': 'https://example.com/c.jpg', + 'likes': 0, + 'playDuration': 1000, + 'tintColor': 'cbc6c6', + }, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + }); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + storage: storage, + functions: functions, + ); + + test('build returns stories for the requested category', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => + RowList(total: 1, rows: [_storyRow(category: 'horror')])); + + final container = await install(); + final key = categoryStoriesProvider(StoryCategory.horror); + container.listen(key, (_, _) {}); + + final stories = await container.read(key.future); + + expect(stories, hasLength(1)); + expect(stories.first.category, StoryCategory.horror); + }); +} diff --git a/test/features/stories/viewmodel/chapter_player_notifier_test.dart b/test/features/stories/viewmodel/chapter_player_notifier_test.dart new file mode 100644 index 00000000..b110d975 --- /dev/null +++ b/test/features/stories/viewmodel/chapter_player_notifier_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/stories/viewmodel/chapter_player_notifier.dart'; + +void main() { + setUp(TestWidgetsFlutterBinding.ensureInitialized); + + test('build returns the default player state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final key = chapterPlayerProvider('c1'); + container.listen(key, (_, _) {}); + + final state = container.read(key); + + expect(state.sliderProgress, 0.0); + expect(state.isPlaying, isFalse); + }); + + test('onSliderChanged updates the slider progress', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final key = chapterPlayerProvider('c1'); + container.listen(key, (_, _) {}); + + container.read(key.notifier).onSliderChanged(42.0); + + expect(container.read(key).sliderProgress, 42.0); + }); +} diff --git a/test/features/stories/viewmodel/create_story_notifier_test.dart b/test/features/stories/viewmodel/create_story_notifier_test.dart new file mode 100644 index 00000000..525c2227 --- /dev/null +++ b/test/features/stories/viewmodel/create_story_notifier_test.dart @@ -0,0 +1,115 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; +import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _chapterRow({String id = 'c1', int playDuration = 250}) => buildRow( + id: id, + tableId: chapterTableId, + databaseId: storyDatabaseId, + data: { + 'title': 'Chapter $id', + 'description': 'desc', + 'lyrics': '', + 'coverImgUrl': 'https://example.com/cc.jpg', + 'audioFileUrl': 'https://example.com/audio.mp3', + 'playDuration': playDuration, + 'tintColor': 'cbc6c6', + }, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + }); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + storage: storage, + functions: functions, + ); + + test('createStory writes the story row (URL cover, no chapters)', () async { + when(tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 's-new', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: const {}, + )); + + final container = await install(); + + await container.read(createStoryProvider.notifier).createStory( + title: 'My Story', + description: 'desc', + category: StoryCategory.drama, + coverImgRef: 'https://example.com/cover.jpg', + storyPlayDuration: 100, + chapters: const [], + ); + + verify(tables.createRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).called(1); + }); + + test('addChaptersToStory updates the story duration from the DB', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList( + total: 2, + rows: [ + _chapterRow(id: 'c1', playDuration: 200), + _chapterRow(id: 'c2', playDuration: 300), + ], + )); + when(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: const {}, + )); + + final container = await install(); + + await container + .read(createStoryProvider.notifier) + .addChaptersToStory(const [], 's1'); + + verify(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: {'playDuration': 500}, + )).called(1); + }); +} diff --git a/test/features/stories/viewmodel/explore_stories_notifier_test.dart b/test/features/stories/viewmodel/explore_stories_notifier_test.dart new file mode 100644 index 00000000..38315f07 --- /dev/null +++ b/test/features/stories/viewmodel/explore_stories_notifier_test.dart @@ -0,0 +1,80 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _storyRow({String id = 's1', String creatorId = 'creator-1'}) => buildRow( + id: id, + tableId: storyTableId, + databaseId: storyDatabaseId, + data: { + 'title': 'Story $id', + 'description': 'desc', + 'category': 'drama', + 'creatorId': creatorId, + 'creatorName': 'Creator', + 'creatorImgUrl': 'https://example.com/a.jpg', + 'coverImgUrl': 'https://example.com/c.jpg', + 'likes': 0, + 'playDuration': 1000, + 'tintColor': 'cbc6c6', + }, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + }); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + storage: storage, + functions: functions, + ); + + test('build returns recommended stories from the repository', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => + RowList(total: 2, rows: [_storyRow(id: 's1'), _storyRow(id: 's2')])); + + final container = await install(); + final stories = await container.read(exploreStoriesProvider.future); + + expect(stories, hasLength(2)); + expect(stories.first.storyId, 's1'); + }); + + test('refresh re-invokes the repository load', () async { + var count = 0; + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async { + count++; + return RowList(total: 0, rows: []); + }); + + final container = await install(); + await container.read(exploreStoriesProvider.future); + expect(count, 1); + + await container.read(exploreStoriesProvider.notifier).refresh(); + expect(count, 2); + }); +} diff --git a/test/features/stories/viewmodel/live_chapter_notifier_test.dart b/test/features/stories/viewmodel/live_chapter_notifier_test.dart new file mode 100644 index 00000000..428080e7 --- /dev/null +++ b/test/features/stories/viewmodel/live_chapter_notifier_test.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:appwrite/appwrite.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +MockExecution _execution(String body) { + final exec = MockExecution(); + when(exec.responseStatusCode).thenReturn(200); + when(exec.responseBody).thenReturn(body); + return exec; +} + +void main() { + late MockTablesDB tables; + late MockRealtime realtime; + late MockFunctions functions; + late StreamController attendeeEvents; + + setUp(() { + stubFlutterSecureStorageChannel(); + tables = MockTablesDB(); + realtime = MockRealtime(); + functions = MockFunctions(); + attendeeEvents = StreamController.broadcast(); + when(realtime.subscribe(any)).thenReturn( + RealtimeSubscription( + close: () async {}, + channels: const ['attendees'], + controller: attendeeEvents, + ), + ); + }); + + tearDown(() => attendeeEvents.close()); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + realtime: realtime, + functions: functions, + ); + + test('turnOnMic / turnOffMic flip the mic flag', () async { + final container = await install(); + + await container.read(liveChapterProvider.notifier).turnOnMic(); + expect(container.read(liveChapterProvider).isMicOn, isTrue); + + await container.read(liveChapterProvider.notifier).turnOffMic(); + expect(container.read(liveChapterProvider).isMicOn, isFalse); + }); + + test('checkUserIsAdmin is false with no live chapter loaded', () async { + final container = await install(); + expect( + container.read(liveChapterProvider.notifier).checkUserIsAdmin('me'), + isFalse, + ); + expect(container.read(liveChapterProvider.notifier).isAdmin, isFalse); + }); + + test('startLiveChapter creates docs, connects and stores the model', + () async { + when(tables.createRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow(id: 'x', data: const {})); + when(functions.createExecution( + functionId: createLiveChapterRoomFunctionId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution( + '{"livekit_socket_url":"wss://example.com","access_token":"tok"}', + )); + + final container = await install(); + + await container.read(liveChapterProvider.notifier).startLiveChapter( + roomId: 'room-1', + chapterTitle: 'My Live Chapter', + chapterDescription: 'desc', + storyId: 'story-1', + storyName: 'My Story', + ); + + final state = container.read(liveChapterProvider); + expect(state.model, isNotNull); + expect(state.model!.chapterTitle, 'My Live Chapter'); + // The author is admin of their own live chapter. + expect(container.read(liveChapterProvider.notifier).isAdmin, isTrue); + }); + + test('joinLiveChapter adds the current user to the attendees', () async { + when(tables.updateRow( + databaseId: anyNamed('databaseId'), + tableId: anyNamed('tableId'), + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow(id: 'room-1', data: const {})); + when(functions.createExecution( + functionId: joinRoomServiceId, + body: anyNamed('body'), + )).thenAnswer((_) async => _execution( + '{"livekit_socket_url":"wss://example.com","access_token":"jtok"}', + )); + + final container = await install(); + + await container.read(liveChapterProvider.notifier).joinLiveChapter( + 'room-1', + fakeLiveChapterModel( + authorUid: 'author-9', + chapterTitle: 'Joined Chapter', + attendees: fakeLiveChapterAttendees(), + ), + ); + + final state = container.read(liveChapterProvider); + expect(state.model, isNotNull); + expect(state.model!.attendees!.users, hasLength(1)); + expect(state.model!.attendees!.users.first['\$id'], 'me'); + }); +} diff --git a/test/features/stories/viewmodel/story_detail_notifier_test.dart b/test/features/stories/viewmodel/story_detail_notifier_test.dart new file mode 100644 index 00000000..e0c52934 --- /dev/null +++ b/test/features/stories/viewmodel/story_detail_notifier_test.dart @@ -0,0 +1,179 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _chapterRow({String id = 'c1', int playDuration = 250}) => buildRow( + id: id, + tableId: chapterTableId, + databaseId: storyDatabaseId, + data: { + 'title': 'Chapter $id', + 'description': 'desc', + 'lyrics': '', + 'coverImgUrl': 'https://example.com/cc.jpg', + 'audioFileUrl': 'https://example.com/audio.mp3', + 'playDuration': playDuration, + 'tintColor': 'cbc6c6', + }, + ); + +Row _likeRow({String id = 'like-1'}) => buildRow( + id: id, + tableId: likeTableId, + databaseId: storyDatabaseId, + data: {'uId': 'me', 'storyId': 's1'}, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + }); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + storage: storage, + functions: functions, + ); + + // Stubs for StoryDetailNotifier tests + void stubLoad({required bool liked, required int likes}) { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [_chapterRow()])); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => + RowList(total: liked ? 1 : 0, rows: liked ? [_likeRow()] : [])); + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: {'likes': likes}, + )); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: liveChaptersTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + } + + test('build aggregates chapters, likes and like status', () async { + stubLoad(liked: false, likes: 3); + + final container = await install(); + final key = storyDetailProvider('s1'); + container.listen(key, (_, _) {}); + + final state = await container.read(key.future); + + expect(state.chapters, hasLength(1)); + expect(state.likesCount, 3); + expect(state.isLikedByCurrentUser, isFalse); + }); + + test('toggleLike optimistically likes then reconciles with the server', + () async { + stubLoad(liked: false, likes: 3); + + final container = await install(); + final key = storyDetailProvider('s1'); + container.listen(key, (_, _) {}); + await container.read(key.future); + + when(tables.createRow( + databaseId: storyDatabaseId, + tableId: likeTableId, + rowId: anyNamed('rowId'), + data: anyNamed('data'), + )).thenAnswer((_) async => _likeRow()); + when(tables.updateRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + data: anyNamed('data'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: const {}, + )); + when(tables.getRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + queries: anyNamed('queries'), + )).thenAnswer((_) async => buildRow( + id: 's1', + tableId: storyTableId, + databaseId: storyDatabaseId, + data: {'likes': 4}, + )); + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: likeTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [_likeRow()])); + + await container + .read(key.notifier) + .toggleLike(fakeStory(storyId: 's1', likesCount: 3)); + + final state = container.read(key).value!; + expect(state.isLikedByCurrentUser, isTrue); + expect(state.likesCount, 4); + }); + + test('deleteStory routes through the repository delete', () async { + stubLoad(liked: false, likes: 0); + + final container = await install(); + final key = storyDetailProvider('s1'); + container.listen(key, (_, _) {}); + await container.read(key.future); + + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: chapterTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + when(storage.deleteFile( + bucketId: storyBucketId, + fileId: anyNamed('fileId'), + )).thenAnswer((_) async => null); + when(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).thenAnswer((_) async => ''); + + await container.read(key.notifier).deleteStory(fakeStory(storyId: 's1')); + + verify(tables.deleteRow( + databaseId: storyDatabaseId, + tableId: storyTableId, + rowId: 's1', + )).called(1); + }); +} diff --git a/test/features/stories/viewmodel/story_search_notifier_test.dart b/test/features/stories/viewmodel/story_search_notifier_test.dart new file mode 100644 index 00000000..dc661a54 --- /dev/null +++ b/test/features/stories/viewmodel/story_search_notifier_test.dart @@ -0,0 +1,105 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:resonate/features/auth/model/auth_state.dart'; +import 'package:resonate/features/stories/viewmodel/story_search_notifier.dart'; +import 'package:resonate/utils/constants.dart'; + +import '../../../helpers/test_root_container.dart'; +import '../../../helpers/test_root_container.mocks.dart'; + +Row _storyRow({String id = 's1'}) => buildRow( + id: id, + tableId: storyTableId, + databaseId: storyDatabaseId, + data: { + 'title': 'Adventure', + 'description': 'desc', + 'category': 'drama', + 'creatorId': 'creator-1', + 'creatorName': 'Creator', + 'creatorImgUrl': 'https://example.com/a.jpg', + 'coverImgUrl': 'https://example.com/c.jpg', + 'likes': 0, + 'playDuration': 1000, + 'tintColor': 'cbc6c6', + }, + ); + +Row _userRow({String id = 'u1'}) => buildRow( + id: id, + tableId: usersTableID, + databaseId: userDatabaseID, + data: { + 'name': 'Alice', + 'username': 'alice', + 'profileImageUrl': 'https://example.com/u.jpg', + 'ratingCount': 1, + 'ratingTotal': 4, + }, + ); + +void main() { + late MockTablesDB tables; + late MockStorage storage; + late MockFunctions functions; + + setUp(() { + tables = MockTablesDB(); + storage = MockStorage(); + functions = MockFunctions(); + }); + + Future install() => installTestRootContainer( + authState: AuthState.authenticated(fakeAuthUser(uid: 'me')), + tables: tables, + storage: storage, + functions: functions, + ); + + test('search populates stories and users', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [_storyRow()])); + when(tables.listRows( + databaseId: userDatabaseID, + tableId: usersTableID, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [_userRow()])); + + final container = await install(); + container.listen(storySearchProvider, (_, _) {}); + + await container.read(storySearchProvider.notifier).search('adv'); + + final state = container.read(storySearchProvider); + expect(state.stories, hasLength(1)); + expect(state.users, hasLength(1)); + expect(state.users.first.userName, 'alice'); + }); + + test('clear resets the search state', () async { + when(tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 1, rows: [_storyRow()])); + when(tables.listRows( + databaseId: userDatabaseID, + tableId: usersTableID, + queries: anyNamed('queries'), + )).thenAnswer((_) async => RowList(total: 0, rows: [])); + + final container = await install(); + container.listen(storySearchProvider, (_, _) {}); + + await container.read(storySearchProvider.notifier).search('adv'); + expect(container.read(storySearchProvider).stories, isNotEmpty); + + container.read(storySearchProvider.notifier).clear(); + expect(container.read(storySearchProvider).stories, isEmpty); + expect(container.read(storySearchProvider).users, isEmpty); + }); +} diff --git a/test/features/stories/viewmodel/whisper_model_notifier_test.dart b/test/features/stories/viewmodel/whisper_model_notifier_test.dart new file mode 100644 index 00000000..82a8086b --- /dev/null +++ b/test/features/stories/viewmodel/whisper_model_notifier_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/stories/viewmodel/whisper_model_notifier.dart'; +import 'package:whisper_flutter_new/whisper_flutter_new.dart'; + +import '../../../helpers/test_root_container.dart'; + +void main() { + setUp(stubFlutterSecureStorageChannel); + + test('build falls back to the base model when nothing is stored', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final model = await container.read(whisperModelSettingProvider.future); + + expect(model, WhisperModel.base); + }); + + test('setModel updates the current model', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(whisperModelSettingProvider.future); + + final other = WhisperModel.values.firstWhere((m) => m != WhisperModel.base); + await container.read(whisperModelSettingProvider.notifier).setModel(other); + + expect(container.read(whisperModelSettingProvider).value, other); + }); +} diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index 89651f07..59b77c95 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -22,23 +22,25 @@ import 'package:resonate/features/rooms/model/appwrite_upcoming_room.dart'; import 'package:resonate/features/rooms/model/livekit_state.dart'; import 'package:resonate/features/rooms/model/participant.dart'; import 'package:resonate/features/rooms/viewmodel/livekit_notifier.dart'; +import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/model/live_chapter_attendees_model.dart'; +import 'package:resonate/features/stories/model/live_chapter_model.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/models/resonate_user.dart'; import 'package:resonate/utils/enums/room_state.dart'; +import 'package:resonate/utils/enums/story_category.dart'; -// Mocks shared across every notifier/repo test in the suite. Generated to -// `test_root_container.mocks.dart` by `dart run build_runner build`. @GenerateMocks([ Account, TablesDB, + Storage, Realtime, Functions, RealtimeSubscription, FirebaseMessaging, Execution, ]) -// -// ─── Data builders ────────────────────────────────────────────────────────── -// - +// Data builders AuthUser fakeAuthUser({ String uid = '123', String email = 'test@test.com', @@ -50,19 +52,18 @@ AuthUser fakeAuthUser({ bool isEmailVerified = true, double ratingTotal = 5, int ratingCount = 1, -}) => - AuthUser( - uid: uid, - email: email, - displayName: displayName, - userName: userName, - profileImageUrl: profileImageUrl, - profileImageID: profileImageID, - isProfileComplete: isProfileComplete, - isEmailVerified: isEmailVerified, - ratingTotal: ratingTotal, - ratingCount: ratingCount, - ); +}) => AuthUser( + uid: uid, + email: email, + displayName: displayName, + userName: userName, + profileImageUrl: profileImageUrl, + profileImageID: profileImageID, + isProfileComplete: isProfileComplete, + isEmailVerified: isEmailVerified, + ratingTotal: ratingTotal, + ratingCount: ratingCount, +); AppwriteRoom fakeAppwriteRoom({ String id = 'room-1', @@ -74,19 +75,18 @@ AppwriteRoom fakeAppwriteRoom({ bool isUserAdmin = true, List reportedUsers = const [], String? myDocId, -}) => - AppwriteRoom( - id: id, - name: name, - description: description, - totalParticipants: totalParticipants, - tags: tags, - memberAvatarUrls: memberAvatarUrls, - state: RoomState.live, - isUserAdmin: isUserAdmin, - reportedUsers: reportedUsers, - myDocId: myDocId, - ); +}) => AppwriteRoom( + id: id, + name: name, + description: description, + totalParticipants: totalParticipants, + tags: tags, + memberAvatarUrls: memberAvatarUrls, + state: RoomState.live, + isUserAdmin: isUserAdmin, + reportedUsers: reportedUsers, + myDocId: myDocId, +); AppwriteUpcomingRoom fakeUpcomingRoom({ String id = 'upcoming-1', @@ -99,19 +99,18 @@ AppwriteUpcomingRoom fakeUpcomingRoom({ List subscribersAvatarUrls = const [], bool userIsCreator = true, bool hasUserSubscribed = false, -}) => - AppwriteUpcomingRoom( - id: id, - name: name, - isTime: isTime, - scheduledDateTime: scheduledDateTime ?? DateTime.now(), - description: description, - totalSubscriberCount: totalSubscriberCount, - tags: tags, - subscribersAvatarUrls: subscribersAvatarUrls, - userIsCreator: userIsCreator, - hasUserSubscribed: hasUserSubscribed, - ); +}) => AppwriteUpcomingRoom( + id: id, + name: name, + isTime: isTime, + scheduledDateTime: scheduledDateTime ?? DateTime.now(), + description: description, + totalSubscriberCount: totalSubscriberCount, + tags: tags, + subscribersAvatarUrls: subscribersAvatarUrls, + userIsCreator: userIsCreator, + hasUserSubscribed: hasUserSubscribed, +); Participant fakeParticipant({ String uid = 'p-1', @@ -123,18 +122,17 @@ Participant fakeParticipant({ bool isModerator = false, bool isSpeaker = false, bool hasRequestedToBeSpeaker = false, -}) => - Participant( - uid: uid, - email: email, - name: name, - dpUrl: dpUrl, - isAdmin: isAdmin, - isMicOn: isMicOn, - isModerator: isModerator, - isSpeaker: isSpeaker, - hasRequestedToBeSpeaker: hasRequestedToBeSpeaker, - ); +}) => Participant( + uid: uid, + email: email, + name: name, + dpUrl: dpUrl, + isAdmin: isAdmin, + isMicOn: isMicOn, + isModerator: isModerator, + isSpeaker: isSpeaker, + hasRequestedToBeSpeaker: hasRequestedToBeSpeaker, +); FriendsModel fakeFriendsModel({ String senderId = 'sender-1', @@ -152,61 +150,151 @@ FriendsModel fakeFriendsModel({ double? senderRating = 4.0, double? recieverRating = 3.5, String docId = 'friend-doc-1', -}) => - FriendsModel( - senderId: senderId, - recieverId: recieverId, - senderName: senderName, - recieverName: recieverName, - senderUsername: senderUsername, - recieverUsername: recieverUsername, - senderProfileImgUrl: senderProfileImgUrl, - recieverProfileImgUrl: recieverProfileImgUrl, - senderFCMToken: senderFCMToken, - recieverFCMToken: recieverFCMToken, - requestStatus: requestStatus, - requestSentByUserId: requestSentByUserId ?? senderId, - senderRating: senderRating, - recieverRating: recieverRating, - docId: docId, - ); - -/// Stubs the `flutter_secure_storage` method channel so writes/reads succeed -/// in unit tests (the plugin normally calls into native code). Call from a -/// `setUp()` in any test where the code path touches secure storage. +}) => FriendsModel( + senderId: senderId, + recieverId: recieverId, + senderName: senderName, + recieverName: recieverName, + senderUsername: senderUsername, + recieverUsername: recieverUsername, + senderProfileImgUrl: senderProfileImgUrl, + recieverProfileImgUrl: recieverProfileImgUrl, + senderFCMToken: senderFCMToken, + recieverFCMToken: recieverFCMToken, + requestStatus: requestStatus, + requestSentByUserId: requestSentByUserId ?? senderId, + senderRating: senderRating, + recieverRating: recieverRating, + docId: docId, +); + +Story fakeStory({ + String storyId = 'story-1', + String title = 'Test Story', + String description = 'A story for testing', + bool userIsCreator = false, + StoryCategory category = StoryCategory.drama, + String coverImageUrl = 'https://example.com/cover.jpg', + String creatorId = 'creator-1', + String creatorName = 'Creator', + String creatorImgUrl = 'https://example.com/avatar.jpg', + DateTime? creationDate, + int likesCount = 0, + bool isLikedByCurrentUser = false, + int playDuration = 1000, + List chapters = const [], +}) => Story( + storyId: storyId, + title: title, + description: description, + userIsCreator: userIsCreator, + category: category, + coverImageUrl: coverImageUrl, + creatorId: creatorId, + creatorName: creatorName, + creatorImgUrl: creatorImgUrl, + creationDate: creationDate ?? DateTime(2024, 1, 1), + likesCount: likesCount, + isLikedByCurrentUser: isLikedByCurrentUser, + playDuration: playDuration, + tintColor: const Color(0xffcbc6c6), + chapters: chapters, +); + +Chapter fakeChapter({ + String chapterId = 'chapter-1', + String title = 'Chapter One', + String coverImageUrl = 'https://example.com/chapter.jpg', + String description = 'A chapter for testing', + String lyrics = '', + String audioFileUrl = 'https://example.com/audio.mp3', + int playDuration = 500, +}) => Chapter( + chapterId, + title, + coverImageUrl, + description, + lyrics, + audioFileUrl, + playDuration, + const Color(0xffcbc6c6), +); + +LiveChapterAttendeesModel fakeLiveChapterAttendees({ + String liveChapterId = 'room-1', + List> users = const [], + List? userIds = const [], +}) => LiveChapterAttendeesModel( + liveChapterId: liveChapterId, + users: users, + userIds: userIds, +); + +LiveChapterModel fakeLiveChapterModel({ + String id = 'room-1', + String? livekitRoomId, + String authorUid = 'author-1', + String authorProfileImageUrl = 'https://example.com/a.jpg', + String authorName = 'Author', + String chapterTitle = 'Live Chapter', + String chapterDescription = 'Live description', + String storyId = 'story-1', + List followersFCMToken = const [], + LiveChapterAttendeesModel? attendees, +}) => LiveChapterModel( + livekitRoomId: livekitRoomId ?? id, + authorUid: authorUid, + authorProfileImageUrl: authorProfileImageUrl, + authorName: authorName, + chapterTitle: chapterTitle, + chapterDescription: chapterDescription, + storyId: storyId, + followersFCMToken: followersFCMToken, + attendees: attendees, + id: id, +); + +ResonateUser fakeResonateUser({ + String uid = 'user-1', + String userName = 'testuser', + String name = 'Test User', + String profileImageUrl = 'https://example.com/u.jpg', + String? email, + double userRating = 4.5, +}) => ResonateUser( + uid: uid, + userName: userName, + name: name, + profileImageUrl: profileImageUrl, + email: email, + userRating: userRating, +); + void stubFlutterSecureStorageChannel() { TestWidgetsFlutterBinding.ensureInitialized(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( - const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), - (call) async => null, - ); + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); } -/// Convenience builder for fake Appwrite [Row]s in tests. Row buildRow({ required String id, required Map data, String tableId = 'test-table', String databaseId = 'test-db', -}) => - Row( - $id: id, - $sequence: 0, - $tableId: tableId, - $databaseId: databaseId, - $createdAt: DateTime.now().toIso8601String(), - $updatedAt: DateTime.now().toIso8601String(), - $permissions: const [], - data: data, - ); - -// -// ─── In-memory SDK stand-ins ──────────────────────────────────────────────── -// - -/// In-memory implementation of [GetStorage] used by `UpcomingRoomsNotifier` -/// to track which upcoming rooms the user has hidden. +}) => Row( + $id: id, + $sequence: 0, + $tableId: tableId, + $databaseId: databaseId, + $createdAt: DateTime.now().toIso8601String(), + $updatedAt: DateTime.now().toIso8601String(), + $permissions: const [], + data: data, +); + class FakeGetStorage implements GetStorage { final Map _data = {}; @@ -233,11 +321,10 @@ class FakeGetStorage implements GetStorage { @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError( - '${invocation.memberName} not stubbed in FakeGetStorage', - ); + '${invocation.memberName} not stubbed in FakeGetStorage', + ); } -/// Stub LiveKit notifier that never touches the network. class FakeLiveKitNotifier extends LiveKitNotifier { @override LiveKitState build() => const LiveKitState(); @@ -269,7 +356,6 @@ class FakeLiveKitNotifier extends LiveKitNotifier { } } -/// Stub [CallKitService] that never touches the CallKit plugin channels. class FakeCallKitService extends CallKitService { int showIncomingCallCount = 0; int endAllCallsCount = 0; @@ -294,9 +380,6 @@ class FakeCallKitService extends CallKitService { } } -/// Stub auth notifier — used by tests that just need an auth context but -/// don't want to wire up the full Appwrite SDK to make `loadCurrentUser()` -/// return what they want. class _StubAuthNotifier extends AuthNotifier { _StubAuthNotifier(this._initial); final AuthState _initial; @@ -305,10 +388,6 @@ class _StubAuthNotifier extends AuthNotifier { Future build() async => _initial; } -/// Reusable fake [AuthRepository] with counters. Used by the auth view and -/// profile tests where the goal is to drive auth state without spinning up -/// the full Appwrite SDK. Newer tests prefer the real `AuthRepository` with -/// mocked SDK — both patterns are supported. class FakeAuthRepository implements AuthRepository { FakeAuthRepository(this.state); @@ -359,23 +438,10 @@ class FakeAuthRepository implements AuthRepository { @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError( - '${invocation.memberName} not stubbed in FakeAuthRepository', - ); + '${invocation.memberName} not stubbed in FakeAuthRepository', + ); } -// -// ─── installTestRootContainer ─────────────────────────────────────────────── -// - -/// Installs the global `rootContainer` for tests. Pass mocked Appwrite SDK -/// objects via the named params; they back the *real* repositories. -/// -/// Three ways to provide auth context: -/// - [authRepository]: explicit fake repo (legacy auth/profile tests). -/// - [authState]: overrides `authProvider` directly via [_StubAuthNotifier]. -/// - [account]: real `AuthRepository` runs against your mocked `Account`/ -/// `TablesDB`, suitable for tests that want to verify `loadCurrentUser` -/// was called. Future installTestRootContainer({ AuthState? authState, Account? account, @@ -412,7 +478,6 @@ Future installTestRootContainer({ ], ); setRootContainerForTesting(container); - // Warm authProvider when any of the auth-providing inputs is supplied. if (authRepository != null || authState != null || account != null) { await container.read(authProvider.future); } diff --git a/test/helpers/test_root_container.mocks.dart b/test/helpers/test_root_container.mocks.dart index 128e0b80..d223917d 100644 --- a/test/helpers/test_root_container.mocks.dart +++ b/test/helpers/test_root_container.mocks.dart @@ -4,20 +4,23 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'dart:typed_data' as _i12; import 'package:appwrite/appwrite.dart' as _i8; import 'package:appwrite/enums.dart' as _i9; import 'package:appwrite/models.dart' as _i3; import 'package:appwrite/src/client.dart' as _i2; -import 'package:appwrite/src/realtime.dart' as _i10; -import 'package:appwrite/src/realtime_message.dart' as _i11; +import 'package:appwrite/src/input_file.dart' as _i10; +import 'package:appwrite/src/realtime.dart' as _i13; +import 'package:appwrite/src/realtime_message.dart' as _i14; import 'package:appwrite/src/realtime_subscription.dart' as _i4; +import 'package:appwrite/src/upload_progress.dart' as _i11; import 'package:firebase_core/firebase_core.dart' as _i6; -import 'package:firebase_messaging/firebase_messaging.dart' as _i12; +import 'package:firebase_messaging/firebase_messaging.dart' as _i15; import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart' as _i7; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i13; +import 'package:mockito/src/dummies.dart' as _i16; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -126,36 +129,46 @@ class _FakeRow_17 extends _i1.SmartFake implements _i3.Row { : super(parent, parentInvocation); } -class _FakeRealtimeSubscription_18 extends _i1.SmartFake +class _FakeFileList_18 extends _i1.SmartFake implements _i3.FileList { + _FakeFileList_18(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeFile_19 extends _i1.SmartFake implements _i3.File { + _FakeFile_19(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRealtimeSubscription_20 extends _i1.SmartFake implements _i4.RealtimeSubscription { - _FakeRealtimeSubscription_18(Object parent, Invocation parentInvocation) + _FakeRealtimeSubscription_20(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeExecutionList_19 extends _i1.SmartFake implements _i3.ExecutionList { - _FakeExecutionList_19(Object parent, Invocation parentInvocation) +class _FakeExecutionList_21 extends _i1.SmartFake implements _i3.ExecutionList { + _FakeExecutionList_21(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeExecution_20 extends _i1.SmartFake implements _i3.Execution { - _FakeExecution_20(Object parent, Invocation parentInvocation) +class _FakeExecution_22 extends _i1.SmartFake implements _i3.Execution { + _FakeExecution_22(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeStreamController_21 extends _i1.SmartFake +class _FakeStreamController_23 extends _i1.SmartFake implements _i5.StreamController { - _FakeStreamController_21(Object parent, Invocation parentInvocation) + _FakeStreamController_23(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeFirebaseApp_22 extends _i1.SmartFake implements _i6.FirebaseApp { - _FakeFirebaseApp_22(Object parent, Invocation parentInvocation) +class _FakeFirebaseApp_24 extends _i1.SmartFake implements _i6.FirebaseApp { + _FakeFirebaseApp_24(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeNotificationSettings_23 extends _i1.SmartFake +class _FakeNotificationSettings_25 extends _i1.SmartFake implements _i7.NotificationSettings { - _FakeNotificationSettings_23(Object parent, Invocation parentInvocation) + _FakeNotificationSettings_25(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -1504,10 +1517,220 @@ class MockTablesDB extends _i1.Mock implements _i8.TablesDB { as _i5.Future<_i3.Row>); } +/// A class which mocks [Storage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockStorage extends _i1.Mock implements _i8.Storage { + MockStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_0(this, Invocation.getter(#client)), + ) + as _i2.Client); + + @override + _i5.Future<_i3.FileList> listFiles({ + required String? bucketId, + List? queries, + String? search, + bool? total, + }) => + (super.noSuchMethod( + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + returnValue: _i5.Future<_i3.FileList>.value( + _FakeFileList_18( + this, + Invocation.method(#listFiles, [], { + #bucketId: bucketId, + #queries: queries, + #search: search, + #total: total, + }), + ), + ), + ) + as _i5.Future<_i3.FileList>); + + @override + _i5.Future<_i3.File> createFile({ + required String? bucketId, + required String? fileId, + required _i10.InputFile? file, + List? permissions, + dynamic Function(_i11.UploadProgress)? onProgress, + }) => + (super.noSuchMethod( + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + returnValue: _i5.Future<_i3.File>.value( + _FakeFile_19( + this, + Invocation.method(#createFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #file: file, + #permissions: permissions, + #onProgress: onProgress, + }), + ), + ), + ) + as _i5.Future<_i3.File>); + + @override + _i5.Future<_i3.File> getFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i5.Future<_i3.File>.value( + _FakeFile_19( + this, + Invocation.method(#getFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + ), + ), + ) + as _i5.Future<_i3.File>); + + @override + _i5.Future<_i3.File> updateFile({ + required String? bucketId, + required String? fileId, + String? name, + List? permissions, + }) => + (super.noSuchMethod( + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + returnValue: _i5.Future<_i3.File>.value( + _FakeFile_19( + this, + Invocation.method(#updateFile, [], { + #bucketId: bucketId, + #fileId: fileId, + #name: name, + #permissions: permissions, + }), + ), + ), + ) + as _i5.Future<_i3.File>); + + @override + _i5.Future deleteFile({ + required String? bucketId, + required String? fileId, + }) => + (super.noSuchMethod( + Invocation.method(#deleteFile, [], { + #bucketId: bucketId, + #fileId: fileId, + }), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i12.Uint8List> getFileDownload({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileDownload, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i5.Future<_i12.Uint8List>.value(_i12.Uint8List(0)), + ) + as _i5.Future<_i12.Uint8List>); + + @override + _i5.Future<_i12.Uint8List> getFilePreview({ + required String? bucketId, + required String? fileId, + int? width, + int? height, + _i9.ImageGravity? gravity, + int? quality, + int? borderWidth, + String? borderColor, + int? borderRadius, + double? opacity, + int? rotation, + String? background, + _i9.ImageFormat? output, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFilePreview, [], { + #bucketId: bucketId, + #fileId: fileId, + #width: width, + #height: height, + #gravity: gravity, + #quality: quality, + #borderWidth: borderWidth, + #borderColor: borderColor, + #borderRadius: borderRadius, + #opacity: opacity, + #rotation: rotation, + #background: background, + #output: output, + #token: token, + }), + returnValue: _i5.Future<_i12.Uint8List>.value(_i12.Uint8List(0)), + ) + as _i5.Future<_i12.Uint8List>); + + @override + _i5.Future<_i12.Uint8List> getFileView({ + required String? bucketId, + required String? fileId, + String? token, + }) => + (super.noSuchMethod( + Invocation.method(#getFileView, [], { + #bucketId: bucketId, + #fileId: fileId, + #token: token, + }), + returnValue: _i5.Future<_i12.Uint8List>.value(_i12.Uint8List(0)), + ) + as _i5.Future<_i12.Uint8List>); +} + /// A class which mocks [Realtime]. /// /// See the documentation for Mockito's code generation for more information. -class MockRealtime extends _i1.Mock implements _i10.Realtime { +class MockRealtime extends _i1.Mock implements _i13.Realtime { MockRealtime() { _i1.throwOnMissingStub(this); } @@ -1524,7 +1747,7 @@ class MockRealtime extends _i1.Mock implements _i10.Realtime { _i4.RealtimeSubscription subscribe(List? channels) => (super.noSuchMethod( Invocation.method(#subscribe, [channels]), - returnValue: _FakeRealtimeSubscription_18( + returnValue: _FakeRealtimeSubscription_20( this, Invocation.method(#subscribe, [channels]), ), @@ -1561,7 +1784,7 @@ class MockFunctions extends _i1.Mock implements _i8.Functions { #total: total, }), returnValue: _i5.Future<_i3.ExecutionList>.value( - _FakeExecutionList_19( + _FakeExecutionList_21( this, Invocation.method(#listExecutions, [], { #functionId: functionId, @@ -1594,7 +1817,7 @@ class MockFunctions extends _i1.Mock implements _i8.Functions { #scheduledAt: scheduledAt, }), returnValue: _i5.Future<_i3.Execution>.value( - _FakeExecution_20( + _FakeExecution_22( this, Invocation.method(#createExecution, [], { #functionId: functionId, @@ -1621,7 +1844,7 @@ class MockFunctions extends _i1.Mock implements _i8.Functions { #executionId: executionId, }), returnValue: _i5.Future<_i3.Execution>.value( - _FakeExecution_20( + _FakeExecution_22( this, Invocation.method(#getExecution, [], { #functionId: functionId, @@ -1643,23 +1866,23 @@ class MockRealtimeSubscription extends _i1.Mock } @override - _i5.Stream<_i11.RealtimeMessage> get stream => + _i5.Stream<_i14.RealtimeMessage> get stream => (super.noSuchMethod( Invocation.getter(#stream), - returnValue: _i5.Stream<_i11.RealtimeMessage>.empty(), + returnValue: _i5.Stream<_i14.RealtimeMessage>.empty(), ) - as _i5.Stream<_i11.RealtimeMessage>); + as _i5.Stream<_i14.RealtimeMessage>); @override - _i5.StreamController<_i11.RealtimeMessage> get controller => + _i5.StreamController<_i14.RealtimeMessage> get controller => (super.noSuchMethod( Invocation.getter(#controller), - returnValue: _FakeStreamController_21<_i11.RealtimeMessage>( + returnValue: _FakeStreamController_23<_i14.RealtimeMessage>( this, Invocation.getter(#controller), ), ) - as _i5.StreamController<_i11.RealtimeMessage>); + as _i5.StreamController<_i14.RealtimeMessage>); @override List get channels => @@ -1684,7 +1907,7 @@ class MockRealtimeSubscription extends _i1.Mock /// A class which mocks [FirebaseMessaging]. /// /// See the documentation for Mockito's code generation for more information. -class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { +class MockFirebaseMessaging extends _i1.Mock implements _i15.FirebaseMessaging { MockFirebaseMessaging() { _i1.throwOnMissingStub(this); } @@ -1693,7 +1916,7 @@ class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { _i6.FirebaseApp get app => (super.noSuchMethod( Invocation.getter(#app), - returnValue: _FakeFirebaseApp_22(this, Invocation.getter(#app)), + returnValue: _FakeFirebaseApp_24(this, Invocation.getter(#app)), ) as _i6.FirebaseApp); @@ -1773,7 +1996,7 @@ class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { (super.noSuchMethod( Invocation.method(#getNotificationSettings, []), returnValue: _i5.Future<_i7.NotificationSettings>.value( - _FakeNotificationSettings_23( + _FakeNotificationSettings_25( this, Invocation.method(#getNotificationSettings, []), ), @@ -1804,7 +2027,7 @@ class MockFirebaseMessaging extends _i1.Mock implements _i12.FirebaseMessaging { #providesAppNotificationSettings: providesAppNotificationSettings, }), returnValue: _i5.Future<_i7.NotificationSettings>.value( - _FakeNotificationSettings_23( + _FakeNotificationSettings_25( this, Invocation.method(#requestPermission, [], { #alert: alert, @@ -1888,7 +2111,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get $id => (super.noSuchMethod( Invocation.getter(#$id), - returnValue: _i13.dummyValue(this, Invocation.getter(#$id)), + returnValue: _i16.dummyValue(this, Invocation.getter(#$id)), ) as String); @@ -1896,7 +2119,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get $createdAt => (super.noSuchMethod( Invocation.getter(#$createdAt), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#$createdAt), ), @@ -1907,7 +2130,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get $updatedAt => (super.noSuchMethod( Invocation.getter(#$updatedAt), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#$updatedAt), ), @@ -1926,7 +2149,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get functionId => (super.noSuchMethod( Invocation.getter(#functionId), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#functionId), ), @@ -1937,7 +2160,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get deploymentId => (super.noSuchMethod( Invocation.getter(#deploymentId), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#deploymentId), ), @@ -1964,7 +2187,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get requestMethod => (super.noSuchMethod( Invocation.getter(#requestMethod), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#requestMethod), ), @@ -1975,7 +2198,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get requestPath => (super.noSuchMethod( Invocation.getter(#requestPath), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#requestPath), ), @@ -2002,7 +2225,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get responseBody => (super.noSuchMethod( Invocation.getter(#responseBody), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#responseBody), ), @@ -2021,7 +2244,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get logs => (super.noSuchMethod( Invocation.getter(#logs), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#logs), ), @@ -2032,7 +2255,7 @@ class MockExecution extends _i1.Mock implements _i3.Execution { String get errors => (super.noSuchMethod( Invocation.getter(#errors), - returnValue: _i13.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.getter(#errors), ), From e504d94efccf4368e96e247e4e4539f05c6791eb Mon Sep 17 00:00:00 2001 From: Mayank4352 <120477383+Mayank4352@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:09:50 +0530 Subject: [PATCH 12/12] feat: added widgte tests and fixed bugs --- .../data/repositories/profile_repository.dart | 34 +- .../repositories/live_chapter_repository.dart | 2 +- .../data/repositories/stories_repository.dart | 161 +++++---- .../whisper_transcription_service.dart | 2 +- lib/features/stories/model/chapter.dart | 20 +- .../generated/stories_failure.freezed.dart | 62 +--- .../stories/model/stories_failure.dart | 1 - lib/features/stories/model/story.dart | 10 - .../stories/view/pages/add_chapter_page.dart | 5 +- .../stories/view/pages/category_page.dart | 3 +- .../stories/view/pages/chapter_play_page.dart | 4 +- .../view/pages/create_chapter_page.dart | 95 +----- .../stories/view/pages/create_story_page.dart | 95 +----- .../stories/view/pages/explore_page.dart | 47 +-- .../stories/view/pages/live_chapter_page.dart | 9 +- .../stories/view/pages/story_page.dart | 66 ++-- .../pages/verify_chapter_details_page.dart | 103 +----- lib/features/stories/view/story_format.dart | 5 - .../view/widgets/chapter_list_tile.dart | 25 +- .../stories/view/widgets/chapter_player.dart | 19 +- .../view/widgets/cover_image_picker.dart | 73 ++++ .../view/widgets/filtered_list_tile.dart | 16 +- .../stories/view/widgets/like_button.dart | 6 +- .../widgets/live_chapter_attendee_block.dart | 91 ++--- .../view/widgets/live_chapter_list_tile.dart | 12 +- .../view/widgets/secondary_list_card.dart | 24 ++ .../widgets/start_live_chapter_dialog.dart | 4 +- .../stories/view/widgets/story_list_tile.dart | 13 +- .../viewmodel/create_story_notifier.dart | 36 +- .../generated/create_story_notifier.g.dart | 2 +- .../generated/live_chapter_notifier.g.dart | 2 +- .../generated/story_detail_notifier.g.dart | 2 +- .../viewmodel/live_chapter_notifier.dart | 31 +- .../viewmodel/story_detail_notifier.dart | 2 + .../viewmodel/story_search_notifier.dart | 4 +- pubspec.lock | 8 + pubspec.yaml | 1 + .../profile/data/profile_repository_test.dart | 1 - .../viewmodel/profile_view_notifier_test.dart | 1 - .../view/live_chapter_widgets_test.dart | 137 ++++++++ .../stories/view/stories_pages_test.dart | 126 +++++++ .../stories/view/stories_test_helpers.dart | 63 +++- .../stories/view/stories_widgets_test.dart | 313 ++++++------------ .../stories/view/story_format_test.dart | 14 - test/helpers/test_root_container.dart | 18 +- 45 files changed, 882 insertions(+), 886 deletions(-) create mode 100644 lib/features/stories/view/widgets/cover_image_picker.dart create mode 100644 lib/features/stories/view/widgets/secondary_list_card.dart create mode 100644 test/features/stories/view/live_chapter_widgets_test.dart create mode 100644 test/features/stories/view/stories_pages_test.dart diff --git a/lib/features/profile/data/repositories/profile_repository.dart b/lib/features/profile/data/repositories/profile_repository.dart index 12cff57e..338aae7a 100644 --- a/lib/features/profile/data/repositories/profile_repository.dart +++ b/lib/features/profile/data/repositories/profile_repository.dart @@ -17,11 +17,11 @@ part 'generated/profile_repository.g.dart'; @Riverpod(keepAlive: true) ProfileRepository profileRepository(Ref ref) => ProfileRepository( - tables: ref.watch(appwriteTablesProvider), - storage: ref.watch(appwriteStorageProvider), - account: ref.watch(appwriteAccountProvider), - messaging: ref.watch(firebaseMessagingProvider), - ); + tables: ref.watch(appwriteTablesProvider), + storage: ref.watch(appwriteStorageProvider), + account: ref.watch(appwriteAccountProvider), + messaging: ref.watch(firebaseMessagingProvider), +); class ProfileRepository { ProfileRepository({ @@ -29,17 +29,17 @@ class ProfileRepository { required Storage storage, required Account account, required FirebaseMessaging messaging, - }) : _tables = tables, - _storage = storage, - _account = account, - _messaging = messaging; + }) : _tables = tables, + _storage = storage, + _account = account, + _messaging = messaging; final TablesDB _tables; final Storage _storage; final Account _account; final FirebaseMessaging _messaging; -// Profile viewing + // Profile viewing Future> fetchCreatedStories(String creatorId) async { List rows = []; try { @@ -94,7 +94,7 @@ class ProfileRepository { tableId: usersTableID, rowId: userId, queries: [ - Query.select(['*', 'followers.*']) + Query.select(['*', 'followers.*']), ], ); @@ -146,7 +146,6 @@ class ProfileRepository { isLikedByCurrentUser: false, playDuration: value.data['playDuration'], tintColor: tintColor, - chapters: [], ); }).toList(); } @@ -155,8 +154,7 @@ class ProfileRepository { String username, { String? currentUsername, }) async { - if (currentUsername != null && - username.trim() == currentUsername.trim()) { + if (currentUsername != null && username.trim() == currentUsername.trim()) { return true; } try { @@ -225,7 +223,7 @@ class ProfileRepository { ); } -// Profile picture + // Profile picture Future<({String url, String id})> uploadProfileImage({ required String uid, required String email, @@ -275,7 +273,7 @@ class ProfileRepository { ); } -// Onboarding + // Onboarding Future createUserRow({ required String uid, required String name, @@ -304,7 +302,7 @@ class ProfileRepository { await _account.updatePrefs(prefs: {'isUserProfileComplete': true}); } -// Change email + // Change email Future isEmailAvailable(String email) async { final docs = await _tables.listRows( databaseId: userDatabaseID, @@ -346,7 +344,7 @@ class ProfileRepository { ); } -// Delete account + // Delete account Future deleteProfilePicture(String profileImageID) async { try { await _storage.deleteFile( diff --git a/lib/features/stories/data/repositories/live_chapter_repository.dart b/lib/features/stories/data/repositories/live_chapter_repository.dart index 0b6f3c53..3d2f8df3 100644 --- a/lib/features/stories/data/repositories/live_chapter_repository.dart +++ b/lib/features/stories/data/repositories/live_chapter_repository.dart @@ -129,7 +129,7 @@ class LiveChapterRepository { static String attendeesChannel(String roomId) => "databases.$userDatabaseID.tables.$liveChapterAttendeesTableId.rows.$roomId"; - // Stream of attendee-table events for a live chapter. + // Stream of attendee-table events for a live chapter. Stream attendeesStream(String roomId) { final subscription = _realtime.subscribe([attendeesChannel(roomId)]); final controller = StreamController(); diff --git a/lib/features/stories/data/repositories/stories_repository.dart b/lib/features/stories/data/repositories/stories_repository.dart index c6e08fd4..a2bae24e 100644 --- a/lib/features/stories/data/repositories/stories_repository.dart +++ b/lib/features/stories/data/repositories/stories_repository.dart @@ -50,7 +50,7 @@ class StoriesRepository { MeiliSearchIndex get _storyIndex => _meili.index('stories'); MeiliSearchIndex get _userIndex => _meili.index('users'); - // Loaders + // Loaders Future> fetchRecommendedStories(String currentUid) async { try { @@ -78,7 +78,9 @@ class StoriesRepository { ); return _rowsToStories(result.rows, currentUid); } on AppwriteException catch (e) { - log('Failed to fetch stories for category ${category.name}: ${e.message}'); + log( + 'Failed to fetch stories for category ${category.name}: ${e.message}', + ); return []; } } @@ -177,14 +179,14 @@ class StoriesRepository { return result.rows.map((value) { final tintColor = Color(int.parse("0xff${value.data['tintColor']}")); return Chapter( - value.$id, - value.data['title'], - value.data['coverImgUrl'], - value.data['description'], - value.data['lyrics'], - value.data['audioFileUrl'], - value.data['playDuration'], - tintColor, + chapterId: value.$id, + title: value.data['title'], + coverImageUrl: value.data['coverImgUrl'], + description: value.data['description'], + lyrics: value.data['lyrics'], + audioFileUrl: value.data['audioFileUrl'], + playDuration: value.data['playDuration'], + tintColor: tintColor, ); }).toList(); } @@ -206,10 +208,7 @@ class StoriesRepository { databaseId: storyDatabaseId, tableId: likeTableId, queries: [ - Query.and([ - Query.equal('uId', uid), - Query.equal('storyId', storyId), - ]), + Query.and([Query.equal('uId', uid), Query.equal('storyId', storyId)]), ], ); return result.rows.isNotEmpty; @@ -247,56 +246,66 @@ class StoriesRepository { } Future> _searchStories(String query, String currentUid) async { - if (isUsingMeilisearch) { - final result = await _storyIndex.search( - query, - SearchQuery( - attributesToHighlight: ['title', 'creatorName', 'description'], - ), + try { + if (isUsingMeilisearch) { + final result = await _storyIndex.search( + query, + SearchQuery( + attributesToHighlight: ['title', 'creatorName', 'description'], + ), + ); + return _meiliHitsToStories(result.hits, currentUid); + } + + final result = await _tables.listRows( + databaseId: storyDatabaseId, + tableId: storyTableId, + queries: [ + Query.or([ + Query.search('title', query), + Query.search('creatorName', query), + Query.search('description', query), + ]), + Query.limit(16), + ], ); - return _meiliHitsToStories(result.hits, currentUid); + return _rowsToStories(result.rows, currentUid); + } catch (e) { + log('Story search failed: $e'); + return []; } - - final result = await _tables.listRows( - databaseId: storyDatabaseId, - tableId: storyTableId, - queries: [ - Query.or([ - Query.search('title', query), - Query.search('creatorName', query), - Query.search('description', query), - ]), - Query.limit(16), - ], - ); - return _rowsToStories(result.rows, currentUid); } Future> _searchUsers( String query, String currentUid, ) async { - if (isUsingMeilisearch) { - final result = await _userIndex.search( - query, - SearchQuery(attributesToHighlight: ['name', 'username']), + try { + if (isUsingMeilisearch) { + final result = await _userIndex.search( + query, + SearchQuery(attributesToHighlight: ['name', 'username']), + ); + return result.hits.map(_meiliHitToUser).toList(); + } + + final result = await _tables.listRows( + databaseId: userDatabaseID, + tableId: usersTableID, + queries: [ + Query.or([ + Query.search('name', query), + Query.search('username', query), + ]), + Query.notEqual('\$id', currentUid), + Query.limit(16), + ], ); - return result.hits.map(_meiliHitToUser).toList(); + return result.rows.map((doc) => _rowToUser(doc.data, doc.$id)).toList(); + } catch (e) { + log('User search failed: $e'); + return []; } - - final result = await _tables.listRows( - databaseId: userDatabaseID, - tableId: usersTableID, - queries: [ - Query.or([ - Query.search('name', query), - Query.search('username', query), - ]), - Query.notEqual('\$id', currentUid), - Query.limit(16), - ], - ); - return result.rows.map((doc) => _rowToUser(doc.data, doc.$id)).toList(); } // Actions @@ -362,7 +371,7 @@ class StoriesRepository { if (lyricsFilePath.isNotEmpty) { lyrics = await io.File(lyricsFilePath).readAsString(); } - return _buildChapter( + return buildRecordedChapter( chapterId: ID.unique(), title: title, description: description, @@ -379,35 +388,19 @@ class StoriesRepository { required String coverImgPath, required String audioFilePath, required String lyrics, - }) => _buildChapter( - chapterId: chapterId, - title: title, - description: description, - coverImgPath: coverImgPath, - audioFilePath: audioFilePath, - lyrics: lyrics, - ); - - Future _buildChapter({ - required String chapterId, - required String title, - required String description, - required String coverImgPath, - required String audioFilePath, - required String lyrics, }) async { final metadata = readMetadata(io.File(audioFilePath)); final playDuration = metadata.duration?.inMilliseconds ?? 0; final primaryColor = await _tintForCover(coverImgPath); return Chapter( - chapterId, - title, - coverImgPath, - description, - lyrics, - audioFilePath, - playDuration, - primaryColor, + chapterId: chapterId, + title: title, + coverImageUrl: coverImgPath, + description: description, + lyrics: lyrics, + audioFileUrl: audioFilePath, + playDuration: playDuration, + tintColor: primaryColor, ); } @@ -667,7 +660,9 @@ class StoriesRepository { final stories = []; for (final row in rows) { try { - stories.add(_storyFromMap(row.data, row.$id, row.$createdAt, currentUid)); + stories.add( + _storyFromMap(row.data, row.$id, row.$createdAt, currentUid), + ); } catch (e) { // Skiping malformed story rows log('Skipping malformed story row ${row.$id}: $e'); @@ -714,7 +709,6 @@ class StoriesRepository { isLikedByCurrentUser: false, playDuration: data['playDuration'], tintColor: Color(int.parse("0xff${data['tintColor']}")), - chapters: const [], ); } @@ -724,8 +718,9 @@ class StoriesRepository { userData['uid'] = id; userData['userName'] = userData['username']; final ratingCount = (userData['ratingCount'] ?? 0) as num; - userData['userRating'] = - ratingCount == 0 ? 0 : userData['ratingTotal'] / ratingCount; + userData['userRating'] = ratingCount == 0 + ? 0 + : userData['ratingTotal'] / ratingCount; return ResonateUser.fromJson(userData); } diff --git a/lib/features/stories/data/services/whisper_transcription_service.dart b/lib/features/stories/data/services/whisper_transcription_service.dart index 94bc0c5f..31c052cc 100644 --- a/lib/features/stories/data/services/whisper_transcription_service.dart +++ b/lib/features/stories/data/services/whisper_transcription_service.dart @@ -25,7 +25,7 @@ class WhisperTranscriptionService { transcribeRequest: TranscribeRequest( audio: '${storagePath.path}/recordings/$chapterId.wav', isTranslate: true, // Translate result from audio lang to english text - isNoTimestamps: false, + isNoTimestamps: false, ), ); diff --git a/lib/features/stories/model/chapter.dart b/lib/features/stories/model/chapter.dart index b799b1a5..5ad96e23 100644 --- a/lib/features/stories/model/chapter.dart +++ b/lib/features/stories/model/chapter.dart @@ -10,14 +10,14 @@ class Chapter { final int playDuration; final Color tintColor; - const Chapter( - this.chapterId, - this.title, - this.coverImageUrl, - this.description, - this.lyrics, - this.audioFileUrl, - this.playDuration, - this.tintColor, - ); + const Chapter({ + required this.chapterId, + required this.title, + required this.coverImageUrl, + required this.description, + required this.lyrics, + required this.audioFileUrl, + required this.playDuration, + required this.tintColor, + }); } diff --git a/lib/features/stories/model/generated/stories_failure.freezed.dart b/lib/features/stories/model/generated/stories_failure.freezed.dart index 59f15928..f4ad146f 100644 --- a/lib/features/stories/model/generated/stories_failure.freezed.dart +++ b/lib/features/stories/model/generated/stories_failure.freezed.dart @@ -55,12 +55,11 @@ extension StoriesFailurePatterns on StoriesFailure { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( StoriesFailureUpload value)? upload,TResult Function( StoriesFailureNetwork value)? network,TResult Function( StoriesFailureUnknown value)? unknown,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( StoriesFailureUpload value)? upload,TResult Function( StoriesFailureUnknown value)? unknown,required TResult orElse(),}){ final _that = this; switch (_that) { case StoriesFailureUpload() when upload != null: -return upload(_that);case StoriesFailureNetwork() when network != null: -return network(_that);case StoriesFailureUnknown() when unknown != null: +return upload(_that);case StoriesFailureUnknown() when unknown != null: return unknown(_that);case _: return orElse(); @@ -79,12 +78,11 @@ return unknown(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( StoriesFailureUpload value) upload,required TResult Function( StoriesFailureNetwork value) network,required TResult Function( StoriesFailureUnknown value) unknown,}){ +@optionalTypeArgs TResult map({required TResult Function( StoriesFailureUpload value) upload,required TResult Function( StoriesFailureUnknown value) unknown,}){ final _that = this; switch (_that) { case StoriesFailureUpload(): -return upload(_that);case StoriesFailureNetwork(): -return network(_that);case StoriesFailureUnknown(): +return upload(_that);case StoriesFailureUnknown(): return unknown(_that);} } /// A variant of `map` that fallback to returning `null`. @@ -99,12 +97,11 @@ return unknown(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( StoriesFailureUpload value)? upload,TResult? Function( StoriesFailureNetwork value)? network,TResult? Function( StoriesFailureUnknown value)? unknown,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( StoriesFailureUpload value)? upload,TResult? Function( StoriesFailureUnknown value)? unknown,}){ final _that = this; switch (_that) { case StoriesFailureUpload() when upload != null: -return upload(_that);case StoriesFailureNetwork() when network != null: -return network(_that);case StoriesFailureUnknown() when unknown != null: +return upload(_that);case StoriesFailureUnknown() when unknown != null: return unknown(_that);case _: return null; @@ -122,11 +119,10 @@ return unknown(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String what)? upload,TResult Function()? network,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String what)? upload,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; switch (_that) { case StoriesFailureUpload() when upload != null: -return upload(_that.what);case StoriesFailureNetwork() when network != null: -return network();case StoriesFailureUnknown() when unknown != null: +return upload(_that.what);case StoriesFailureUnknown() when unknown != null: return unknown(_that.message);case _: return orElse(); @@ -145,11 +141,10 @@ return unknown(_that.message);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String what) upload,required TResult Function() network,required TResult Function( String message) unknown,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String what) upload,required TResult Function( String message) unknown,}) {final _that = this; switch (_that) { case StoriesFailureUpload(): -return upload(_that.what);case StoriesFailureNetwork(): -return network();case StoriesFailureUnknown(): +return upload(_that.what);case StoriesFailureUnknown(): return unknown(_that.message);} } /// A variant of `when` that fallback to returning `null` @@ -164,11 +159,10 @@ return unknown(_that.message);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String what)? upload,TResult? Function()? network,TResult? Function( String message)? unknown,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String what)? upload,TResult? Function( String message)? unknown,}) {final _that = this; switch (_that) { case StoriesFailureUpload() when upload != null: -return upload(_that.what);case StoriesFailureNetwork() when network != null: -return network();case StoriesFailureUnknown() when unknown != null: +return upload(_that.what);case StoriesFailureUnknown() when unknown != null: return unknown(_that.message);case _: return null; @@ -243,38 +237,6 @@ as String, } -/// @nodoc - - -class StoriesFailureNetwork implements StoriesFailure { - const StoriesFailureNetwork(); - - - - - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is StoriesFailureNetwork); -} - - -@override -int get hashCode => runtimeType.hashCode; - -@override -String toString() { - return 'StoriesFailure.network()'; -} - - -} - - - - /// @nodoc diff --git a/lib/features/stories/model/stories_failure.dart b/lib/features/stories/model/stories_failure.dart index e24f63df..ba1d54dc 100644 --- a/lib/features/stories/model/stories_failure.dart +++ b/lib/features/stories/model/stories_failure.dart @@ -5,6 +5,5 @@ part 'generated/stories_failure.freezed.dart'; @freezed sealed class StoriesFailure with _$StoriesFailure { const factory StoriesFailure.upload(String what) = StoriesFailureUpload; - const factory StoriesFailure.network() = StoriesFailureNetwork; const factory StoriesFailure.unknown(String message) = StoriesFailureUnknown; } diff --git a/lib/features/stories/model/story.dart b/lib/features/stories/model/story.dart index 9e31cd97..c150d25d 100644 --- a/lib/features/stories/model/story.dart +++ b/lib/features/stories/model/story.dart @@ -1,7 +1,5 @@ import 'dart:ui'; -import 'package:resonate/features/stories/model/chapter.dart'; -import 'package:resonate/features/stories/model/live_chapter_model.dart'; import 'package:resonate/utils/enums/story_category.dart'; class Story { @@ -19,8 +17,6 @@ class Story { final bool isLikedByCurrentUser; final int playDuration; final Color tintColor; - final List chapters; - final LiveChapterModel? liveChapter; const Story({ required this.title, @@ -37,16 +33,12 @@ class Story { required this.isLikedByCurrentUser, required this.playDuration, required this.tintColor, - required this.chapters, - this.liveChapter, }); Story copyWith({ int? likesCount, bool? isLikedByCurrentUser, int? playDuration, - List? chapters, - LiveChapterModel? liveChapter, }) => Story( title: title, storyId: storyId, @@ -62,7 +54,5 @@ class Story { isLikedByCurrentUser: isLikedByCurrentUser ?? this.isLikedByCurrentUser, playDuration: playDuration ?? this.playDuration, tintColor: tintColor, - chapters: chapters ?? this.chapters, - liveChapter: liveChapter ?? this.liveChapter, ); } diff --git a/lib/features/stories/view/pages/add_chapter_page.dart b/lib/features/stories/view/pages/add_chapter_page.dart index fb854798..66e26f3e 100644 --- a/lib/features/stories/view/pages/add_chapter_page.dart +++ b/lib/features/stories/view/pages/add_chapter_page.dart @@ -98,10 +98,7 @@ class _AddChapterPageState extends ConsumerState { child: const Icon(Icons.add), ), SizedBox(height: UiSizes.height_20), - ElevatedButton( - onPressed: _submit, - child: Text(l10n.newChapters), - ), + ElevatedButton(onPressed: _submit, child: Text(l10n.newChapters)), ], ), ), diff --git a/lib/features/stories/view/pages/category_page.dart b/lib/features/stories/view/pages/category_page.dart index 437057da..d081c138 100644 --- a/lib/features/stories/view/pages/category_page.dart +++ b/lib/features/stories/view/pages/category_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/features/stories/view/story_format.dart'; import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; @@ -17,7 +16,7 @@ class CategoryPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final storiesAsync = ref.watch(categoryStoriesProvider(category)); - final label = capitalizeFirstLetter(category.name); + final label = AppLocalizations.of(context)!.storyCategory(category.name); final colorScheme = Theme.of(context).colorScheme; return Scaffold( diff --git a/lib/features/stories/view/pages/chapter_play_page.dart b/lib/features/stories/view/pages/chapter_play_page.dart index 6049dcd7..83557858 100644 --- a/lib/features/stories/view/pages/chapter_play_page.dart +++ b/lib/features/stories/view/pages/chapter_play_page.dart @@ -139,7 +139,9 @@ class _ChapterPlayPageState extends ConsumerState { padding: EdgeInsets.all(UiSizes.width_10), decoration: BoxDecoration( color: cardColor, - borderRadius: BorderRadius.circular(UiSizes.width_10), + borderRadius: BorderRadius.circular( + UiSizes.width_10, + ), ), width: double.infinity, child: Column( diff --git a/lib/features/stories/view/pages/create_chapter_page.dart b/lib/features/stories/view/pages/create_chapter_page.dart index 1d1f0134..d38109af 100644 --- a/lib/features/stories/view/pages/create_chapter_page.dart +++ b/lib/features/stories/view/pages/create_chapter_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/view/widgets/cover_image_picker.dart'; import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/constants.dart'; @@ -89,19 +90,7 @@ class _CreateChapterPageState extends ConsumerState { if (titleController.text.isEmpty || aboutController.text.isEmpty || audioFile == null) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.error), - content: Text(l10n.fillAllRequiredFields), - actions: [ - TextButton( - child: Text(l10n.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); + customSnackbar(l10n.error, l10n.fillAllRequiredFields, LogType.error); return; } @@ -111,7 +100,8 @@ class _CreateChapterPageState extends ConsumerState { .buildChapter( title: titleController.text, description: aboutController.text, - coverImgPath: chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, + coverImgPath: + chapterCoverImage?.path ?? chapterCoverImagePlaceholderUrl, audioFilePath: audioFile!.path, lyricsFilePath: lyricsFile?.path ?? '', ); @@ -126,12 +116,7 @@ class _CreateChapterPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( - onTap: () { - final currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: colorScheme.surface, @@ -160,7 +145,11 @@ class _CreateChapterPageState extends ConsumerState { ), ), SizedBox(height: UiSizes.height_20), - _coverPicker(context), + CoverImagePicker( + image: chapterCoverImage, + placeholderUrl: chapterCoverImagePlaceholderUrl, + onTap: _pickCoverImage, + ), SizedBox(height: UiSizes.height_30), _filePicker( context, @@ -189,66 +178,6 @@ class _CreateChapterPageState extends ConsumerState { ); } - Widget _coverPicker(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colorScheme = Theme.of(context).colorScheme; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.all(UiSizes.width_8), - child: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_20), - child: chapterCoverImage != null - ? Image.file( - chapterCoverImage!, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ) - : Image.network( - chapterCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: Padding( - padding: EdgeInsets.all(UiSizes.width_8), - child: GestureDetector( - onTap: _pickCoverImage, - child: Container( - height: UiSizes.height_140, - decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.5), - ), - borderRadius: BorderRadius.circular(UiSizes.width_20), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.change_circle, - size: UiSizes.size_40, - color: colorScheme.onSurface.withValues(alpha: 0.5), - ), - Text(l10n.changeCoverImage, textAlign: TextAlign.center), - ], - ), - ), - ), - ), - ), - ], - ); - } - Widget _filePicker( BuildContext context, { required VoidCallback onTap, @@ -261,9 +190,7 @@ class _CreateChapterPageState extends ConsumerState { width: double.infinity, height: UiSizes.height_50, decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.6), - ), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.6)), borderRadius: BorderRadius.circular(UiSizes.width_8), ), child: Center( diff --git a/lib/features/stories/view/pages/create_story_page.dart b/lib/features/stories/view/pages/create_story_page.dart index a14ecc11..b50b156e 100644 --- a/lib/features/stories/view/pages/create_story_page.dart +++ b/lib/features/stories/view/pages/create_story_page.dart @@ -8,6 +8,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:resonate/features/stories/model/chapter.dart'; import 'package:resonate/features/stories/view/pages/create_chapter_page.dart'; import 'package:resonate/features/stories/view/story_format.dart'; +import 'package:resonate/features/stories/view/widgets/cover_image_picker.dart'; import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/routes/route_paths.dart'; @@ -52,7 +53,11 @@ class _CreateStoryPageState extends ConsumerState { if (titleController.text.isEmpty || aboutController.text.isEmpty || chapters.isEmpty) { - _showError(l10n.fillAllRequiredFieldsAndChapter); + customSnackbar( + l10n.error, + l10n.fillAllRequiredFieldsAndChapter, + LogType.error, + ); return; } @@ -82,23 +87,6 @@ class _CreateStoryPageState extends ConsumerState { router.go(RoutePaths.tabview); } - void _showError(String message) { - final l10n = AppLocalizations.of(context)!; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.error), - content: Text(message), - actions: [ - TextButton( - child: Text(l10n.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -106,12 +94,7 @@ class _CreateStoryPageState extends ConsumerState { final labelStyle = TextStyle(color: colorScheme.onSurfaceVariant); return GestureDetector( - onTap: () { - final currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: colorScheme.surface, @@ -173,7 +156,11 @@ class _CreateStoryPageState extends ConsumerState { ), ), SizedBox(height: UiSizes.height_20), - _coverPicker(context), + CoverImagePicker( + image: coverImage, + placeholderUrl: storyCoverImagePlaceholderUrl, + onTap: _pickCoverImage, + ), SizedBox(height: UiSizes.height_30), ListView.builder( shrinkWrap: true, @@ -250,62 +237,4 @@ class _CreateStoryPageState extends ConsumerState { borderRadius: const BorderRadius.all(Radius.circular(12)), borderSide: BorderSide(color: color), ); - - Widget _coverPicker(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colorScheme = Theme.of(context).colorScheme; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.all(UiSizes.width_8), - child: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_20), - child: coverImage != null - ? Image.file( - coverImage!, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ) - : Image.network( - storyCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: GestureDetector( - onTap: _pickCoverImage, - child: Container( - margin: EdgeInsets.all(UiSizes.width_8), - height: UiSizes.height_140, - decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.5), - ), - borderRadius: BorderRadius.circular(UiSizes.width_20), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.change_circle, - size: UiSizes.size_40, - color: colorScheme.onSurface.withValues(alpha: 0.5), - ), - Text(l10n.changeCoverImage, textAlign: TextAlign.center), - ], - ), - ), - ), - ), - ], - ); - } } diff --git a/lib/features/stories/view/pages/explore_page.dart b/lib/features/stories/view/pages/explore_page.dart index 1dccda8e..aa056188 100644 --- a/lib/features/stories/view/pages/explore_page.dart +++ b/lib/features/stories/view/pages/explore_page.dart @@ -49,12 +49,7 @@ class _ExplorePageState extends ConsumerState { final l10n = AppLocalizations.of(context)!; return GestureDetector( - onTap: () { - final currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Scaffold( backgroundColor: colorScheme.surface, body: SingleChildScrollView( @@ -136,7 +131,10 @@ class _ExplorePageState extends ConsumerState { itemCount: results.stories.length + results.users.length, itemBuilder: (context, index) { if (index < results.stories.length) { - return FilteredListTile(story: results.stories[index], isStory: true); + return FilteredListTile( + story: results.stories[index], + isStory: true, + ); } final user = results.users[index - results.stories.length]; return FilteredListTile(user: user, isStory: false); @@ -220,35 +218,26 @@ class _ExploreContent extends ConsumerWidget { ), ), ), - SizedBox(height: UiSizes.height_35), - Text( - l10n.someSuggestions, - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - fontSize: UiSizes.size_20, - fontFamily: 'Inter', + if (rest.isNotEmpty) ...[ + SizedBox(height: UiSizes.height_35), + Text( + l10n.someSuggestions, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w900, + fontSize: UiSizes.size_20, + fontFamily: 'Inter', + ), ), - ), - SizedBox(height: UiSizes.height_10), - if (rest.isNotEmpty) + SizedBox(height: UiSizes.height_10), ListView.builder( physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, shrinkWrap: true, itemCount: rest.length, - itemBuilder: (context, index) => - StoryListTile(story: rest[index]), - ) - else - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: topCount, - itemBuilder: (context, index) => - StoryListTile(story: stories[index]), + itemBuilder: (context, index) => StoryListTile(story: rest[index]), ), + ], SizedBox(height: UiSizes.height_20), ], ); diff --git a/lib/features/stories/view/pages/live_chapter_page.dart b/lib/features/stories/view/pages/live_chapter_page.dart index 6141dffc..f0a9a358 100644 --- a/lib/features/stories/view/pages/live_chapter_page.dart +++ b/lib/features/stories/view/pages/live_chapter_page.dart @@ -206,14 +206,11 @@ class _MicButton extends ConsumerWidget { final isMicOn = ref.watch(liveChapterProvider).isMicOn; final notifier = ref.read(liveChapterProvider.notifier); return FloatingActionButton( - heroTag: null, + heroTag: null, onPressed: () => isMicOn ? notifier.turnOffMic() : notifier.turnOnMic(), // Mic on/off is a semantic green/red control. backgroundColor: isMicOn ? Colors.lightGreen : Colors.redAccent, - child: Icon( - isMicOn ? Icons.mic : Icons.mic_off, - color: Colors.black, - ), + child: Icon(isMicOn ? Icons.mic : Icons.mic_off, color: Colors.black), ); } } @@ -226,7 +223,7 @@ class _RecordButton extends ConsumerWidget { final l10n = AppLocalizations.of(context)!; final isRecording = ref.watch(liveKitProvider).isRecording; return FloatingActionButton( - heroTag: null, + heroTag: null, onPressed: () { if (isRecording) { customSnackbar( diff --git a/lib/features/stories/view/pages/story_page.dart b/lib/features/stories/view/pages/story_page.dart index 4b902a94..96cd1721 100644 --- a/lib/features/stories/view/pages/story_page.dart +++ b/lib/features/stories/view/pages/story_page.dart @@ -59,9 +59,8 @@ class _StoryPageState extends ConsumerState { ), ), ), - error: (e, _) => Center( - child: Text(AppLocalizations.of(context)!.error), - ), + error: (e, _) => + Center(child: Text(AppLocalizations.of(context)!.error)), data: (detail) => _content(context, story, detail), ), ), @@ -180,13 +179,15 @@ class _StoryPageState extends ConsumerState { right: UiSizes.width_10, bottom: UiSizes.height_15, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_10), - child: Image.network( - story.coverImageUrl, - width: UiSizes.width_111, - height: UiSizes.width_111, - fit: BoxFit.cover, + child: SizedBox( + width: UiSizes.width_111, + height: UiSizes.width_111, + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_10), + child: Image.network( + story.coverImageUrl, + fit: BoxFit.cover, + ), ), ), ), @@ -217,22 +218,31 @@ class _StoryPageState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - '${detail.likesCount} ${l10n.likes}', - style: Theme.of(context).textTheme.bodyLarge! - .copyWith( - fontSize: UiSizes.size_16, - fontFamily: 'Inter', - ), + Flexible( + child: Text( + '${detail.likesCount} ${l10n.likes}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), ), SizedBox(width: UiSizes.width_16), - Text( - '${formatPlayDuration(story.playDuration)} ${l10n.lengthMinutes}', - style: Theme.of(context).textTheme.bodyLarge! - .copyWith( - fontSize: UiSizes.size_16, - fontFamily: 'Inter', - ), + Flexible( + child: Text( + '${formatPlayDuration(story.playDuration)} ${l10n.lengthMinutes}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: UiSizes.size_16, + fontFamily: 'Inter', + ), + ), ), ], ), @@ -255,7 +265,9 @@ class _StoryPageState extends ConsumerState { SizedBox(width: UiSizes.width_8), Expanded( child: Text( - story.userIsCreator ? l10n.you : story.creatorName, + story.userIsCreator + ? l10n.you + : story.creatorName, overflow: TextOverflow.ellipsis, maxLines: 1, style: Theme.of(context).textTheme.bodyLarge! @@ -318,7 +330,9 @@ class _StoryPageState extends ConsumerState { return GestureDetector( onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (_) => ChapterPlayPage(chapter: chapter)), + MaterialPageRoute( + builder: (_) => ChapterPlayPage(chapter: chapter), + ), ), child: ChapterListTile(chapter: chapter), ); diff --git a/lib/features/stories/view/pages/verify_chapter_details_page.dart b/lib/features/stories/view/pages/verify_chapter_details_page.dart index 54577de6..1ee7f1ce 100644 --- a/lib/features/stories/view/pages/verify_chapter_details_page.dart +++ b/lib/features/stories/view/pages/verify_chapter_details_page.dart @@ -5,13 +5,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; +import 'package:resonate/features/stories/view/widgets/cover_image_picker.dart'; import 'package:resonate/features/stories/viewmodel/create_story_notifier.dart'; import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/routes/route_paths.dart'; import 'package:resonate/utils/constants.dart'; +import 'package:resonate/utils/enums/log_type.dart'; import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; class VerifyChapterDetailsPage extends ConsumerStatefulWidget { const VerifyChapterDetailsPage({super.key, required this.lyricsString}); @@ -92,25 +94,13 @@ class _VerifyChapterDetailsPageState aboutController.text.isEmpty || audioFile == null || model == null) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.error), - content: Text(l10n.fillAllRequiredFields), - actions: [ - TextButton( - child: Text(l10n.ok), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); + customSnackbar(l10n.error, l10n.fillAllRequiredFields, LogType.error); return; } final router = GoRouter.of(context); final chapter = await ref - .read(storiesRepositoryProvider) + .read(createStoryProvider.notifier) .buildRecordedChapter( chapterId: model.id, title: titleController.text, @@ -120,9 +110,9 @@ class _VerifyChapterDetailsPageState audioFilePath: audioFile!.path, lyrics: lyricsController.text, ); - await ref - .read(createStoryProvider.notifier) - .addChaptersToStory([chapter], model.storyId); + await ref.read(createStoryProvider.notifier).addChaptersToStory([ + chapter, + ], model.storyId); ref.read(liveChapterProvider.notifier).reset(); router.go(RoutePaths.tabview); @@ -134,12 +124,7 @@ class _VerifyChapterDetailsPageState final colorScheme = Theme.of(context).colorScheme; return GestureDetector( - onTap: () { - final currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: colorScheme.surface, @@ -168,7 +153,11 @@ class _VerifyChapterDetailsPageState ), ), SizedBox(height: UiSizes.height_20), - _coverPicker(context), + CoverImagePicker( + image: chapterCoverImage, + placeholderUrl: chapterCoverImagePlaceholderUrl, + onTap: _pickCoverImage, + ), SizedBox(height: UiSizes.height_30), _infoTile( context, @@ -195,66 +184,6 @@ class _VerifyChapterDetailsPageState ); } - Widget _coverPicker(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colorScheme = Theme.of(context).colorScheme; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.all(UiSizes.width_8), - child: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_20), - child: chapterCoverImage != null - ? Image.file( - chapterCoverImage!, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ) - : Image.network( - chapterCoverImagePlaceholderUrl, - fit: BoxFit.cover, - height: UiSizes.height_140, - width: UiSizes.height_140, - ), - ), - ), - ), - SizedBox(width: UiSizes.width_10), - Expanded( - child: Padding( - padding: EdgeInsets.all(UiSizes.width_8), - child: GestureDetector( - onTap: _pickCoverImage, - child: Container( - height: UiSizes.height_140, - decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.5), - ), - borderRadius: BorderRadius.circular(UiSizes.width_20), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.change_circle, - size: UiSizes.size_40, - color: colorScheme.onSurface.withValues(alpha: 0.5), - ), - Text(l10n.changeCoverImage, textAlign: TextAlign.center), - ], - ), - ), - ), - ), - ), - ], - ); - } - Widget _infoTile( BuildContext context, { required VoidCallback? onTap, @@ -267,9 +196,7 @@ class _VerifyChapterDetailsPageState width: double.infinity, height: UiSizes.height_50, decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.6), - ), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.6)), borderRadius: BorderRadius.circular(UiSizes.width_8), ), child: Center( diff --git a/lib/features/stories/view/story_format.dart b/lib/features/stories/view/story_format.dart index a8881ffc..e69f17cf 100644 --- a/lib/features/stories/view/story_format.dart +++ b/lib/features/stories/view/story_format.dart @@ -4,8 +4,3 @@ String formatPlayDuration(int milliseconds) { final seconds = totalSeconds % 60; return "$minutes:${seconds.toString().padLeft(2, '0')}"; } - -String capitalizeFirstLetter(String input) { - if (input.isEmpty) return input; - return input[0].toUpperCase() + input.substring(1); -} diff --git a/lib/features/stories/view/widgets/chapter_list_tile.dart b/lib/features/stories/view/widgets/chapter_list_tile.dart index 1e0434a7..64e98eb4 100644 --- a/lib/features/stories/view/widgets/chapter_list_tile.dart +++ b/lib/features/stories/view/widgets/chapter_list_tile.dart @@ -3,6 +3,7 @@ import 'package:resonate/features/stories/model/chapter.dart'; import 'package:resonate/features/stories/view/story_format.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/features/stories/view/widgets/secondary_list_card.dart'; class ChapterListTile extends StatelessWidget { const ChapterListTile({super.key, required this.chapter}); @@ -12,28 +13,18 @@ class ChapterListTile extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(UiSizes.width_10), - color: colorScheme.secondary, - ), - margin: EdgeInsets.symmetric( - horizontal: UiSizes.width_16, - vertical: UiSizes.height_8, - ), - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + return SecondaryListCard( child: ListTile( contentPadding: EdgeInsets.symmetric( vertical: UiSizes.height_4, horizontal: UiSizes.width_5, ), - leading: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_10), - child: Image.network( - chapter.coverImageUrl, - width: UiSizes.width_45, - height: UiSizes.width_45, - fit: BoxFit.cover, + leading: SizedBox( + width: UiSizes.width_45, + height: UiSizes.width_45, + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_10), + child: Image.network(chapter.coverImageUrl, fit: BoxFit.cover), ), ), title: Text( diff --git a/lib/features/stories/view/widgets/chapter_player.dart b/lib/features/stories/view/widgets/chapter_player.dart index e02411b5..c682536d 100644 --- a/lib/features/stories/view/widgets/chapter_player.dart +++ b/lib/features/stories/view/widgets/chapter_player.dart @@ -19,7 +19,9 @@ class ChapterPlayerView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final playerState = ref.watch(chapterPlayerProvider(chapter.chapterId)); - final notifier = ref.read(chapterPlayerProvider(chapter.chapterId).notifier); + final notifier = ref.read( + chapterPlayerProvider(chapter.chapterId).notifier, + ); final isDark = Theme.of(context).brightness == Brightness.dark; return Center( @@ -28,8 +30,8 @@ class ChapterPlayerView extends ConsumerWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - chapter.tintColor.withAlpha((progress < 0.75 ? 0.8 : 1) * 255 ~/ 1), - chapter.tintColor.withAlpha((progress < 0.75 ? 0.3 : 1) * 255 ~/ 1), + chapter.tintColor.withValues(alpha: progress < 0.75 ? 0.8 : 1), + chapter.tintColor.withValues(alpha: progress < 0.75 ? 0.3 : 1), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -72,10 +74,10 @@ class ChapterPlayerView extends ConsumerWidget { color: isDark || (ThemeData.estimateBrightnessForColor( - chapter.tintColor, - ) == - Brightness.dark && - progress > 0.75) + chapter.tintColor, + ) == + Brightness.dark && + progress > 0.75) ? Colors.white : Colors.black87, ), @@ -96,7 +98,8 @@ class ChapterPlayerView extends ConsumerWidget { onChanged: notifier.onSliderChanged, onChangeEnd: notifier.onSliderChangeEnd, min: 0, - max: notifier.chapterDuration.inMilliseconds.toDouble() + + max: + notifier.chapterDuration.inMilliseconds.toDouble() + 1000, activeColor: Colors.white, inactiveColor: Colors.grey.shade300, diff --git a/lib/features/stories/view/widgets/cover_image_picker.dart b/lib/features/stories/view/widgets/cover_image_picker.dart new file mode 100644 index 00000000..6eb4d7e2 --- /dev/null +++ b/lib/features/stories/view/widgets/cover_image_picker.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class CoverImagePicker extends StatelessWidget { + const CoverImagePicker({ + super.key, + required this.image, + required this.placeholderUrl, + required this.onTap, + }); + + final File? image; + final String placeholderUrl; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_20), + child: SizedBox( + height: UiSizes.height_140, + width: UiSizes.height_140, + child: image != null + ? Image.file(image!, fit: BoxFit.cover) + : Image.network(placeholderUrl, fit: BoxFit.cover), + ), + ), + ), + ), + SizedBox(width: UiSizes.width_10), + Expanded( + child: Padding( + padding: EdgeInsets.all(UiSizes.width_8), + child: GestureDetector( + onTap: onTap, + child: Container( + height: UiSizes.height_140, + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(UiSizes.width_20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.change_circle, + size: UiSizes.size_40, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + Text(l10n.changeCoverImage, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/stories/view/widgets/filtered_list_tile.dart b/lib/features/stories/view/widgets/filtered_list_tile.dart index ad319869..d99c4bec 100644 --- a/lib/features/stories/view/widgets/filtered_list_tile.dart +++ b/lib/features/stories/view/widgets/filtered_list_tile.dart @@ -6,6 +6,7 @@ import 'package:resonate/features/stories/view/story_format.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/models/resonate_user.dart'; import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/features/stories/view/widgets/secondary_list_card.dart'; class FilteredListTile extends StatelessWidget { final Story? story; @@ -21,7 +22,6 @@ class FilteredListTile extends StatelessWidget { isStory ? (story != null && user == null) : (user != null && story == null), - 'Provide only story when isStory=true, or only user when isStory=false.', ); @override @@ -38,21 +38,13 @@ class FilteredListTile extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (_) => ProfilePage(creator: user, isCreatorProfile: true), + builder: (_) => + ProfilePage(creator: user, isCreatorProfile: true), ), ); } }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(UiSizes.width_10), - color: colorScheme.secondary, - ), - margin: EdgeInsets.symmetric( - horizontal: UiSizes.width_16, - vertical: UiSizes.height_8, - ), - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + child: SecondaryListCard( child: ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( diff --git a/lib/features/stories/view/widgets/like_button.dart b/lib/features/stories/view/widgets/like_button.dart index 3222cf3b..62c680f5 100644 --- a/lib/features/stories/view/widgets/like_button.dart +++ b/lib/features/stories/view/widgets/like_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:resonate/utils/ui_sizes.dart'; - class LikeButton extends StatefulWidget { final Color tintColor; final bool isLikedByUser; @@ -43,10 +42,7 @@ class _LikeButtonState extends State _colorAnimation = ColorTween(begin: Colors.grey, end: widget.tintColor) .animate( - CurvedAnimation( - parent: _controller, - curve: const Interval(0.0, 0.5), - ), + CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.5)), ); if (_isFavorite) { diff --git a/lib/features/stories/view/widgets/live_chapter_attendee_block.dart b/lib/features/stories/view/widgets/live_chapter_attendee_block.dart index 33eb7f71..561b62fc 100644 --- a/lib/features/stories/view/widgets/live_chapter_attendee_block.dart +++ b/lib/features/stories/view/widgets/live_chapter_attendee_block.dart @@ -1,8 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:focused_menu/focused_menu.dart'; -import 'package:focused_menu/modals.dart'; -import 'package:resonate/core/container.dart'; import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; @@ -15,66 +12,46 @@ class LiveChapterAttendeeBlock extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final brightness = Theme.of(context).brightness; final model = ref.watch(liveChapterProvider).model; final isAuthorBlock = model?.authorUid == user['\$id']; - final viewerIsAdmin = model?.authorUid == currentAuthUser?.uid; - - return FocusedMenuHolder( - onPressed: () {}, - menuItemExtent: UiSizes.width_45, - menuWidth: UiSizes.width_200 * 1.05, - menuBoxDecoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(UiSizes.width_5), - border: Border.all(color: colorScheme.primary, width: UiSizes.width_1), + return Container( + padding: EdgeInsets.symmetric( + vertical: UiSizes.height_2, + horizontal: UiSizes.width_2, ), - duration: const Duration(milliseconds: 100), - animateMenuItems: true, - blurBackgroundColor: brightness == Brightness.light - ? Colors.white54 - : Colors.black54, - menuItems: const [], - openWithTap: viewerIsAdmin, - child: Container( - padding: EdgeInsets.symmetric( - vertical: UiSizes.height_2, - horizontal: UiSizes.width_2, - ), - alignment: Alignment.center, - child: Column( - children: [ - CircleAvatar( - radius: UiSizes.size_32, - backgroundColor: colorScheme.primary, - child: CircleAvatar( - backgroundImage: NetworkImage(user['profileImageUrl'] ?? ''), - radius: UiSizes.size_30, - ), + alignment: Alignment.center, + child: Column( + children: [ + CircleAvatar( + radius: UiSizes.size_32, + backgroundColor: colorScheme.primary, + child: CircleAvatar( + backgroundImage: NetworkImage(user['profileImageUrl'] ?? ''), + radius: UiSizes.size_30, ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - (user["name"] ?? '').toString().split(' ').first, - style: TextStyle(fontSize: UiSizes.size_16), - ), - ], - ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + (user["name"] ?? '').toString().split(' ').first, + style: TextStyle(fontSize: UiSizes.size_16), + ), + ], ), - Text( - isAuthorBlock - ? AppLocalizations.of(context)!.author - : AppLocalizations.of(context)!.listener, - style: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: UiSizes.size_14, - ), + ), + Text( + isAuthorBlock + ? AppLocalizations.of(context)!.author + : AppLocalizations.of(context)!.listener, + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: UiSizes.size_14, ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/stories/view/widgets/live_chapter_list_tile.dart b/lib/features/stories/view/widgets/live_chapter_list_tile.dart index 9f228614..86d75059 100644 --- a/lib/features/stories/view/widgets/live_chapter_list_tile.dart +++ b/lib/features/stories/view/widgets/live_chapter_list_tile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:resonate/features/stories/model/live_chapter_model.dart'; import 'package:resonate/l10n/app_localizations.dart'; import 'package:resonate/utils/ui_sizes.dart'; +import 'package:resonate/features/stories/view/widgets/secondary_list_card.dart'; class LiveChapterListTile extends StatelessWidget { const LiveChapterListTile({super.key, required this.chapter}); @@ -11,16 +12,7 @@ class LiveChapterListTile extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(UiSizes.width_10), - color: colorScheme.secondary, - ), - margin: EdgeInsets.symmetric( - horizontal: UiSizes.width_16, - vertical: UiSizes.height_8, - ), - padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + return SecondaryListCard( child: ListTile( contentPadding: EdgeInsets.symmetric( vertical: UiSizes.height_4, diff --git a/lib/features/stories/view/widgets/secondary_list_card.dart b/lib/features/stories/view/widgets/secondary_list_card.dart new file mode 100644 index 00000000..52f51bdc --- /dev/null +++ b/lib/features/stories/view/widgets/secondary_list_card.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:resonate/utils/ui_sizes.dart'; + +class SecondaryListCard extends StatelessWidget { + const SecondaryListCard({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiSizes.width_10), + color: Theme.of(context).colorScheme.secondary, + ), + margin: EdgeInsets.symmetric( + horizontal: UiSizes.width_16, + vertical: UiSizes.height_8, + ), + padding: EdgeInsets.symmetric(horizontal: UiSizes.width_16), + child: child, + ); + } +} diff --git a/lib/features/stories/view/widgets/start_live_chapter_dialog.dart b/lib/features/stories/view/widgets/start_live_chapter_dialog.dart index 5300c591..64ea38d5 100644 --- a/lib/features/stories/view/widgets/start_live_chapter_dialog.dart +++ b/lib/features/stories/view/widgets/start_live_chapter_dialog.dart @@ -61,7 +61,9 @@ class _StartLiveChapterDialogState } catch (e) { if (mounted) setState(() => _isStarting = false); log('startLiveChapter failed: $e'); - final message = e is AppwriteException ? (e.message ?? e.toString()) : e.toString(); + final message = e is AppwriteException + ? (e.message ?? e.toString()) + : e.toString(); customSnackbar(l10n.error, message, LogType.error); return; } diff --git a/lib/features/stories/view/widgets/story_list_tile.dart b/lib/features/stories/view/widgets/story_list_tile.dart index affd0cd7..9fd4588d 100644 --- a/lib/features/stories/view/widgets/story_list_tile.dart +++ b/lib/features/stories/view/widgets/story_list_tile.dart @@ -64,13 +64,12 @@ class StoryListTile extends StatelessWidget { ), ], ), - leading: ClipRRect( - borderRadius: BorderRadius.circular(UiSizes.width_10), - child: Image.network( - story.coverImageUrl, - fit: BoxFit.cover, - height: UiSizes.width_56, - width: UiSizes.width_56, + leading: SizedBox( + width: UiSizes.width_56, + height: UiSizes.width_56, + child: ClipRRect( + borderRadius: BorderRadius.circular(UiSizes.width_10), + child: Image.network(story.coverImageUrl, fit: BoxFit.cover), ), ), trailing: Icon( diff --git a/lib/features/stories/viewmodel/create_story_notifier.dart b/lib/features/stories/viewmodel/create_story_notifier.dart index 5cc02d26..a9f095f8 100644 --- a/lib/features/stories/viewmodel/create_story_notifier.dart +++ b/lib/features/stories/viewmodel/create_story_notifier.dart @@ -1,6 +1,7 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; import 'package:resonate/features/stories/model/chapter.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; import 'package:resonate/utils/enums/story_category.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -18,13 +19,33 @@ class CreateStory extends _$CreateStory { required String coverImgPath, required String audioFilePath, required String lyricsFilePath, - }) => ref.read(storiesRepositoryProvider).buildChapterFromFiles( - title: title, - description: description, - coverImgPath: coverImgPath, - audioFilePath: audioFilePath, - lyricsFilePath: lyricsFilePath, - ); + }) => ref + .read(storiesRepositoryProvider) + .buildChapterFromFiles( + title: title, + description: description, + coverImgPath: coverImgPath, + audioFilePath: audioFilePath, + lyricsFilePath: lyricsFilePath, + ); + + Future buildRecordedChapter({ + required String chapterId, + required String title, + required String description, + required String coverImgPath, + required String audioFilePath, + required String lyrics, + }) => ref + .read(storiesRepositoryProvider) + .buildRecordedChapter( + chapterId: chapterId, + title: title, + description: description, + coverImgPath: coverImgPath, + audioFilePath: audioFilePath, + lyrics: lyrics, + ); Future createStory({ required String title, @@ -46,6 +67,7 @@ class CreateStory extends _$CreateStory { chapters: chapters, ); ref.invalidate(exploreStoriesProvider); + ref.invalidate(categoryStoriesProvider(category)); } Future addChaptersToStory( diff --git a/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart b/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart index ce8e3744..c11a8863 100644 --- a/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart +++ b/lib/features/stories/viewmodel/generated/create_story_notifier.g.dart @@ -40,7 +40,7 @@ final class CreateStoryProvider extends $NotifierProvider { } } -String _$createStoryHash() => r'75e19e0e0eb00672e88d369844517c267b6a752c'; +String _$createStoryHash() => r'3b09a2e3bdf7f511aea551a63321649ac63a7556'; abstract class _$CreateStory extends $Notifier { void build(); diff --git a/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart index fbb90940..8f2c2430 100644 --- a/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart +++ b/lib/features/stories/viewmodel/generated/live_chapter_notifier.g.dart @@ -41,7 +41,7 @@ final class LiveChapterProvider } } -String _$liveChapterHash() => r'e3e38a3fa75b5b026cc9513adb59b39e96533d08'; +String _$liveChapterHash() => r'959ba1119f40472cbedbbeeeae53ffd0d011a965'; abstract class _$LiveChapter extends $Notifier { LiveChapterState build(); diff --git a/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart b/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart index f6cdf7c1..41c880af 100644 --- a/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart +++ b/lib/features/stories/viewmodel/generated/story_detail_notifier.g.dart @@ -50,7 +50,7 @@ final class StoryDetailProvider } } -String _$storyDetailHash() => r'cc34f136856e95bf25bcc782aa332c6737f80025'; +String _$storyDetailHash() => r'7d20b85644dd1fa675a32140422086200adf3cc4'; final class StoryDetailFamily extends $Family with diff --git a/lib/features/stories/viewmodel/live_chapter_notifier.dart b/lib/features/stories/viewmodel/live_chapter_notifier.dart index 102fc90c..b54c81f6 100644 --- a/lib/features/stories/viewmodel/live_chapter_notifier.dart +++ b/lib/features/stories/viewmodel/live_chapter_notifier.dart @@ -15,7 +15,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'generated/live_chapter_notifier.g.dart'; - @Riverpod(keepAlive: true) class LiveChapter extends _$LiveChapter { StreamSubscription? _attendeesSub; @@ -96,10 +95,7 @@ class LiveChapter extends _$LiveChapter { final user = requireCurrentAuthUser; final attendees = data.attendees!; final newAttendees = attendees.copyWith( - userIds: [ - ...attendees.users.map((e) => e["\$id"] as String), - user.uid, - ], + userIds: [...attendees.users.map((e) => e["\$id"] as String), user.uid], users: [ ...attendees.users, { @@ -114,7 +110,10 @@ class LiveChapter extends _$LiveChapter { await repo.updateAttendees(roomId, newAttendees); state = state.copyWith(model: data.copyWith(attendees: newAttendees)); - final join = await repo.joinLiveChapterRoom(roomId: roomId, userId: user.uid); + final join = await repo.joinLiveChapterRoom( + roomId: roomId, + userId: user.uid, + ); final connected = await ref .read(liveKitProvider.notifier) .connect( @@ -170,24 +169,22 @@ class LiveChapter extends _$LiveChapter { await _attendeesSub?.cancel(); final attendees = model.attendees!; + final remainingUsers = attendees.users + .where((element) => element["\$id"] != user.uid) + .toList(); final updated = attendees.copyWith( - users: attendees.users - .where((element) => element["\$id"] != user.uid) - .toList(), - userIds: (attendees.userIds ?? []) - .where((element) => element != user.uid) - .toList(), - ); - await ref.read(liveChapterRepositoryProvider).updateAttendees( - model.id, - updated, + users: remainingUsers, + userIds: remainingUsers.map((e) => e["\$id"] as String).toList(), ); + await ref + .read(liveChapterRepositoryProvider) + .updateAttendees(model.id, updated); await ref.read(liveKitProvider.notifier).disconnect(); state = const LiveChapterState(); appRouter.go(RoutePaths.tabview); } - // Ends the chapter + // Ends the chapter Future endLiveChapter() async { final model = state.model; if (model == null) return ''; diff --git a/lib/features/stories/viewmodel/story_detail_notifier.dart b/lib/features/stories/viewmodel/story_detail_notifier.dart index d250fe94..749b60c3 100644 --- a/lib/features/stories/viewmodel/story_detail_notifier.dart +++ b/lib/features/stories/viewmodel/story_detail_notifier.dart @@ -2,6 +2,7 @@ import 'package:resonate/core/container.dart'; import 'package:resonate/features/stories/data/repositories/stories_repository.dart'; import 'package:resonate/features/stories/model/story.dart'; import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -52,5 +53,6 @@ class StoryDetail extends _$StoryDetail { Future deleteStory(Story story) async { await ref.read(storiesRepositoryProvider).deleteStory(story); ref.invalidate(exploreStoriesProvider); + ref.invalidate(categoryStoriesProvider(story.category)); } } diff --git a/lib/features/stories/viewmodel/story_search_notifier.dart b/lib/features/stories/viewmodel/story_search_notifier.dart index a352ce3e..31d66744 100644 --- a/lib/features/stories/viewmodel/story_search_notifier.dart +++ b/lib/features/stories/viewmodel/story_search_notifier.dart @@ -13,7 +13,9 @@ class StorySearch extends _$StorySearch { Future search(String query) async { final uid = requireCurrentAuthUser.uid; - final results = await ref.read(storiesRepositoryProvider).search(query, uid); + final results = await ref + .read(storiesRepositoryProvider) + .search(query, uid); if (ref.mounted) state = results; } diff --git a/pubspec.lock b/pubspec.lock index 0e97c46a..d5dfb6a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1349,6 +1349,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + network_image_mock: + dependency: "direct dev" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" nm: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e2b2dec6..4dcae873 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -115,6 +115,7 @@ dev_dependencies: freezed: ^3.2.3 json_serializable: ^6.11.1 mockito: ^5.5.1 + network_image_mock: ^2.1.1 riverpod_generator: ^4.0.0 riverpod_lint: ^3.1.0 diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 91f04085..c964bf46 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -137,7 +137,6 @@ void main() { expect(stories[0].likesCount, 10); expect(stories[0].tintColor, const Color(0xff0000FF)); expect(stories[0].userIsCreator, false); - expect(stories[0].chapters, isEmpty); }); }); diff --git a/test/features/profile/viewmodel/profile_view_notifier_test.dart b/test/features/profile/viewmodel/profile_view_notifier_test.dart index 1d9d8ab8..8ff74706 100644 --- a/test/features/profile/viewmodel/profile_view_notifier_test.dart +++ b/test/features/profile/viewmodel/profile_view_notifier_test.dart @@ -29,7 +29,6 @@ Story _story(String id) => Story( isLikedByCurrentUser: false, playDuration: 60, tintColor: const Color(0xff0000FF), - chapters: const [], ); FollowerUserModel _follower({required String uid, required String docId}) => diff --git a/test/features/stories/view/live_chapter_widgets_test.dart b/test/features/stories/view/live_chapter_widgets_test.dart new file mode 100644 index 00000000..c1e6150d --- /dev/null +++ b/test/features/stories/view/live_chapter_widgets_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonate/features/stories/model/live_chapter_state.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_attendee_block.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_header.dart'; +import 'package:resonate/features/stories/view/widgets/live_chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/start_live_chapter_dialog.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; + +import 'stories_test_helpers.dart'; + +void main() { + group('LiveChapterHeader', () { + testStoryWidget('shows the chapter name and description', (tester) async { + await tester.pumpWidget( + storiesTestApp( + const LiveChapterHeader( + chapterName: 'Header Title', + chapterDescription: 'Header description', + ), + ), + ); + expect(find.text('Header Title'), findsOneWidget); + expect(find.text('Header description'), findsOneWidget); + }); + }); + + group('LiveChapterListTile', () { + testStoryWidget('shows title, description and the Live label', ( + tester, + ) async { + final live = fakeLiveChapterModel(chapterTitle: 'Live Chapter One'); + await tester.pumpWidget( + storiesTestApp(LiveChapterListTile(chapter: live)), + ); + expect(find.text('Live Chapter One'), findsOneWidget); + expect(find.text('Live description'), findsOneWidget); // default + expect(find.text('Live'), findsOneWidget); + }); + }); + + group('LiveChapterAttendeeBlock', () { + Future pumpBlock( + WidgetTester tester, { + required String authorUid, + required Map user, + }) async { + final container = ProviderContainer( + overrides: [ + liveChapterProvider.overrideWith( + () => FakeLiveChapter( + LiveChapterState( + model: fakeLiveChapterModel(authorUid: authorUid), + ), + ), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: storiesTestApp(LiveChapterAttendeeBlock(user: user)), + ), + ); + await tester.pumpAndSettle(); + } + + testStoryWidget('labels the author and shows their first name', ( + tester, + ) async { + await pumpBlock( + tester, + authorUid: 'u1', + user: { + '\$id': 'u1', + 'name': 'Alice Smith', + 'profileImageUrl': 'http://example.com/a.png', + }, + ); + expect(find.text('Alice'), findsOneWidget); // first word of the name + expect(find.text('Author'), findsOneWidget); + expect(find.text('Listener'), findsNothing); + }); + + testStoryWidget('labels a non-author as a listener', (tester) async { + await pumpBlock( + tester, + authorUid: 'someone-else', + user: { + '\$id': 'u2', + 'name': 'Bob Jones', + 'profileImageUrl': 'http://example.com/b.png', + }, + ); + expect(find.text('Listener'), findsOneWidget); + expect(find.text('Author'), findsNothing); + }); + }); + + group('StartLiveChapterDialog', () { + testStoryWidget('renders the title, two fields and the actions', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: storiesTestApp(StartLiveChapterDialog(story: fakeStory())), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Start a Live Chapter'), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(2)); + expect(find.text('Start'), findsOneWidget); + }); + + testStoryWidget('Start with empty fields does not begin loading', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: storiesTestApp(StartLiveChapterDialog(story: fakeStory())), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); +} diff --git a/test/features/stories/view/stories_pages_test.dart b/test/features/stories/view/stories_pages_test.dart new file mode 100644 index 00000000..95682acc --- /dev/null +++ b/test/features/stories/view/stories_pages_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/view/pages/category_page.dart'; +import 'package:resonate/features/stories/view/pages/explore_page.dart'; +import 'package:resonate/features/stories/view/pages/story_page.dart'; +import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; +import 'package:resonate/utils/enums/story_category.dart'; + +import 'stories_test_helpers.dart'; + +void main() { + group('ExplorePage', () { + testStoryWidget('shows a loader while recommended stories resolve', ( + tester, + ) async { + final completer = Completer>(); + await pumpStoriesPage( + tester, + const ExplorePage(), + container: ProviderContainer( + overrides: [ + exploreStoriesProvider.overrideWith( + () => FakeExploreStories(completer.future), + ), + ], + ), + ); + await tester.pump(); + expect(find.byType(LoadingIndicator), findsOneWidget); + + completer.complete(const []); + await tester.pumpAndSettle(); + }); + + testStoryWidget('renders recommended stories', (tester) async { + await pumpStoriesPage( + tester, + const ExplorePage(), + container: ProviderContainer( + overrides: [ + exploreStoriesProvider.overrideWith( + () => + FakeExploreStories(Future.value([fakeStory(title: 'Story A')])), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Story A'), findsWidgets); + }); + }); + + group('CategoryPage', () { + testStoryWidget('renders the category stories', (tester) async { + await pumpStoriesPage( + tester, + const CategoryPage(category: StoryCategory.drama), + container: ProviderContainer( + overrides: [ + categoryStoriesProvider(StoryCategory.drama).overrideWith( + () => + FakeCategoryStories(Future.value([fakeStory(title: 'Story A')])), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Story A'), findsOneWidget); + }); + + testStoryWidget('shows the empty state when there are no stories', ( + tester, + ) async { + await pumpStoriesPage( + tester, + const CategoryPage(category: StoryCategory.drama), + container: ProviderContainer( + overrides: [ + categoryStoriesProvider( + StoryCategory.drama, + ).overrideWith(() => FakeCategoryStories(Future.value(const []))), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(StoryListTile), findsNothing); + expect(find.textContaining('Drama'), findsWidgets); + }); + }); + + group('StoryPage', () { + testStoryWidget('renders the header and chapters from the detail state', ( + tester, + ) async { + final story = fakeStory(title: 'Story A', storyId: 's1'); + await pumpStoriesPage( + tester, + StoryPage(story: story), + container: ProviderContainer( + overrides: [ + storyDetailProvider(story.storyId).overrideWith( + () => FakeStoryDetail( + StoryDetailState( + chapters: [fakeChapter(title: 'My Chapter')], + likesCount: 5, + isLikedByCurrentUser: false, + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Story A'), findsOneWidget); // header + expect(find.text('My Chapter'), findsOneWidget); // chapter + }); + }); +} diff --git a/test/features/stories/view/stories_test_helpers.dart b/test/features/stories/view/stories_test_helpers.dart index 3db87c96..58765eee 100644 --- a/test/features/stories/view/stories_test_helpers.dart +++ b/test/features/stories/view/stories_test_helpers.dart @@ -2,9 +2,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:resonate/features/stories/model/chapter_player_state.dart'; +import 'package:resonate/features/stories/model/live_chapter_state.dart'; +import 'package:resonate/features/stories/model/story.dart'; +import 'package:resonate/features/stories/model/story_detail_state.dart'; +import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/chapter_player_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/live_chapter_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; import 'package:resonate/l10n/app_localizations.dart'; +import 'package:resonate/utils/enums/story_category.dart'; import 'package:resonate/utils/ui_sizes.dart'; +export '../../../helpers/test_root_container.dart'; + Widget storiesTestApp(Widget child) { return MaterialApp( localizationsDelegates: const [ @@ -23,11 +36,6 @@ Widget storiesTestApp(Widget child) { ); } -/// Image.network can't load under flutter_test -void clearImageLoadErrors(WidgetTester tester) { - while (tester.takeException() != null) {} -} - Future pumpStoriesPage( WidgetTester tester, Widget child, { @@ -45,3 +53,48 @@ Future pumpStoriesPage( ), ); } + +void testStoryWidget( + String description, + Future Function(WidgetTester tester) body, +) { + testWidgets( + description, + (tester) => mockNetworkImagesFor(() => body(tester)), + ); +} + +class FakeExploreStories extends ExploreStories { + FakeExploreStories(this._future); + final Future> _future; + @override + Future> build() => _future; +} + +class FakeCategoryStories extends CategoryStories { + FakeCategoryStories(this._future); + final Future> _future; + @override + Future> build(StoryCategory category) => _future; +} + +class FakeStoryDetail extends StoryDetail { + FakeStoryDetail(this._state); + final StoryDetailState _state; + @override + Future build(String storyId) async => _state; +} + +class FakeLiveChapter extends LiveChapter { + FakeLiveChapter(this._state); + final LiveChapterState _state; + @override + LiveChapterState build() => _state; +} + +class FakeChapterPlayer extends ChapterPlayer { + FakeChapterPlayer(this._state); + final ChapterPlayerState _state; + @override + ChapterPlayerState build(String chapterId) => _state; +} diff --git a/test/features/stories/view/stories_widgets_test.dart b/test/features/stories/view/stories_widgets_test.dart index a575cf22..ffa724d5 100644 --- a/test/features/stories/view/stories_widgets_test.dart +++ b/test/features/stories/view/stories_widgets_test.dart @@ -1,93 +1,24 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:loading_indicator/loading_indicator.dart'; -import 'package:resonate/features/stories/model/story.dart'; -import 'package:resonate/features/stories/model/story_detail_state.dart'; -import 'package:resonate/features/stories/view/pages/category_page.dart'; -import 'package:resonate/features/stories/view/pages/explore_page.dart'; -import 'package:resonate/features/stories/view/pages/story_page.dart'; +import 'package:resonate/features/stories/model/chapter_player_state.dart'; import 'package:resonate/features/stories/view/widgets/category_card.dart'; import 'package:resonate/features/stories/view/widgets/chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/chapter_player.dart'; +import 'package:resonate/features/stories/view/widgets/cover_image_picker.dart'; import 'package:resonate/features/stories/view/widgets/filtered_list_tile.dart'; import 'package:resonate/features/stories/view/widgets/like_button.dart'; -import 'package:resonate/features/stories/view/widgets/live_chapter_header.dart'; -import 'package:resonate/features/stories/view/widgets/live_chapter_list_tile.dart'; +import 'package:resonate/features/stories/view/widgets/secondary_list_card.dart'; import 'package:resonate/features/stories/view/widgets/story_card.dart'; import 'package:resonate/features/stories/view/widgets/story_list_tile.dart'; -import 'package:resonate/features/stories/viewmodel/category_stories_notifier.dart'; -import 'package:resonate/features/stories/viewmodel/explore_stories_notifier.dart'; -import 'package:resonate/features/stories/viewmodel/story_detail_notifier.dart'; +import 'package:resonate/features/stories/viewmodel/chapter_player_notifier.dart'; import 'package:resonate/utils/enums/story_category.dart'; -import '../../../helpers/test_root_container.dart'; import 'stories_test_helpers.dart'; -// Fake notifiers -class _FakeExploreStories extends ExploreStories { - _FakeExploreStories(this._future); - final Future> _future; - @override - Future> build() => _future; -} - -class _FakeCategoryStories extends CategoryStories { - _FakeCategoryStories(this._future); - final Future> _future; - @override - Future> build(StoryCategory category) => _future; -} - -class _FakeStoryDetail extends StoryDetail { - _FakeStoryDetail(this._state); - final StoryDetailState _state; - @override - Future build(String storyId) async => _state; -} - -final _chapter = fakeChapter( - chapterId: 'c1', - title: 'My Chapter', - coverImageUrl: 'http://example.com/cover.png', - description: 'A chapter description', - audioFileUrl: 'http://example.com/audio.mp3', - playDuration: 65000, // 1:05 -); - -final _live = fakeLiveChapterModel( - id: 'r1', - authorUid: 'u1', - authorProfileImageUrl: '', - authorName: 'Author Name', - chapterTitle: 'Live Chapter One', - // chapterDescription defaults to Live description -); - -final _user = fakeResonateUser( - uid: 'u9', - userName: 'janedoe', - name: 'Jane Doe', - profileImageUrl: 'http://example.com/u.png', - userRating: 4.5, -); - -final _story = fakeStory( - title: 'Story A', - storyId: 's1', - description: 'story description', - coverImageUrl: 'http://example.com/c.png', - creatorId: 'u1', - creatorName: 'Creator Name', - creatorImgUrl: 'http://example.com/p.png', - likesCount: 3, - playDuration: 65000, -); - void main() { group('LikeButton', () { - testWidgets('renders the outline icon when not liked', (tester) async { + testStoryWidget('renders the outline icon when not liked', (tester) async { await tester.pumpWidget( storiesTestApp( LikeButton( @@ -102,7 +33,9 @@ void main() { expect(find.byIcon(Icons.favorite), findsNothing); }); - testWidgets('renders the filled icon when already liked', (tester) async { + testStoryWidget('renders the filled icon when already liked', ( + tester, + ) async { await tester.pumpWidget( storiesTestApp( LikeButton( @@ -116,7 +49,7 @@ void main() { expect(find.byIcon(Icons.favorite), findsOneWidget); }); - testWidgets('tapping toggles to liked and reports true', (tester) async { + testStoryWidget('tapping toggles to liked and reports true', (tester) async { bool? reported; await tester.pumpWidget( storiesTestApp( @@ -137,32 +70,8 @@ void main() { }); }); - group('LiveChapterHeader', () { - testWidgets('shows the chapter name and description', (tester) async { - await tester.pumpWidget( - storiesTestApp( - const LiveChapterHeader( - chapterName: 'Header Title', - chapterDescription: 'Header description', - ), - ), - ); - expect(find.text('Header Title'), findsOneWidget); - expect(find.text('Header description'), findsOneWidget); - }); - }); - - group('LiveChapterListTile', () { - testWidgets('shows title, description and the Live label', (tester) async { - await tester.pumpWidget(storiesTestApp(LiveChapterListTile(chapter: _live))); - expect(find.text('Live Chapter One'), findsOneWidget); - expect(find.text('Live description'), findsOneWidget); - expect(find.text('Live'), findsOneWidget); - }); - }); - group('CategoryCard', () { - testWidgets('shows the localized category name', (tester) async { + testStoryWidget('shows the localized category name', (tester) async { await tester.pumpWidget( storiesTestApp( const CategoryCard( @@ -176,168 +85,156 @@ void main() { }); group('ChapterListTile', () { - testWidgets('shows title, description and formatted duration', (tester) async { - await tester.pumpWidget(storiesTestApp(ChapterListTile(chapter: _chapter))); + testStoryWidget('shows title, description and formatted duration', ( + tester, + ) async { + final chapter = fakeChapter( + title: 'My Chapter', + description: 'A chapter description', + coverImageUrl: 'http://example.com/cover.png', + playDuration: 65000, // 1:05 + ); + await tester.pumpWidget(storiesTestApp(ChapterListTile(chapter: chapter))); await tester.pump(); expect(find.text('My Chapter'), findsOneWidget); expect(find.text('A chapter description'), findsOneWidget); - expect(find.text('1:05 min'), findsOneWidget); // formatPlayDuration + lengthMinutes - - clearImageLoadErrors(tester); + expect(find.text('1:05 min'), findsOneWidget); }); }); group('StoryListTile', () { - testWidgets('shows title, creator and category', (tester) async { - await tester.pumpWidget(storiesTestApp(StoryListTile(story: _story))); + testStoryWidget('shows title, creator and category', (tester) async { + final story = fakeStory(title: 'Story A', creatorName: 'Creator Name'); + await tester.pumpWidget(storiesTestApp(StoryListTile(story: story))); await tester.pump(); expect(find.text('Story A'), findsOneWidget); expect(find.text('Creator Name'), findsOneWidget); - expect(find.text('Drama'), findsOneWidget); - - clearImageLoadErrors(tester); + expect(find.text('Drama'), findsOneWidget); // default category }); }); group('StoryCard', () { - testWidgets('shows the hashed story title', (tester) async { - await tester.pumpWidget(storiesTestApp(StoryCard(story: _story))); + testStoryWidget('shows the hashed story title', (tester) async { + final story = fakeStory(title: 'Story A'); + await tester.pumpWidget(storiesTestApp(StoryCard(story: story))); await tester.pump(); expect(find.text('# Story A'), findsOneWidget); - clearImageLoadErrors(tester); }); }); - group('FilteredListTile (story)', () { - testWidgets('shows the story title and creator', (tester) async { + group('FilteredListTile', () { + testStoryWidget('story variant shows the title and creator', (tester) async { + final story = fakeStory(title: 'Story A', creatorName: 'Creator Name'); await tester.pumpWidget( - storiesTestApp(FilteredListTile(story: _story, isStory: true)), + storiesTestApp(FilteredListTile(story: story, isStory: true)), ); await tester.pump(); expect(find.text('Story A'), findsOneWidget); expect(find.textContaining('Creator Name'), findsOneWidget); - clearImageLoadErrors(tester); }); - }); - group('FilteredListTile (user)', () { - testWidgets('shows the username, name and rating', (tester) async { + testStoryWidget('user variant shows the username, name and rating', ( + tester, + ) async { + final user = fakeResonateUser( + userName: ' testuser', + name: ' Test User', + profileImageUrl: 'http://example.com/u.png', + userRating: 4.5, + ); await tester.pumpWidget( - storiesTestApp(FilteredListTile(user: _user, isStory: false)), + storiesTestApp(FilteredListTile(user: user, isStory: false)), ); await tester.pump(); - expect(find.text('janedoe'), findsOneWidget); // title - expect(find.textContaining('Test User'), findsOneWidget); // "User: Test User" - expect(find.text('4.5'), findsOneWidget); // rating + expect(find.text(' testuser'), findsOneWidget); + expect(find.textContaining('Test User'), findsOneWidget); + expect(find.text('4.5'), findsOneWidget); expect(find.byIcon(Icons.star), findsOneWidget); - clearImageLoadErrors(tester); }); }); - group('ExplorePage', () { - testWidgets('shows a loader while recommended stories resolve', (tester) async { - final completer = Completer>(); - await pumpStoriesPage( - tester, - const ExplorePage(), - container: ProviderContainer( - overrides: [ - exploreStoriesProvider.overrideWith( - () => _FakeExploreStories(completer.future), - ), - ], - ), - ); - await tester.pump(); - expect(find.byType(LoadingIndicator), findsOneWidget); - - completer.complete(const []); - await tester.pumpAndSettle(); - clearImageLoadErrors(tester); - }); - - testWidgets('renders recommended stories', (tester) async { - await pumpStoriesPage( - tester, - const ExplorePage(), - container: ProviderContainer( - overrides: [ - exploreStoriesProvider.overrideWith( - () => _FakeExploreStories(Future.value([_story])), - ), - ], - ), + group('SecondaryListCard', () { + testStoryWidget('wraps and shows its child', (tester) async { + await tester.pumpWidget( + storiesTestApp(const SecondaryListCard(child: Text('inner content'))), ); - await tester.pumpAndSettle(); - expect(find.textContaining('Story A'), findsWidgets); - clearImageLoadErrors(tester); + expect(find.text('inner content'), findsOneWidget); }); }); - group('CategoryPage', () { - testWidgets('renders the category stories', (tester) async { - await pumpStoriesPage( - tester, - const CategoryPage(category: StoryCategory.drama), - container: ProviderContainer( - overrides: [ - categoryStoriesProvider(StoryCategory.drama).overrideWith( - () => _FakeCategoryStories(Future.value([_story])), - ), - ], + group('CoverImagePicker', () { + testStoryWidget('shows the placeholder image and change button', ( + tester, + ) async { + await tester.pumpWidget( + storiesTestApp( + CoverImagePicker( + image: null, + placeholderUrl: 'http://example.com/ph.png', + onTap: () {}, + ), ), ); await tester.pumpAndSettle(); - expect(find.text('Story A'), findsOneWidget); - clearImageLoadErrors(tester); + expect(find.byType(Image), findsOneWidget); + expect(find.text('Change Cover Image'), findsOneWidget); + expect(find.byIcon(Icons.change_circle), findsOneWidget); }); - testWidgets('shows the empty state when there are no stories', (tester) async { - await pumpStoriesPage( - tester, - const CategoryPage(category: StoryCategory.drama), - container: ProviderContainer( - overrides: [ - categoryStoriesProvider(StoryCategory.drama).overrideWith( - () => _FakeCategoryStories(Future.value(const [])), - ), - ], + testStoryWidget('fires onTap when the change button is tapped', ( + tester, + ) async { + var tapped = false; + await tester.pumpWidget( + storiesTestApp( + CoverImagePicker( + image: null, + placeholderUrl: 'http://example.com/ph.png', + onTap: () => tapped = true, + ), ), ); await tester.pumpAndSettle(); - expect(find.byType(StoryListTile), findsNothing); - expect(find.textContaining('Drama'), findsWidgets); - clearImageLoadErrors(tester); + await tester.tap(find.byIcon(Icons.change_circle)); + expect(tapped, isTrue); }); }); - group('StoryPage', () { - testWidgets('renders the header and chapters from the detail state', ( + group('ChapterPlayerView', () { + testStoryWidget('renders the chapter title and a transport slider', ( tester, ) async { - await pumpStoriesPage( - tester, - StoryPage(story: _story), - container: ProviderContainer( - overrides: [ - storyDetailProvider(_story.storyId).overrideWith( - () => _FakeStoryDetail( - StoryDetailState( - chapters: [_chapter], - likesCount: 5, - isLikedByCurrentUser: false, - ), - ), + final chapter = fakeChapter( + chapterId: 'c1', + title: 'My Chapter', + coverImageUrl: 'http://example.com/cover.png', + ); + final container = ProviderContainer( + overrides: [ + chapterPlayerProvider( + chapter.chapterId, + ).overrideWith(() => FakeChapterPlayer(const ChapterPlayerState())), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: storiesTestApp( + // Bounded so the absolutely-positioned transport lays out. + SizedBox( + height: 600, + width: 400, + child: ChapterPlayerView(chapter: chapter, progress: 0.0), ), - ], + ), ), ); await tester.pumpAndSettle(); - expect(find.text('Story A'), findsOneWidget); // header - expect(find.text('My Chapter'), findsOneWidget); // chapter - clearImageLoadErrors(tester); + expect(find.text('My Chapter'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); }); }); } diff --git a/test/features/stories/view/story_format_test.dart b/test/features/stories/view/story_format_test.dart index 9f014cd4..777fa4aa 100644 --- a/test/features/stories/view/story_format_test.dart +++ b/test/features/stories/view/story_format_test.dart @@ -18,18 +18,4 @@ void main() { expect(formatPlayDuration(600000), '10:00'); }); }); - - group('capitalizeFirstLetter', () { - test('capitalizes the first character', () { - expect(capitalizeFirstLetter('drama'), 'Drama'); - }); - - test('leaves an already-capitalized string unchanged', () { - expect(capitalizeFirstLetter('Horror'), 'Horror'); - }); - - test('returns an empty string unchanged', () { - expect(capitalizeFirstLetter(''), ''); - }); - }); } diff --git a/test/helpers/test_root_container.dart b/test/helpers/test_root_container.dart index 59b77c95..84a9e9b7 100644 --- a/test/helpers/test_root_container.dart +++ b/test/helpers/test_root_container.dart @@ -182,7 +182,6 @@ Story fakeStory({ int likesCount = 0, bool isLikedByCurrentUser = false, int playDuration = 1000, - List chapters = const [], }) => Story( storyId: storyId, title: title, @@ -198,7 +197,6 @@ Story fakeStory({ isLikedByCurrentUser: isLikedByCurrentUser, playDuration: playDuration, tintColor: const Color(0xffcbc6c6), - chapters: chapters, ); Chapter fakeChapter({ @@ -210,14 +208,14 @@ Chapter fakeChapter({ String audioFileUrl = 'https://example.com/audio.mp3', int playDuration = 500, }) => Chapter( - chapterId, - title, - coverImageUrl, - description, - lyrics, - audioFileUrl, - playDuration, - const Color(0xffcbc6c6), + chapterId: chapterId, + title: title, + coverImageUrl: coverImageUrl, + description: description, + lyrics: lyrics, + audioFileUrl: audioFileUrl, + playDuration: playDuration, + tintColor: const Color(0xffcbc6c6), ); LiveChapterAttendeesModel fakeLiveChapterAttendees({