diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 0000000..2e17854 --- /dev/null +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../services/preferences_service.dart'; + +class SettingsPage extends StatefulWidget { + final Function(Locale) onLanguageChanged; + final Function(ThemeMode) onThemeChanged; + final Locale currentLocale; + final ThemeMode currentTheme; + + const SettingsPage({ + super.key, + required this.onLanguageChanged, + required this.onThemeChanged, + required this.currentLocale, + required this.currentTheme, + }); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final PreferencesService _preferencesService = PreferencesService(); + String _appVersion = '1.0.0'; + + @override + void initState() { + super.initState(); + _loadAppVersion(); + } + + Future _loadAppVersion() async { + try { + final packageInfo = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _appVersion = packageInfo.version; + }); + } + } catch (e) { + // Keep default version on error + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: AppConstants.backgroundColor, + appBar: AppBar( + title: Text(l10n.settings), + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildLanguageSection(context, l10n), + const SizedBox(height: 24), + _buildThemeSection(context, l10n), + const SizedBox(height: 24), + _buildGitHubSection(context, l10n), + const SizedBox(height: 24), + _buildVersionSection(context, l10n), + ], + ), + ); + } + + Widget _buildLanguageSection(BuildContext context, AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.language, color: AppConstants.primaryColor), + const SizedBox(width: 12), + Text( + l10n.language, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildLanguageOption( + context, + 'English', + const Locale('en'), + widget.currentLocale, + ), + const Divider(), + _buildLanguageOption( + context, + 'Deutsch', + const Locale('de'), + widget.currentLocale, + ), + const Divider(), + _buildLanguageOption( + context, + 'Schwiizerdütsch', + const Locale('gsw'), + widget.currentLocale, + ), + ], + ), + ), + ); + } + + Widget _buildLanguageOption( + BuildContext context, + String label, + Locale locale, + Locale currentLocale, + ) { + final isSelected = currentLocale.languageCode == locale.languageCode; + + return InkWell( + onTap: () async { + widget.onLanguageChanged(locale); + await _preferencesService.setLanguage(locale.languageCode); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Language changed to $label'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + if (isSelected) + const Icon( + Icons.check, + color: AppConstants.primaryColor, + ), + ], + ), + ), + ); + } + + Widget _buildThemeSection(BuildContext context, AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.palette, color: AppConstants.primaryColor), + const SizedBox(width: 12), + Text( + l10n.theme, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildThemeOption( + context, + l10n.lightMode, + ThemeMode.light, + Icons.light_mode, + ), + const Divider(), + _buildThemeOption( + context, + l10n.darkMode, + ThemeMode.dark, + Icons.dark_mode, + ), + ], + ), + ), + ); + } + + Widget _buildThemeOption( + BuildContext context, + String label, + ThemeMode themeMode, + IconData icon, + ) { + final isSelected = widget.currentTheme == themeMode; + + return InkWell( + onTap: () async { + widget.onThemeChanged(themeMode); + await _preferencesService.setTheme( + themeMode == ThemeMode.light ? 'light' : 'dark', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Theme changed to $label'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + if (isSelected) + const Icon( + Icons.check, + color: AppConstants.primaryColor, + ), + ], + ), + ), + ); + } + + Widget _buildGitHubSection(BuildContext context, AppLocalizations l10n) { + return Card( + child: InkWell( + onTap: () => _openGitHub(context), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.code, color: AppConstants.primaryColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.githubContributors, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + l10n.githubContributorsDescription, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + const Icon(Icons.open_in_new), + ], + ), + ), + ), + ); + } + + Future _openGitHub(BuildContext context) async { + final url = Uri.parse('https://github.com/tujii/angry_raphi_flutter'); + try { + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not open GitHub link'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error opening link: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Widget _buildVersionSection(BuildContext context, AppLocalizations l10n) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + l10n.version(_appVersion), + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/widgets/language_selector_dialog.dart b/lib/features/settings/presentation/widgets/language_selector_dialog.dart new file mode 100644 index 0000000..4d40848 --- /dev/null +++ b/lib/features/settings/presentation/widgets/language_selector_dialog.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../../../core/constants/app_constants.dart'; + +class LanguageSelectorDialog extends StatefulWidget { + final Function(Locale) onLanguageSelected; + final Locale currentLocale; + + const LanguageSelectorDialog({ + super.key, + required this.onLanguageSelected, + required this.currentLocale, + }); + + @override + State createState() => _LanguageSelectorDialogState(); +} + +class _LanguageSelectorDialogState extends State { + late Locale _selectedLocale; + + @override + void initState() { + super.initState(); + _selectedLocale = widget.currentLocale; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n?.selectYourLanguage ?? 'Select Your Language', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n?.welcomeSelectLanguage ?? + 'Welcome! Please select your preferred language:', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + _buildLanguageOption('English', const Locale('en')), + const Divider(), + _buildLanguageOption('Deutsch', const Locale('de')), + const Divider(), + _buildLanguageOption('Schwiizerdütsch', const Locale('gsw')), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + widget.onLanguageSelected(_selectedLocale); + Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + l10n?.ok ?? 'Continue', + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLanguageOption(String label, Locale locale) { + final isSelected = _selectedLocale.languageCode == locale.languageCode; + + return InkWell( + onTap: () { + setState(() { + _selectedLocale = locale; + }); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + child: Row( + children: [ + Radio( + value: locale, + groupValue: _selectedLocale, + onChanged: (Locale? value) { + if (value != null) { + setState(() { + _selectedLocale = value; + }); + } + }, + activeColor: AppConstants.primaryColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/user/presentation/widgets/public_user_list_page.dart b/lib/features/user/presentation/widgets/public_user_list_page.dart index f175494..a782bf2 100644 --- a/lib/features/user/presentation/widgets/public_user_list_page.dart +++ b/lib/features/user/presentation/widgets/public_user_list_page.dart @@ -25,13 +25,25 @@ import '../../../../shared/widgets/streaming_raphcon_detail_bottom_sheet.dart'; import '../../../../services/admin_config_service.dart'; import '../../../../services/story_of_the_day_service.dart'; import '../../../admin/presentation/pages/admin_settings_page.dart'; +import '../../../settings/presentation/pages/settings_page.dart'; import '../../../../shared/widgets/user_ranking_search_delegate.dart'; import '../../../../shared/widgets/markdown_content_widget.dart'; import '../../../../shared/widgets/story_of_the_day_banner.dart'; import '../../../../core/utils/responsive_helper.dart'; class PublicUserListPage extends StatefulWidget { - const PublicUserListPage({super.key}); + final Function(Locale) onLanguageChanged; + final Function(ThemeMode) onThemeChanged; + final Locale currentLocale; + final ThemeMode currentTheme; + + const PublicUserListPage({ + super.key, + required this.onLanguageChanged, + required this.onThemeChanged, + required this.currentLocale, + required this.currentTheme, + }); @override State createState() => _PublicUserListPageState(); @@ -210,6 +222,22 @@ class _PublicUserListPageState extends State { context.read().add(RefreshUsersEvent()); }, ), + IconButton( + icon: const Icon(Icons.settings), + tooltip: AppLocalizations.of(context)?.settings ?? 'Settings', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SettingsPage( + onLanguageChanged: widget.onLanguageChanged, + onThemeChanged: widget.onThemeChanged, + currentLocale: widget.currentLocale, + currentTheme: widget.currentTheme, + ), + ), + ); + }, + ), BlocBuilder( builder: (context, authState) { if (authState is AuthAuthenticated && _isAdmin) { @@ -217,7 +245,7 @@ class _PublicUserListPageState extends State { onSelected: (value) { if (value == 'logout') { context.read().add(AuthSignOutRequested()); - } else if (value == 'settings') { + } else if (value == 'admin_settings') { Navigator.of(context).push( MaterialPageRoute( builder: (context) => const AdminSettingsPage(), @@ -227,13 +255,13 @@ class _PublicUserListPageState extends State { }, itemBuilder: (context) => [ PopupMenuItem( - value: 'settings', + value: 'admin_settings', child: Row( children: [ - Icon(Icons.settings), + Icon(Icons.admin_panel_settings), SizedBox(width: 8), - Text(AppLocalizations.of(context)?.settings ?? - 'Einstellungen'), + Text(AppLocalizations.of(context)?.adminSettings ?? + 'Admin Settings'), ], ), ), @@ -244,8 +272,7 @@ class _PublicUserListPageState extends State { Icon(Icons.logout), SizedBox(width: 8), Text(AppLocalizations.of(context)?.signOut ?? - AppLocalizations.of(context)?.signOut ?? - 'Abmelden'), + 'Sign Out'), ], ), ), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0f21ed9..f3e88c2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1151,5 +1151,45 @@ "type": "String" } } + }, + "language": "Sprache", + "selectLanguage": "Sprache auswählen", + "theme": "Design", + "lightMode": "Heller Modus", + "darkMode": "Dunkler Modus", + "githubContributors": "Contributors auf GitHub", + "githubContributorsDescription": "Sehen Sie wer an diesem Projekt mitarbeitet", + "selectYourLanguage": "Wählen Sie Ihre Sprache", + "welcomeSelectLanguage": "Willkommen! Bitte wählen Sie Ihre bevorzugte Sprache:", + "continue": "Weiter", + "@language": { + "description": "Language label" + }, + "@selectLanguage": { + "description": "Select language label" + }, + "@theme": { + "description": "Theme label" + }, + "@lightMode": { + "description": "Light mode label" + }, + "@darkMode": { + "description": "Dark mode label" + }, + "@githubContributors": { + "description": "GitHub contributors link text" + }, + "@githubContributorsDescription": { + "description": "GitHub contributors description" + }, + "@selectYourLanguage": { + "description": "Select your language title" + }, + "@welcomeSelectLanguage": { + "description": "Welcome message for language selection" + }, + "@continue": { + "description": "Continue button text" } } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bc19bf5..92450c2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1162,5 +1162,45 @@ "type": "String" } } + }, + "language": "Language", + "selectLanguage": "Select Language", + "theme": "Theme", + "lightMode": "Light Mode", + "darkMode": "Dark Mode", + "githubContributors": "Contributors on GitHub", + "githubContributorsDescription": "See who's contributing to this project", + "selectYourLanguage": "Select Your Language", + "welcomeSelectLanguage": "Welcome! Please select your preferred language:", + "continue": "Continue", + "@language": { + "description": "Language label" + }, + "@selectLanguage": { + "description": "Select language label" + }, + "@theme": { + "description": "Theme label" + }, + "@lightMode": { + "description": "Light mode label" + }, + "@darkMode": { + "description": "Dark mode label" + }, + "@githubContributors": { + "description": "GitHub contributors link text" + }, + "@githubContributorsDescription": { + "description": "GitHub contributors description" + }, + "@selectYourLanguage": { + "description": "Select your language title" + }, + "@welcomeSelectLanguage": { + "description": "Welcome message for language selection" + }, + "@continue": { + "description": "Continue button text" } } \ No newline at end of file diff --git a/lib/l10n/app_gsw.arb b/lib/l10n/app_gsw.arb new file mode 100644 index 0000000..800c081 --- /dev/null +++ b/lib/l10n/app_gsw.arb @@ -0,0 +1,402 @@ +{ + "@@locale": "gsw", + "appTitle": "AngryRaphi", + "welcome": "Willkomme bi AngryRaphi!", + "subtitle": "Bewärt Persone mit Raphcons", + "signIn": "Aamälde", + "signInWithGoogle": "Mit Google aamälde", + "signOut": "Abmälde", + "loading": "Ladet...", + "error": "Fähler", + "retry": "Nomal probiere", + "cancel": "Abbräche", + "confirm": "Bestätige", + "delete": "Lösche", + "add": "Hinzuefüege", + "name": "Name", + "description": "Beschriibig", + "comingSoon": "🚧 Meh Features chömed bald! 🚧", + "termsAndPrivacy": "Mit dr Aamäldig stimmsch du öisne Nutzigsbedingige und Dateschutzbestimmige zue", + "errorOccurred": "Uups! Öppis isch schief gloffe", + "users": "Benutzer", + "totalRaphcons": "Total Raphcons", + "topCollector": "Top Sammler", + "loadingUsers": "Lade Benutzer...", + "noUsersFound": "Käi Benutzer gfunde", + "addFirstUser": "Füeg de erst Benutzer hinzue!", + "addUser": "Benutzer hinzuefüege", + "initials": "Initiale", + "firstInitial": "Erschte Buechstab", + "secondInitial": "Zweite Buechstab", + "pleaseEnterFirstInitial": "Bitte gib de erst Buechstab ii", + "pleaseEnterSecondInitial": "Bitte gib de zweit Buechstab ii", + "initialMustBeLetter": "Mues en Buechstab sii", + "settings": "Iistellige", + "settingsComingSoon": "Iistellige chömed bald!", + "addUserDialogTitle": "Benutzer hinzuefüege", + "addUserDialogContent": "Die Funktion isch no in Entwicklig. Bald chasch du neui Benutzer hinzuefüege!", + "ok": "OK", + "raphcons": "Raphcons", + "login": "Aamälde", + "adminLogin": "Admin Aamäldig", + "loginRequired": "Aamäldig erforderlich", + "loginToManage": "Mäld dich aa zum Benutzer verwalte", + "notAdmin": "Käi Admin-Berächtigung", + "notAdminMessage": "Du hesch käi Berächtigung für die Aktion.", + "loginSuccess": "Erfolgrych aamäldet", + "loginError": "Aamäldefähler", + "signInCancelled": "Aamäldig abbbroche", + "noInternetConnection": "Käi Internetverbindig", + "networkRequestFailed": "Netzwerkaafrag isch fählgschlage", + "tooManyRequests": "Z'viel Aafrage. Probier's spöter nomol.", + "userDisabled": "Benutzerkonto deaktiviert", + "userNotFound": "Benutzer nöd gfunde", + "operationNotAllowed": "Vorgang nöd erloubt", + "invalidCredential": "Ungültigi Aamälddate", + "credentialAlreadyInUse": "Aamälddate scho bruucht", + "invalidEmail": "Ungültigi E-Mail-Adrässe", + "wrongPassword": "Falschs Passwort", + "weakPassword": "Passwort z'schwach", + "emailAlreadyInUse": "E-Mail scho bruucht", + "enterName": "Name iigää", + "nameRequired": "Name isch erforderlich", + "addingUser": "Benutzer wird hinzuegfüegt...", + "userAdded": "Benutzer hinzuegfüegt", + "failedToAddUser": "Fähler bim Hinzuefüege vom Benutzer", + "creatingRaphcon": "Raphcon wird erstellt...", + "raphconCreated": "Raphcon erstellt! 🎉", + "failedToCreateRaphcon": "Fähler bim Erstelle vom Raphcon", + "confirmDeleteUser": "Benutzer lösche bestätige", + "confirmDeleteUserMessage": "Möchtsch du dä Benutzer würklich lösche? Die Aktion cha nöd rückgängig gmacht wärde.", + "deletingUser": "Benutzer wird glöscht...", + "userDeleted": "Benutzer glöscht", + "failedToDeleteUser": "Fähler bim Lösche vom Benutzer", + "adminFunctionsOnly": "Nur für Administratore", + "loginToAccess": "Mäld dich aa zum uf die Funktion zuegriife", + "checkingAdminStatus": "Prüef Admin-Status...", + "failedToCheckAdmin": "Fähler bim Prüefe vom Admin-Status", + "loggedInAs": "Aamäldet als", + "unknownError": "Unbekannte Fähler isch uftrete", + "tryAgain": "Nomal probiere", + "close": "Schliesse", + "loadingUsers2": "Lade Benutzer...", + "addFirstUserHint": "Füeg de erst Benutzer hinzue!", + "usersCount": "Benutzer", + "manageUsers": "Benutzer verwalte", + "manageUsersMessage": "Möchtsch Biispiil-Benutzer zur Datebank hinzuefüege oder en neue Benutzer erstelle?", + "createUserComingSoon": "Benutzer erstelle chunnt bald!", + "newUser": "Neue Benutzer", + "noDataAvailable": "Käi Date verfüegbar", + "settingsComingSoon2": "Iistellige chömed bald!", + "mustBeLoggedIn": "Du muesch aamäldet sii", + "createRaphconFor": "Raphcon für {name} erstelle", + "loginFailed": "Aamäldig fählgschlage", + "adminRequired": "Admin-Berächtigung erforderlich", + "loginToCreateRaphcons": "Mäld dich aa zum Raphcons erstelle", + "createdAgo": "Erstellt: {timeAgo}", + "dayAgo": "vor {days} Tag", + "daysAgo": "vor {count} Tag{plural}", + "hourAgo": "vor {hours} Stund", + "hoursAgo": "vor {count} Stund{plural}", + "justNow": "grad äbe", + "pleaseEnterName": "Bitte gib en Name ii", + "nameMaxLength": "Name darf höchstens 50 Zeiche haa", + "descriptionOptional": "Beschriibig (optional)", + "descriptionMaxLength": "Beschriibig darf höchstens 500 Zeiche haa", + "loginAsAdmin": "Mäld dich als Admin aa zum Benutzer verwalte und Raphcons erstelle.", + "addPersonWithInitials": "Person mit Initiale hinzuefüege", + "enterTwoInitials": "Gib zwei Initiale ii (z.B. J.D.)", + "initialsFormat": "Format: Erste Buechstab + Zweite Buechstab", + "ratePersonsWithRaphcons": "Bewärt Persone mit Raphcons", + "termsOfService": "Nutzigsbedingige", + "privacyPolicy": "Dateschutzerklärig", + "bySigningInYouAgree": "Mit dr Aamäldig stimmsch du öisne", + "and": "und dr", + "agreeTo": "zue.", + "raphconTypeMouse": "Muus", + "raphconTypeKeyboard": "Tastatur", + "raphconTypeMicrophone": "Mikrofon", + "raphconTypeHeadset": "Headset", + "raphconTypeWebcam": "Webcam", + "raphconTypeSpeakers": "Lüütsprecher", + "raphconTypeNetwork": "Netzwärk", + "raphconTypeSoftware": "Software", + "raphconTypeHardware": "Hardware", + "raphconTypeOther": "Sonschtigs", + "selectProblemType": "Problem-Typ uuswähle", + "whatKindOfProblem": "Was für es Problem?", + "showDetails": "Details aazeige", + "problemStatisticsFor": "Problem vo {userName}", + "sortedByType": "Nach Problemtyp sortiert", + "totalCount": "Gsamtaazahl:", + "problems": "Problem", + "noProblemsReported": "Käi Problem gmäldet!", + "noTechnicalProblemsYet": "Dä Benutzer het bis jetzt käi technischi Problem.", + "errorLoadingStatistics": "Fähler bim Lade vo de Statistike: {message}", + "tryAgainButton": "Nomal probiere", + "cancelButton": "Abbräche", + "createdTimeAgo": "Erstellt: {timeAgo}", + "daysAgoSingular": "vor {days} Tag", + "daysAgoPlural": "vor {days} Täg", + "hoursAgoSingular": "vor {hours} Stund", + "hoursAgoPlural": "vor {hours} Stunde", + "justCreated": "grad äbe", + "raphconsCount": "Raphcons: {count}", + "memberSince": "Mitgliid sit {date}", + "noEntriesForThisType": "Käi Iiträg für dä Typ", + "createdBy": "Erstellt vo: {name}", + "noComment": "Kä Kommentar", + "confirmDeleteRaphcon": "Raphcon lösche", + "confirmDeleteRaphconMessage": "Bisch sicher dass du dä Iitrag möchtsch lösche?", + "raphconDeleted": "Raphcon glöscht", + "errorRaphconIdNotFound": "Fähler: Raphcon ID nöd gfunde", + "adminSettings": "Admin Iistellige", + "configuredAdminsCSV": "Konfigurierti Admins (CSV)", + "activeAdminsFirebase": "Aktivi Admins (Firebase)", + "promoteUserToAdmin": "User zu Admin befördere", + "selectAndPromoteUser": "User uuswähle und befördere", + "removeAdmin": "Admin entferne", + "searchUser": "User sueche", + "enterEmailAddress": "E-Mail-Adrässe iigää", + "emailAddress": "E-Mail-Adrässe", + "termsOfServiceTitle": "Nutzigsbedingige für AngryRaphi", + "privacyPolicyTitle": "Dateschutzerklärig für AngryRaphi", + "lastUpdated": "Stand: {date}", + "termsSection1Title": "1. Gältigsbereich", + "termsSection1Content": "Die Nutzigsbedingige reglet d'Nutzig vo dr AngryRaphi-App (\"d'App\"), emene System zur Bewertig vo Persone mit \"Raphcons\". Mit dr Nutzig vo dr App stimmsch du dene Bedingige zue.", + "termsSection2Title": "2. Beschriibig vo de Dienscht", + "termsSection2Content": "AngryRaphi isch en Aawändig zur humorvolle Bewertig vo Persone in gschlossene Gruppe. Benutzer chönd:\n\n• Persone zur Bewertigsliste hinzuefüege\n• Raphcons (Bewertige) für verschideni Problemberiich vergää\n• Statistike und Ranglischte aaluege\n• Als Administrator zuesätzlichi Verwaltigsfunktione nutze", + "termsSection3Title": "3. Benutzerkonte und Zuegang", + "termsSection3Content": "Dr Zuegang zur App erfolgt über Google-Authentifizirig. Du bisch verantwortlich für:\n\n• D'Gheimhaltig vo dine Aamälddate\n• Alli Aktivitäte under dim Konto\n• D'Benochrichtiging bi unbfuegter Nutzig", + "termsSection4Title": "4. Aagmässeni Nutzig", + "termsSection4Content": "Du verpflichtesch dich zur aagmässene Nutzig vo dr App:\n\n• Käi Beleidigunge oder Diskriminirig\n• Respektvolle Umgang mit andere Benutzer\n• Käi missbräuchlichi Nutzig vo dr Bewertigsfunktion\n• Wahrig vo dr Privatsphäre vo andere Persone", + "termsSection5Title": "5. Dateschutz", + "termsSection5Content": "Dini Privatsphäre isch öis wichtig. Details zur Dateerhebig und -verarbeitig findsch in öisere Dateschutzerklärig, wo integrale Bestandteil vo dene Nutzigsbedingige isch.", + "termsSection6Title": "6. Geischtigs Eigetum", + "termsSection6Content": "Alli Inhalt vo dr App, iischliesslich Design, Code und Grafike, sind urheberrächtlich gschützt. D'Nutzig isch nur im Rahme vo dr beschtimmigsgemässe Verwändig vo dr App gstattet.", + "termsSection7Title": "7. Haftigsusschluss", + "termsSection7Content": "D'App wird \"wie gseh\" bereitgstellt. Mir übernähmed käi Gwähr für:\n\n• Kontinuierlichi Verfüegbarkeit\n• Fählerfröiheit vo dr Software\n• Eignet für beschtimmti Zwäck\n• Schäde dür d'Nutzig vo dr App", + "termsSection8Title": "8. Änderige vo de Bedingige", + "termsSection8Content": "Mir behaltet öis s'Rächt vor, die Nutzigsbedingige jederzit z'ändere. Änderige werdet in dr App bekannt gää und träted mit dr Fortsätzig vo dr Nutzig in Chraft.", + "termsSection9Title": "9. Kündiging", + "termsSection9Content": "Du chasch dis Konto jederzit lösche. Mir behaltet öis vor, Konte bi Verschtöss gege die Bedingige z'sperre oder z'lösche.", + "termsSection10Title": "10. Aawändbers Rächt", + "termsSection10Content": "Die Nutzigsbedingige understönd em Rächt vo dr Schwiz. Grichtsstand isch Olten, Schwiz.\n\n**WICHTIGE HIIWIS:** AngryRaphi isch en reine Spass-App ohni ernsthafti Absicht. Bi Problem nimm bitte zerscht Kontakt uf, bevor rächtlichi Schritt iigleitet werdet.", + "contactTitle": "Kontakt", + "termsContactContent": "Bi Frage zu dene Nutzigsbedingige wänd dich bitte aa:\n\nInthusan Gunasiri\nAarauerstrasse 132\n4600 Olten\nE-Mail: inthusan@hotmail.de\n\nApp: AngryRaphi v{version}", + "privacySection1Title": "1. Verantwortliche", + "privacySection1Content": "Verantwortlich für d'Dateverarbeitig in dr AngryRaphi-App isch:\n\nInthusan Gunasiri\nAarauerstrasse 132\n4600 Olten, Schwiz\nE-Mail: inthusan@hotmail.de\n\n**HIIWIS:** Das isch en Spass-App ohni kommerzielli Absichte.", + "privacySection2Title": "2. Erhobeni Date", + "privacySection2Content": "Mir erhebet und verarbeitet folgendi Date:\n\n**Authentifizirigsddate:**\n• Google-Account-Informatione (Name, E-Mail, Profilbild)\n• Eindütigi Benutzer-ID\n\n**App-Nutzigsdate:**\n• Erstellti Persone und dere Name/Initiale\n• Raphcon-Bewertige mit Ziitstämpel\n• Admin-Status und Berächtigung\n\n**Technischi Date:**\n• Gerätinformatione\n• App-Nutzigsstatistike\n• Crash-Reports (anonymisiert)", + "privacySection3Title": "3. Zwäck vo dr Dateverarbeitig", + "privacySection3Content": "D'Dateverarbeitig erfolgt zu folgede Zwäck:\n\n• **Bereitstellig vo de App-Funktione:** Authentifizirig, Benutzerverwaltung\n• **Personebewertige:** Speicherig und Aazeig vo Raphcons\n• **Administration:** Verwaltung vo Benutzer und Berächtigung\n• **Verbesserig vo dr App:** Analys vo Nutzigsmuschter\n• **Sicherheit:** Schutz vor Missbruch", + "privacySection4Title": "4. Rächtsgrundlag", + "privacySection4Content": "D'Verarbeitig erfolgt uf Basis vo:\n\n• **Art. 6 Abs. 1 lit. b DSGVO:** Vertragserfüllig\n• **Art. 6 Abs. 1 lit. f DSGVO:** Berächtigti Interesse\n• **Art. 6 Abs. 1 lit. a DSGVO:** Iiverschtändnis (wo erforderlich)", + "privacySection5Title": "5. Datespeicherig und -Sicherheit", + "privacySection5Content": "**Speicherort:**\nDini Date werdet in Google Firebase/Firestore gspeicheret, wo de EU-Dateschutzstandards entspricht.\n\n**Sicherheitsmassnähme:**\n• Verschlüsselti Dateübertragig (HTTPS)\n• Sicheri Authentifizirig über Google\n• Regelmässigi Sicherheits-Updates\n• Zuegriffskontrolle über Firebase Security Rules", + "privacySection6Title": "6. Datewitergoob", + "privacySection6Content": "En Witergoob vo dine Date erfolgt nur:\n\n• Aa Google Firebase (als technische Dienstleischter)\n• Bi rächtlicher Verpflichtig\n• Mit dinere usdrückliche Iiwilliging\n\nEn kommerzielli Witergoob findet nöd statt.", + "privacySection7Title": "7. Speicherduur", + "privacySection7Content": "**Benutzerkonto:** Bis zur Löschig dür dich\n**Raphcon-Date:** Bis zur manuelle Löschig\n**Log-Date:** 30 Täg\n**Crash-Reports:** 90 Täg (anonymisiert)", + "privacySection8Title": "8. Dini Rächt", + "privacySection8Content": "Du hesch folgendi Rächt bezüglich dinere Date:\n\n• **Uskunft:** Information über gspeicherti Date\n• **Berichtiging:** Korrektur vo falsche Date\n• **Löschig:** Entfernig vo dine Date\n• **Iischränkig:** Beschränkig vo dr Verarbeitig\n• **Dateübertragbarkeit:** Export vo dine Date\n• **Widerspruch:** Widerspruch gege d'Verarbeitig\n\nKontaktier öis under: privacy@angryraphi.app", + "privacySection9Title": "9. Google Services", + "privacySection9Content": "D'App nutzt Google-Dienscht:\n\n• **Firebase Authentication:** Sicheri Aamäldig\n• **Cloud Firestore:** Datespeicherig\n• **Firebase Storage:** Bildspeicherig\n\nGoogle's Dateschutzbeschtimmige gälted für die Dienscht: https://policies.google.com/privacy", + "privacySection10Title": "10. Minderjährigi", + "privacySection10Content": "D'App isch nöd für Persone under 16 Jahr beschtimmt. Mir erhebet wissentlich käi Date vo Minderjährige.", + "privacySection11Title": "11. Änderige", + "privacySection11Content": "Die Dateschutzerklärig cha aktualisiert wärde. Änderige werdet in dr App bekannt gää.", + "privacyContactTitle": "Dateschutz-Kontakt", + "privacyContactContent": "Bi Frage zum Dateschutz:\n\nInthusan Gunasiri\nE-Mail: inthusan@hotmail.de\nBetreff: \"Dateschutz AngryRaphi\"\n\nBitte nimm zerscht Kontakt uf, bevor du dich aa Ufsichtsbehörde wändsch. Die App dient nur em Spass!", + "searchUsers": "Benutzer sueche...", + "showRanking": "Ranglischte aazeige", + "noResultsFor": "Käi Resultat für \"{query}\"", + "noUsersAvailable": "Käi Benutzer verfüegbar", + "fullRanking": "Vollständigi Ranglischte", + "topRanking": "Top 5 Ranglischte", + "showFullRanking": "Vollständigi Ranglischte aazeige ({count} Benutzer)", + "gold": "GOLD", + "silver": "SILBER", + "bronze": "BRONZE", + "commentExample": "z.B. \"Muus klickt nöd richtig\"", + "rateWithRaphcons": "Bewärt Persone mit Raphcons!", + "termsPrivacyAgreement": "Mit dr Aamäldig stimmsch du öisne Nutzigsbedingige und Dateschutzrichtiinie zue", + "errorLoadingAdminData": "Fähler bim Lade vo de Admin-Date: {error}", + "csvAdminsDescription": "Die Admins sind in dr CSV-Konfiguration definiert:", + "noConfiguredAdmins": "Käi konfigurierti Admins gfunde.", + "firebaseAdminsDescription": "Die Admins sind aktuell in Firebase registriert:", + "noActiveAdmins": "Käi aktivi Admins in Firebase gfunde.", + "registeredUsers": "Registrierti Benutzer", + "registeredUsersDescription": "Die Benutzer händ sich bereits mit Google aamäldet:", + "noRegisteredUsers": "Käi registrierti Benutzer gfunde.", + "lastLogin": "Letschte Login: {date}", + "alreadyAdmin": "Bereits Admin", + "promoteToAdmin": "Zu Admin befördere", + "minutesAgo": "vor {count} Minute{plural}", + "addAdminManually": "Manuell en neue Admin hinzuefüege:", + "addAdminManuallyButton": "Manuell Admin hinzuefüege", + "superAdmin": "Super Admin", + "admin": "Admin", + "confirmRemoveAdmin": "Möchtsch {email} würklich als Admin entferne?", + "remove": "Entferne", + "errorRemoving": "Fähler bim Entferne: {error}", + "promoteUserDialogTitle": "User zu Admin befördere", + "promoteUserDescription": "Gib d'Date vom Benutzer ii, wo zu Admin beförderet söll wärde:", + "emailAddressLabel": "E-Mail-Adrässe", + "emailAddressHint": "bispil@email.com", + "displayNameLabel": "Aazeigname (optional)", + "displayNameHint": "Max Muschter", + "googleAccountRequired": "Dr Benutzer mues bereits es Google-Konto haa und sich mindischtens eimol in dr App aamäldet haa.", + "promoteToAdminButton": "Zu Admin befördere", + "errorPromoting": "Fähler bim Befördere: {error}", + "manageUsersDescription": "Möchtsch Biispiil-Benutzer zur Datebank hinzuefüege oder en neue Benutzer erstelle?", + "sampleData": "Biispiil-Date", + "searchAndRanking": "Sueche & Ranglischte", + "logout": "Abmälde", + "adminLoginPrompt": "Mäld dich als Admin aa zum Benutzer verwalte und Raphcons erstelle.", + "version": "Version {version}", + "language": "Sproch", + "selectLanguage": "Sproch uuswähle", + "theme": "Design", + "lightMode": "Hälle Modus", + "darkMode": "Dunkle Modus", + "githubContributors": "Contributors uf GitHub", + "githubContributorsDescription": "Lueg wär aa däm Projekt mitarbeitet", + "selectYourLanguage": "Wähl dini Sproch", + "welcomeSelectLanguage": "Willkomme! Wähl bitte dini bevorzugti Sproch:", + "continue": "Wiiter", + "@appTitle": { + "description": "The title of the application" + }, + "@welcome": { + "description": "Welcome message" + }, + "@subtitle": { + "description": "App subtitle" + }, + "@signIn": { + "description": "Sign in button text" + }, + "@signInWithGoogle": { + "description": "Google sign in button text" + }, + "@signOut": { + "description": "Sign out button text" + }, + "@loading": { + "description": "Loading text" + }, + "@error": { + "description": "Generic error text" + }, + "@retry": { + "description": "Retry button text" + }, + "@cancel": { + "description": "Cancel button text" + }, + "@confirm": { + "description": "Confirm button text" + }, + "@delete": { + "description": "Delete button text" + }, + "@add": { + "description": "Add button text" + }, + "@name": { + "description": "Name field label" + }, + "@description": { + "description": "Description field label" + }, + "@comingSoon": { + "description": "Coming soon message" + }, + "@termsAndPrivacy": { + "description": "Terms and privacy notice" + }, + "@errorOccurred": { + "description": "Error occurred message" + }, + "@users": { + "description": "Users label" + }, + "@totalRaphcons": { + "description": "Total raphcons label" + }, + "@topCollector": { + "description": "Top collector label" + }, + "@loadingUsers": { + "description": "Loading users message" + }, + "@noUsersFound": { + "description": "No users found message" + }, + "@addFirstUser": { + "description": "Add first user message" + }, + "@addUser": { + "description": "Add user button text" + }, + "@initials": { + "description": "Initials label" + }, + "@firstInitial": { + "description": "First initial input label" + }, + "@secondInitial": { + "description": "Second initial input label" + }, + "@pleaseEnterFirstInitial": { + "description": "Validation message for first initial" + }, + "@pleaseEnterSecondInitial": { + "description": "Validation message for second initial" + }, + "@initialMustBeLetter": { + "description": "Validation message for invalid initial" + }, + "@settings": { + "description": "Settings menu item" + }, + "@version": { + "description": "Version text", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "@language": { + "description": "Language label" + }, + "@selectLanguage": { + "description": "Select language label" + }, + "@theme": { + "description": "Theme label" + }, + "@lightMode": { + "description": "Light mode label" + }, + "@darkMode": { + "description": "Dark mode label" + }, + "@githubContributors": { + "description": "GitHub contributors link text" + }, + "@githubContributorsDescription": { + "description": "GitHub contributors description" + }, + "@selectYourLanguage": { + "description": "Select your language title" + }, + "@welcomeSelectLanguage": { + "description": "Welcome message for language selection" + }, + "@continue": { + "description": "Continue button text" + } +} diff --git a/lib/main.dart b/lib/main.dart index 063c195..d9ddc9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'features/user/data/repositories/firestore_user_repository.dart'; import 'features/user/domain/usecases/user_usecases.dart'; import 'features/user/presentation/bloc/user_bloc.dart'; import 'services/admin_service.dart'; +import 'services/preferences_service.dart'; import 'features/admin/data/repositories/admin_repository_impl.dart'; import 'features/admin/data/datasources/admin_remote_datasource.dart'; import 'features/admin/domain/usecases/check_admin_status.dart'; @@ -38,6 +39,7 @@ import 'features/authentication/presentation/bloc/auth_event.dart'; import 'shared/widgets/app_wrapper.dart'; import 'core/network/network_info.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'features/settings/presentation/widgets/language_selector_dialog.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -78,15 +80,98 @@ Future _initializeAdmin() async { } } -class AngryRaphiApp extends StatelessWidget { +class AngryRaphiApp extends StatefulWidget { const AngryRaphiApp({super.key}); + @override + State createState() => _AngryRaphiAppState(); +} + +class _AngryRaphiAppState extends State { + final PreferencesService _preferencesService = PreferencesService(); + Locale _locale = const Locale('en'); + ThemeMode _themeMode = ThemeMode.light; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + _initializeSettings(); + } + + Future _initializeSettings() async { + // Load saved preferences + final savedLanguage = await _preferencesService.getLanguage(); + final savedTheme = await _preferencesService.getTheme(); + final isFirstLaunch = await _preferencesService.isFirstLaunch(); + + setState(() { + if (savedLanguage != null) { + _locale = Locale(savedLanguage); + } + if (savedTheme != null) { + _themeMode = savedTheme == 'dark' ? ThemeMode.dark : ThemeMode.light; + } + _isInitialized = true; + }); + + // Show language selector on first launch + if (isFirstLaunch) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showLanguageSelector(); + }); + } + } + + void _showLanguageSelector() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => LanguageSelectorDialog( + currentLocale: _locale, + onLanguageSelected: (locale) async { + await _preferencesService.setLanguage(locale.languageCode); + await _preferencesService.setNotFirstLaunch(); + setState(() { + _locale = locale; + }); + }, + ), + ); + } + + void _changeLanguage(Locale locale) { + setState(() { + _locale = locale; + }); + } + + void _changeTheme(ThemeMode themeMode) { + setState(() { + _themeMode = themeMode; + }); + } + @override Widget build(BuildContext context) { + if (!_isInitialized) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + return MaterialApp( title: AppConstants.appName, - theme: _buildTheme(), + theme: _buildLightTheme(), + darkTheme: _buildDarkTheme(), + themeMode: _themeMode, debugShowCheckedModeBanner: false, + locale: _locale, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -96,6 +181,7 @@ class AngryRaphiApp extends StatelessWidget { supportedLocales: const [ Locale('en'), Locale('de'), + Locale('gsw'), ], home: MultiBlocProvider( providers: [ @@ -197,12 +283,17 @@ class AngryRaphiApp extends StatelessWidget { }, ), ], - child: const AppWrapper(), + child: AppWrapper( + onLanguageChanged: _changeLanguage, + onThemeChanged: _changeTheme, + currentLocale: _locale, + currentTheme: _themeMode, + ), ), ); } - ThemeData _buildTheme() { + ThemeData _buildLightTheme() { return ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( @@ -236,6 +327,41 @@ class AngryRaphiApp extends StatelessWidget { ), ); } + + ThemeData _buildDarkTheme() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: AppConstants.primaryColor, + brightness: Brightness.dark, + ), + appBarTheme: AppBarTheme( + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + elevation: 2, + centerTitle: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.defaultPadding, + vertical: AppConstants.smallPadding, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + ), + cardTheme: CardTheme( + elevation: AppConstants.cardElevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + ); + } } // UserListPage is now in features/user/presentation/widgets/user_list_page.dart diff --git a/lib/services/preferences_service.dart b/lib/services/preferences_service.dart new file mode 100644 index 0000000..1e63fbe --- /dev/null +++ b/lib/services/preferences_service.dart @@ -0,0 +1,43 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class PreferencesService { + static const String _languageKey = 'app_language'; + static const String _themeKey = 'app_theme'; + static const String _firstLaunchKey = 'app_first_launch'; + + /// Get the saved language code (e.g., 'en', 'de', 'gsw') + Future getLanguage() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_languageKey); + } + + /// Save the language code + Future setLanguage(String languageCode) async { + final prefs = await SharedPreferences.getInstance(); + return await prefs.setString(_languageKey, languageCode); + } + + /// Get the saved theme mode ('light' or 'dark') + Future getTheme() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_themeKey); + } + + /// Save the theme mode + Future setTheme(String theme) async { + final prefs = await SharedPreferences.getInstance(); + return await prefs.setString(_themeKey, theme); + } + + /// Check if this is the first launch + Future isFirstLaunch() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_firstLaunchKey) ?? true; + } + + /// Mark that the app has been launched + Future setNotFirstLaunch() async { + final prefs = await SharedPreferences.getInstance(); + return await prefs.setBool(_firstLaunchKey, false); + } +} diff --git a/lib/shared/widgets/app_wrapper.dart b/lib/shared/widgets/app_wrapper.dart index 67917d3..a63f5f0 100644 --- a/lib/shared/widgets/app_wrapper.dart +++ b/lib/shared/widgets/app_wrapper.dart @@ -7,7 +7,18 @@ import '../../features/authentication/presentation/pages/splash_page.dart'; import '../../features/user/presentation/widgets/public_user_list_page.dart'; class AppWrapper extends StatelessWidget { - const AppWrapper({super.key}); + final Function(Locale) onLanguageChanged; + final Function(ThemeMode) onThemeChanged; + final Locale currentLocale; + final ThemeMode currentTheme; + + const AppWrapper({ + super.key, + required this.onLanguageChanged, + required this.onThemeChanged, + required this.currentLocale, + required this.currentTheme, + }); @override Widget build(BuildContext context) { @@ -17,7 +28,12 @@ class AppWrapper extends StatelessWidget { return const SplashPage(); } else { // Always show the public user list - login is handled within - return const PublicUserListPage(); + return PublicUserListPage( + onLanguageChanged: onLanguageChanged, + onThemeChanged: onThemeChanged, + currentLocale: currentLocale, + currentTheme: currentTheme, + ); } }, ); diff --git a/pubspec.yaml b/pubspec.yaml index 782e4f2..3aeb6f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,10 @@ dependencies: # AI google_generative_ai: ^0.4.6 + # Storage & URL handling + shared_preferences: ^2.3.4 + url_launcher: ^6.3.1 + cupertino_icons: ^1.0.8 dev_dependencies: