diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/core/injection.dart b/lib/core/injection.dart index 1b619270..99870388 100644 --- a/lib/core/injection.dart +++ b/lib/core/injection.dart @@ -30,6 +30,13 @@ import 'package:campus_app/utils/dio_utils.dart'; import 'package:campus_app/utils/constants.dart'; import 'package:native_dio_adapter/native_dio_adapter.dart'; +// Email-related imports +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/repositories/imap_email_repository.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; + final sl = GetIt.instance; // service locator Future init() async { @@ -37,7 +44,6 @@ Future init() async { //! Datasources //! - //! Datasources sl.registerSingletonAsync(() async { final client = Dio(); client.httpClientAdapter = NativeAdapter(); @@ -63,10 +69,13 @@ Future init() async { sl.registerLazySingleton(() => NavigationDatasource(appwriteClient: sl())); //! - //! Repositories + //! Repositories (non-email) //! - sl.registerLazySingleton(() => BackendRepository(client: sl())); + sl.registerLazySingleton(() { + final Client client = Client().setEndpoint(appwrite).setProject('campus_app'); + return BackendRepository(client: client); + }); sl.registerSingletonWithDependencies( () => NewsRepository(newsDatasource: sl()), @@ -87,6 +96,32 @@ Future init() async { () => TicketRepository(ticketDataSource: sl(), secureStorage: sl()), ); + //! + //! Email dependencies (reordered) + //! + + // 1. FlutterSecureStorage is already registered below in “External” + + // 2. EmailAuthService (needs secure storage) + sl.registerLazySingleton( + () => EmailAuthService(), + ); + + // 3. ImapEmailService (low-level IMAP/SMTP) + sl.registerLazySingleton( + () => ImapEmailService(), + ); + + // 4. EmailRepository (depends on ImapEmailService) + sl.registerLazySingleton( + () => ImapEmailRepository(sl()), + ); + + // 5. EmailService (business logic, depends on EmailRepository) + sl.registerLazySingleton( + () => EmailService(sl()), + ); + //! //! Usecases //! @@ -95,17 +130,14 @@ Future init() async { () => NewsUsecases(newsRepository: sl()), dependsOn: [NewsRepository], ); - sl.registerSingletonWithDependencies( () => CalendarUsecases(calendarRepository: sl()), dependsOn: [CalendarRepository], ); - sl.registerSingletonWithDependencies( () => MensaUsecases(mensaRepository: sl()), dependsOn: [MensaRepository], ); - sl.registerLazySingleton( () => TicketUsecases(ticketRepository: sl()), ); diff --git a/lib/main.dart b/lib/main.dart index 69da3f74..34ddb11a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,10 +30,25 @@ import 'package:campus_app/pages/calendar/entities/venue_entity.dart'; import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/pages/mensa_utils.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/repositories/imap_email_repository.dart'; +import 'package:background_fetch/background_fetch.dart'; +import 'package:campus_app/pages/email_client/services/email_background_service.dart'; + + + + Future main() async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); // Keeps the native splash screen onscreen until all loading is done FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + BackgroundFetch.registerHeadlessTask(EmailBackgroundService.headlessTask); + // initialize background service + await EmailBackgroundService.init(); + // Disable all logs in production mode if (!kDebugMode) debugPrint = (String? message, {int? wrapWidth}) => ''; @@ -66,6 +81,9 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), + ChangeNotifierProvider(create: (_) => EmailAuthService()), + Provider(create: (_) => ImapEmailRepository(ImapEmailService())), + ChangeNotifierProvider(create: (ctx) => EmailService(ctx.read())) ], child: CampusApp( key: campusAppKey, @@ -80,6 +98,9 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), + ChangeNotifierProvider(create: (_) => EmailAuthService()), + Provider(create: (_) => ImapEmailRepository(ImapEmailService())), + ChangeNotifierProvider(create: (ctx) => EmailService(ctx.read())) ], child: CampusApp( key: campusAppKey, diff --git a/lib/pages/email_client/email_drawer/archives.dart b/lib/pages/email_client/email_drawer/archives.dart new file mode 100644 index 00000000..6dc626f8 --- /dev/null +++ b/lib/pages/email_client/email_drawer/archives.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; + +class ArchivesPage extends StatelessWidget { + const ArchivesPage({super.key}); + + @override + Widget build(BuildContext context) { + final emailService = Provider.of(context, listen: false); + final archivedEmails = emailService.filterEmails('', EmailFolder.archives); + + return Scaffold( + appBar: AppBar(title: const Text('Archives')), + body: archivedEmails.isEmpty + ? const Center(child: Text('No archived emails')) + : ListView.builder( + itemCount: archivedEmails.length, + itemBuilder: (context, index) { + final email = archivedEmails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart new file mode 100644 index 00000000..2a82fd68 --- /dev/null +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; + +// UI screen to display and manage email drafts +class DraftsPage extends StatefulWidget { + const DraftsPage({super.key}); + + @override + State createState() => _DraftsPageState(); +} + +class _DraftsPageState extends State { + @override + Widget build(BuildContext context) { + final emailService = Provider.of(context); // Access the email service + final selectionController = emailService.selectionController; // For managing multi-selection + final drafts = emailService.allEmails.where((e) => e.folder == EmailFolder.drafts).toList() + ..sort((a, b) => b.date.compareTo(a.date)); // Sort drafts by newest first + + return Scaffold( + appBar: _buildAppBar(selectionController, drafts, emailService), // Show toolbar with actions + body: drafts.isEmpty + ? _buildEmptyState() // Show message if no drafts + : ListView.separated( + itemCount: drafts.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + itemBuilder: (_, index) { + final draft = drafts[index]; + return EmailTile( + email: draft, + isSelected: selectionController.isSelected(draft), + onTap: () => _handleEmailTap(draft, selectionController), // Tap to edit + onLongPress: () => _handleEmailLongPress(draft, selectionController), // Long press to select + ); + }, + ), + ); + } + + // Builds AppBar depending on whether selection mode is active + PreferredSizeWidget _buildAppBar(selectionController, List drafts, EmailService emailService) { + if (selectionController.isSelecting) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => selectionController.clearSelection(), // Exit selection mode + ), + title: Text('${selectionController.selectionCount} selected'), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: () => selectionController.selectAll(drafts), // Select all drafts + ), + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () => _showDeleteConfirmation(selectionController, emailService), // Confirm before deletion + ), + ], + ); + } + + // Default AppBar when not selecting + return AppBar( + title: const Text('Drafts'), + ); + } + + // Widget shown when there are no drafts + Widget _buildEmptyState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.drafts_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No drafts', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + ], + ), + ); + } + + // Handles tapping a draft: open for editing or toggle selection + void _handleEmailTap(Email draft, selectionController) { + if (selectionController.isSelecting) { + selectionController.toggleSelection(draft); // Toggle selected state + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ComposeEmailScreen(draft: draft), // Navigate to compose screen with the draft + ), + ); + } + } + + // Handles long press to enter selection mode + void _handleEmailLongPress(Email draft, selectionController) { + if (!selectionController.isSelecting) { + selectionController.toggleSelection(draft); // Start selecting + } + } + + // Show confirmation dialog before permanently deleting selected drafts + void _showDeleteConfirmation(selectionController, EmailService emailService) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Drafts'), + content: Text( + 'Are you sure you want to permanently delete ${selectionController.selectionCount} draft(s)?\n\nThis action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), // Cancel deletion + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + emailService.deleteEmailsPermanently(selectionController.selectedEmails); // Delete selected drafts + Navigator.pop(context); // Close dialog + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/email_settings.dart b/lib/pages/email_client/email_drawer/email_settings.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/pages/email_client/email_drawer/sent.dart b/lib/pages/email_client/email_drawer/sent.dart new file mode 100644 index 00000000..592e8e9c --- /dev/null +++ b/lib/pages/email_client/email_drawer/sent.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; + +class SentPage extends StatelessWidget { + const SentPage({super.key}); + + @override + Widget build(BuildContext context) { + final emailService = Provider.of(context, listen: false); + final sentEmails = emailService.filterEmails('', EmailFolder.sent); + + return Scaffold( + appBar: AppBar(title: const Text('Sent Emails')), + body: sentEmails.isEmpty + ? const Center(child: Text('No sent emails')) + : ListView.builder( + itemCount: sentEmails.length, + itemBuilder: (context, index) { + final email = sentEmails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/spam.dart b/lib/pages/email_client/email_drawer/spam.dart new file mode 100644 index 00000000..0a00584c --- /dev/null +++ b/lib/pages/email_client/email_drawer/spam.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; + +class SpamPage extends StatelessWidget { + const SpamPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final emailService = Provider.of(context); + final spamEmails = emailService.allEmails.where((email) => email.folder == EmailFolder.spam).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Spam'), + ), + body: RefreshIndicator( + onRefresh: () => emailService.refreshEmails(), + child: spamEmails.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.4), + Center( + child: Text( + 'No spam emails', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ) + : ListView.builder( + itemCount: spamEmails.length, + itemBuilder: (context, index) { + final email = spamEmails[index]; + return EmailTile( + email: email, + isSelected: false, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart new file mode 100644 index 00000000..42dfb2d9 --- /dev/null +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; + +class TrashPage extends StatelessWidget { + const TrashPage({super.key}); + + @override + Widget build(BuildContext context) { + final emailService = Provider.of(context, listen: false); + final trashEmails = emailService.filterEmails('', EmailFolder.trash); + + return Scaffold( + appBar: AppBar( + title: Text( + emailService.selectionController.isSelecting + ? '${emailService.selectionController.selectionCount} selected' + : 'Trash', + ), + actions: [ + if (emailService.selectionController.isSelecting) ...[ + IconButton( + icon: const Icon(Icons.restore), + onPressed: () { + emailService.moveEmailsToFolder( + emailService.selectionController.selectedEmails, + EmailFolder.inbox, + ); + emailService.selectionController.clearSelection(); + }, + ), + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () { + emailService.deleteEmailsPermanently( + emailService.selectionController.selectedEmails, + ); + }, + ), + ], + ], + ), + body: trashEmails.isEmpty + ? const Center(child: Text('Trash is empty.')) + : ListView.separated( + itemCount: trashEmails.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + itemBuilder: (_, index) { + final email = trashEmails[index]; + return EmailTile( + email: email, + isSelected: emailService.selectionController.isSelected(email), + onTap: () { + if (emailService.selectionController.isSelecting) { + emailService.selectionController.toggleSelection(email); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + isInTrash: true, + onDelete: (email) => emailService.deleteEmailsPermanently([email]), + onRestore: (email) => emailService.moveEmailsToFolder([email], EmailFolder.inbox), + ), + ), + ); + } + }, + onLongPress: () => emailService.selectionController.toggleSelection(email), + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/email_pages/compose_email_screen.dart b/lib/pages/email_client/email_pages/compose_email_screen.dart new file mode 100644 index 00000000..75c320b7 --- /dev/null +++ b/lib/pages/email_client/email_pages/compose_email_screen.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; + +class ComposeEmailScreen extends StatefulWidget { + final Email? draft; + final Email? replyTo; + final Email? forwardFrom; + + const ComposeEmailScreen({ + super.key, + this.draft, + this.replyTo, + this.forwardFrom, + }); + + @override + State createState() => _ComposeEmailScreenState(); +} + +class _ComposeEmailScreenState extends State { + final _formKey = GlobalKey(); + final _toController = TextEditingController(); + final _ccController = TextEditingController(); + final _bccController = TextEditingController(); + final _subjectController = TextEditingController(); + final _bodyController = TextEditingController(); + bool _showCcBcc = false; + final List _attachments = []; + + @override + void initState() { + super.initState(); + if (widget.draft != null) { + _toController.text = widget.draft!.recipients.join(', '); + _subjectController.text = widget.draft!.subject; + _bodyController.text = widget.draft!.htmlBody ?? widget.draft!.body; + _attachments.addAll(widget.draft!.attachments); + } else if (widget.replyTo != null) { + _toController.text = widget.replyTo!.senderEmail; + _subjectController.text = 'Re: ${widget.replyTo!.subject}'; + _bodyController.text = '\n\n----------\n${widget.replyTo!.htmlBody ?? widget.replyTo!.body}'; + } else if (widget.forwardFrom != null) { + _subjectController.text = 'Fwd: ${widget.forwardFrom!.subject}'; + _bodyController.text = '\n\n----------\n${widget.forwardFrom!.htmlBody ?? widget.forwardFrom!.body}'; + } + } + + @override + void dispose() { + _toController.dispose(); + _ccController.dispose(); + _bccController.dispose(); + _subjectController.dispose(); + _bodyController.dispose(); + super.dispose(); + } + + bool _hasContent() { + return _toController.text.trim().isNotEmpty || + _subjectController.text.trim().isNotEmpty || + _bodyController.text.trim().isNotEmpty || + _attachments.isNotEmpty; + } + + void _saveDraft(EmailService emailService) { + if (!_hasContent()) { + if (widget.draft != null) { + emailService.removeDraft(widget.draft!.id); + } + return; + } + + final newDraft = Email( + id: widget.draft?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + sender: 'Me', + senderEmail: 'me@example.com', + recipients: _toController.text.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(), + subject: _subjectController.text, + body: _bodyController.text, + date: DateTime.now(), + attachments: List.from(_attachments), + folder: EmailFolder.drafts, + ); + emailService.saveOrUpdateDraft(newDraft); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Draft saved'), + duration: Duration(seconds: 2), + ), + ); + } + + Future _sendEmail() async { + if (!_formKey.currentState!.validate()) return; + + final emailService = Provider.of(context, listen: false); + + // Remove the old draft if we're editing one + if (widget.draft != null) { + emailService.removeDraft(widget.draft!.id); + } + + try { + await emailService.sendEmail( + to: _toController.text.trim(), + subject: _subjectController.text.trim(), + body: _bodyController.text, + // Pass cc/bcc as String? (the service will split internally) + cc: _ccController.text.trim().isEmpty + ? null + : _ccController.text.trim(), // <<< changed: String? instead of List? + bcc: _bccController.text.trim().isEmpty ? null : _bccController.text.trim(), // <<< changed here as well + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Email sent'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to send email: $e')), + ); + } + } + + Future _attachFile() async { + // TODO: implement real file picker + setState(() { + _attachments.add('file_${_attachments.length + 1}.pdf'); + }); + } + + @override + Widget build(BuildContext context) { + final emailService = Provider.of(context, listen: false); + + return WillPopScope( + onWillPop: () async { + _saveDraft(emailService); + return true; + }, + child: Scaffold( + appBar: AppBar( + title: Text( + widget.replyTo != null + ? 'Reply' + : widget.draft != null + ? 'Edit Draft' + : 'Compose', + ), + actions: [ + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: _attachFile, + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendEmail, + ), + ], + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // To field + TextFormField( + controller: _toController, + decoration: InputDecoration( + labelText: 'To', + border: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter recipient'; + } + final emails = value.split(',').map((e) => e.trim()); + for (final email in emails) { + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(email)) { + return 'Invalid email: $email'; + } + } + return null; + }, + ), + const SizedBox(height: 8), + + // CC/BCC toggle + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => setState(() => _showCcBcc = !_showCcBcc), + child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), + ), + ), + + // CC field + if (_showCcBcc) ...[ + TextFormField( + controller: _ccController, + decoration: const InputDecoration(labelText: 'CC'), + ), + const SizedBox(height: 8), + ], + + // BCC field + if (_showCcBcc) ...[ + TextFormField( + controller: _bccController, + decoration: const InputDecoration(labelText: 'BCC'), + ), + const SizedBox(height: 8), + ], + + // Subject + TextFormField( + controller: _subjectController, + decoration: const InputDecoration(labelText: 'Subject'), + ), + const SizedBox(height: 8), + + // Attachments preview + if (_attachments.isNotEmpty) ...[ + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _attachments.length, + itemBuilder: (_, i) => Chip( + label: Text(_attachments[i]), + onDeleted: () => setState(() => _attachments.removeAt(i)), + ), + ), + ), + const SizedBox(height: 8), + ], + + // Body + Expanded( + child: TextFormField( + controller: _bodyController, + decoration: const InputDecoration( + hintText: 'Compose your email...', + border: InputBorder.none, + ), + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_pages/email_drawer.dart b/lib/pages/email_client/email_pages/email_drawer.dart new file mode 100644 index 00000000..317e22ed --- /dev/null +++ b/lib/pages/email_client/email_pages/email_drawer.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +//import 'package:campus_app/pages/email_client/email_drawer/archives.dart'; +//import 'package:campus_app/pages/email_client/email_drawer/drafts.dart'; +//import 'package:campus_app/pages/email_client/email_drawer/sent.dart'; +//import 'package:campus_app/pages/email_client/email_drawer/trash.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +// TODO: Create this page and import it +//import 'package:campus_app/pages/email_client/email_drawer/spam.dart'; +import 'package:campus_app/pages/email_client/email_pages/folder_emails_page.dart'; + + +class EmailDrawer extends StatelessWidget { + const EmailDrawer({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Watch so the drawer updates when folders arrive + final emailService = context.watch(); + + return Drawer( + child: Container( + color: theme.scaffoldBackgroundColor, + child: ListView( + padding: EdgeInsets.zero, + children: [ + // Drawer header with user info + DrawerHeader( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 25, + child: Icon(Icons.person, size: 30), + ), + const SizedBox(height: 10), + Text( + //'Your Name', + 'Mail' , // dynamic + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + //'you@example.com', + 'Folders from server',// dynamic + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + + // === Drawer navigation options === + //ListTile( + //leading: Icon(Icons.inbox, color: theme.iconTheme.color), + // title: Text('Inbox', style: theme.textTheme.bodyLarge), + // onTap: () => Navigator.pop(context), + //), + //_buildDrawerItem(context, icon: Icons.send, title: 'Sent', page: const SentPage()), + //_buildDrawerItem(context, icon: Icons.archive, title: 'Archives', page: const ArchivesPage()), + //_buildDrawerItem(context, icon: Icons.drafts, title: 'Drafts', page: const DraftsPage()), + //_buildDrawerItem(context, icon: Icons.delete, title: 'Trash', page: const TrashPage()), + + // === NEW: Spam folder === + //_buildDrawerItem( + //context, + //icon: Icons.report_gmailerrorred, + //title: 'Spam', + //page: const SpamPage(), // Make sure you define this page + //), + + //const Divider(), + + // === Folders from server (dynamic) === +if (emailService.userFolders.isEmpty) + ListTile( + leading: Icon(Icons.folder, color: theme.iconTheme.color), + title: Text('No folders loaded yet', style: theme.textTheme.bodyLarge), + subtitle: Text('Check connection or refresh', style: theme.textTheme.bodySmall), + ) +else + ...emailService.userFolders.map((folder) { + return ListTile( + leading: Icon(Icons.folder, color: theme.iconTheme.color), + title: Text(folder.displayName, style: theme.textTheme.bodyLarge), + subtitle: Text(folder.mailboxName, style: theme.textTheme.bodySmall), + onTap: () { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => FolderEmailsPage( + mailboxName: folder.mailboxName, + title: folder.displayName, + ), + ), + ); + }); + }, + ); + }), + +const Divider(), + + + // Settings option (placeholder) + ListTile( + leading: Icon(Icons.settings, color: theme.iconTheme.color), + title: Text('Settings', style: theme.textTheme.bodyLarge), + onTap: () { + Navigator.pop(context); + // TODO: Add SettingsPage navigation + }, + ), + + // === Logout with confirmation === + ListTile( + leading: Icon(Icons.logout, color: theme.colorScheme.error), + title: Text('Logout', style: TextStyle(color: theme.colorScheme.error)), + onTap: () => _confirmLogout(context), + ), + ], + ), + ), + ); + } + + /// Helper to create drawer items with consistent styling and navigation + Widget _buildDrawerItem( + BuildContext context, { + required IconData icon, + required String title, + required Widget page, + }) { + return ListTile( + leading: Icon(icon, color: Theme.of(context).iconTheme.color), + title: Text(title, style: Theme.of(context).textTheme.bodyLarge), + onTap: () { + Navigator.pop(context); // close drawer first + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => page), + ); + }); + }, + ); + } + + /// Show confirmation dialog before logging the user out + void _confirmLogout(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); // Close dialog + Navigator.pop(context); // Close drawer + + // Call logout logic from EmailAuthService and EmailService + final emailAuthService = context.read(); + final emailService = context.read(); + + await emailAuthService.logout(); + emailService.clear(); + }, + child: Text( + 'Logout', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/email_client/email_pages/email_page.dart b/lib/pages/email_client/email_pages/email_page.dart new file mode 100644 index 00000000..9737a35e --- /dev/null +++ b/lib/pages/email_client/email_pages/email_page.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_drawer.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/widgets/select_email.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +// Main entry widget for the email client screen +class EmailPage extends StatelessWidget { + const EmailPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _EmailClientContent(); + } +} + +// Internal stateful widget that handles authentication, email loading, and UI behavior +class _EmailClientContent extends StatefulWidget { + const _EmailClientContent(); + + @override + State<_EmailClientContent> createState() => _EmailClientContentState(); +} + +class _EmailClientContentState extends State<_EmailClientContent> { + final GlobalKey _scaffoldKey = GlobalKey(); + final TextEditingController _searchController = TextEditingController(); + final FlutterSecureStorage secureStorage = sl(); + + bool _isSearching = false; // True when search bar is active + bool _isLoading = true; // True while authenticating or initializing + bool _isAuthenticated = false; // True after successful login + late EmailSelectionController _selectionController; // Handles multi-select actions + + @override + void initState() { + super.initState(); + _initializeEmailClient(); // Start setup on load + } + + // Initialize email services and authentication + Future _initializeEmailClient() async { + final emailAuthService = Provider.of(context, listen: false); + final emailService = Provider.of(context, listen: false); + + // Set up selection controller with callbacks + _selectionController = EmailSelectionController( + onDelete: (emails) async { + emailService.moveEmailsToFolder(emails, EmailFolder.trash); // Move to Trash + _search(); // Refresh view + }, + onArchive: (emails) async { + emailService.moveEmailsToFolder(emails, EmailFolder.archives); // Move to Archives + _search(); + }, + onEmailUpdated: (email) async { + emailService.updateEmail(email); // Update state if email is modified + _search(); + }, + )..addListener(_onSelectionChanged); // Listen for selection state changes + + // Check stored credentials and try to authenticate + final isAuthenticated = await emailAuthService.isAuthenticated(); + + if (isAuthenticated) { + // If valid, initialize mailbox + await emailService.initialize(); + setState(() { + _isAuthenticated = true; + _isLoading = false; + }); + } else { + // Show login screen if not authenticated + setState(() { + _isLoading = false; + }); + } + } + + // Rebuild UI when selection changes + void _onSelectionChanged() => setState(() {}); + + // Rebuilds UI when a search is performed + void _search() { + setState(() {}); + } + + // Triggers the login screen and handles post-login setup + Future _handleLogin() async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginScreen( + loginType: LoginType.email, + customTitle: 'RubMail Login', + customDescription: 'Melde dich mit deinen RUB-Daten an, um auf deine E-Mails zuzugreifen.', + onLogin: (username, password) async { + final emailAuthService = Provider.of(context, listen: false); + await emailAuthService.authenticate(username, password); + }, + onLoginSuccess: () async { + final emailService = Provider.of(context, listen: false); + await emailService.initialize(); + setState(() { + _isAuthenticated = true; + }); + }, + ), + ), + ); + } + +/* + Future _handleLogout() async { + final emailAuthService = Provider.of(context, listen: false); + final emailService = Provider.of(context, listen: false); + + await emailAuthService.logout(); + emailService.clear(); + + setState(() { + _isAuthenticated = false; + }); + } + */ + + // Handles back/gesture navigation, exits selection/search/drawer as needed + Future _handlePop(BuildContext context) async { + if (_selectionController.isSelecting) { + _selectionController.clearSelection(); + return; + } + if (_isSearching) { + setState(() { + _isSearching = false; + _searchController.clear(); + }); + return; + } + if (_scaffoldKey.currentState?.isEndDrawerOpen ?? false) { + Navigator.of(context).pop(); + return; + } + Navigator.of(context).maybePop(); + } + + @override + void dispose() { + _selectionController.removeListener(_onSelectionChanged); + _selectionController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Show loading spinner while initializing + if (_isLoading) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + // Show login prompt if not authenticated + if (!_isAuthenticated) { + return Scaffold( + appBar: AppBar( + title: const Text('RubMail'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.email, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Willkommen bei RubMail', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Text( + 'Melde dich an, um auf deine E-Mails zuzugreifen', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _handleLogin, + child: const Text('Anmelden'), + ), + ], + ), + ), + ); + } + + final emailService = Provider.of(context); + final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); // Apply search filter + + return PopScope( + onPopInvoked: (didPop) async { + if (!didPop) await _handlePop(context); // Custom pop behavior + }, + child: Scaffold( + key: _scaffoldKey, + appBar: AppBar( + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'E-Mails durchsuchen...', + border: InputBorder.none, + ), + onChanged: (_) => _search(), // Update search results + ) + : const Text('RubMail'), + leading: _isSearching + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + _isSearching = false; + _searchController.clear(); + }); + }, + ) + : null, + actions: [ + if (!_isSearching && !_selectionController.isSelecting) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() => _isSearching = true), + ), + if (_selectionController.isSelecting) ...[ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: () => _selectionController.selectAll(filteredEmails), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _selectionController.clearSelection, + ), + ], + if (!_isSearching && !_selectionController.isSelecting) + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openEndDrawer(), + ), + ), + ], + ), + endDrawer: const EmailDrawer(), // Folder navigation drawer + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _selectionController.isSelecting ? _selectionController.clearSelection : null, + child: RefreshIndicator( + onRefresh: () async { + final emailService = Provider.of(context, listen: false); + await emailService.refreshEmails(); // Pull-to-refresh + _search(); // Re-apply search + }, + child: ListView.separated( + itemCount: filteredEmails.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + itemBuilder: (_, index) { + final email = filteredEmails[index]; + return EmailTile( + email: email, + isSelected: _selectionController.isSelected(email), + onTap: () { + if (_selectionController.isSelecting) { + setState(() => _selectionController.toggleSelection(email)); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + onDelete: (email) { + emailService.moveEmailsToFolder([email], EmailFolder.trash); + _search(); + }, + ), + ), + ); + } + }, + onLongPress: () { + setState(() => _selectionController.toggleSelection(email)); + }, + ); + }, + ), + ), + ), + floatingActionButton: _selectionController.isSelecting + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: 'delete', + onPressed: () => _selectionController.onDelete?.call(_selectionController.selectedEmails), + child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onPrimary), + ), + const SizedBox(width: 16), + FloatingActionButton( + heroTag: 'archive', + onPressed: () => _selectionController.onArchive?.call(_selectionController.selectedEmails), + child: Icon(Icons.archive, color: Theme.of(context).colorScheme.onPrimary), + ), + ], + ) + : FloatingActionButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), + ), + child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onPrimary), + ), + ), + ); + } +} + +/* +NOTES: +- some changes on the email client only appear on the app not in the actual Email. Like delete. +- Email inbox only loads a certain number of emails, loading takes a long time needs optimization. +- Drawer top needs to be fixed (name/Email display) +- Some Email bodies are not shown. +- sending emails and replying works. drafts also work. +- selection needs to be added to the drawer pages as well. the selection component is already implemented but + the use of options different than the inbox is needed. +- Setting need to be implemented +- Attachments need implementing as well. Some UI components for that are already implemented but these are only UI + as for the email view with attachments it needs to be further tested. +- Searching is implemented for the inbox but it should also be implemented for the drawer pages +*/ diff --git a/lib/pages/email_client/email_pages/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart new file mode 100644 index 00000000..44097353 --- /dev/null +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -0,0 +1,221 @@ +import 'package:campus_app/utils/widgets/styled_html.dart'; +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; + +// Displays a full view of an email, including sender info, subject, body, and actions (reply, delete, restore) +class EmailView extends StatelessWidget { + final Email email; // The email being viewed + final void Function(Email)? onDelete; // Optional callback for deletion + final void Function(Email)? onRestore; // Optional callback for restoring from trash + final bool isInTrash; // Whether the email is currently in the trash folder + + const EmailView({ + super.key, + required this.email, + this.onDelete, + this.onRestore, + this.isInTrash = false, + }); + + // Opens the compose screen with the current email as a reply + void _handleReply(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComposeEmailScreen(replyTo: email), + ), + ); + } + + // Shows confirmation dialog before permanently deleting the email + void _confirmPermanentDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Permanently Delete'), + content: const Text('This action is permanent. Are you sure?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), // Cancel action + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); // Close dialog + if (onDelete != null) { + onDelete!(email); // Perform delete + } + Navigator.pop(context); // Close email view + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email permanently deleted')), + ); + }, + child: Text( + 'Delete', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } + + // Handles restoring a trashed email + void _handleRestore(BuildContext context) { + if (onRestore != null) { + onRestore!(email); + Navigator.pop(context); // Close email view + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email restored from trash')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final timeText = '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}'; // Format time + + return Scaffold( + appBar: AppBar( + title: const Text('RubMail'), + actions: [ + if (!isInTrash) + IconButton( + icon: const Icon(Icons.reply), + onPressed: () => _handleReply(context), // Quick reply + tooltip: 'Reply', + ), + if (!isInTrash && onDelete != null) + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + onDelete!(email); // Soft delete (to trash) + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email moved to trash')), + ); + }, + tooltip: 'Delete', + ), + if (isInTrash) + IconButton( + icon: const Icon(Icons.restore_from_trash), + onPressed: () => _handleRestore(context), // Restore from trash + tooltip: 'Restore', + ), + if (isInTrash) + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () => _confirmPermanentDelete(context), // Permanent delete + tooltip: 'Permanently Delete', + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header section with sender info and timestamp + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.sender, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + if (email.senderEmail.isNotEmpty) + Text( + email.senderEmail, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + Text( + timeText, // Display formatted time + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Subject line + Text( + email.subject, + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // Email body (HTML if available, fallback to plain text) + if (email.htmlBody != null && email.htmlBody!.isNotEmpty) + StyledHTML( + text: email.htmlBody!, + context: context, + ) + else + Text( + email.body, + style: theme.textTheme.bodyLarge, + ), + + // Attachments section + if (email.attachments.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Attachments (${email.attachments.length})', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: email.attachments.length, + itemBuilder: (context, index) => Container( + width: 80, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.insert_drive_file, size: 30, color: theme.iconTheme.color), + const SizedBox(height: 4), + Text( + 'File ${index + 1}', // Display file number + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + floatingActionButton: !isInTrash + ? FloatingActionButton( + onPressed: () => _handleReply(context), // FAB for quick reply + tooltip: 'Reply', + child: const Icon(Icons.reply), + ) + : null, + ); + } +} diff --git a/lib/pages/email_client/email_pages/folder_emails_page.dart b/lib/pages/email_client/email_pages/folder_emails_page.dart new file mode 100644 index 00000000..d61b4c86 --- /dev/null +++ b/lib/pages/email_client/email_pages/folder_emails_page.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; + +class FolderEmailsPage extends StatelessWidget { + final String mailboxName; + final String title; + + const FolderEmailsPage({ + super.key, + required this.mailboxName, + required this.title, + }); + + @override + Widget build(BuildContext context) { + final emailService = context.watch(); + + final emails = emailService.getEmailsForMailbox(mailboxName); + + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: emails.isEmpty + ? const Center(child: Text('No emails')) + : ListView.builder( + itemCount: emails.length, + itemBuilder: (context, index) { + final email = emails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart new file mode 100644 index 00000000..dd25f0d9 --- /dev/null +++ b/lib/pages/email_client/models/email.dart @@ -0,0 +1,144 @@ +// This file defines the data model of an email (structure) +// by defining a data class that represents an email's properties +class Email { + final String id; // Unique identifier for the email + final String sender; // Display name of the sender + final String senderEmail; // Sender's email address + final List recipients; // List of recipient email addresses + final String subject; // Subject line of the email + final String body; // Plain text body content + final String? htmlBody; // Optional HTML version of the body + final DateTime date; // Timestamp of when the email was sent + final bool isUnread; // Whether the email is unread + final bool isStarred; // Whether the email is marked as important/starred + final List attachments; // Filenames of any attachments + final EmailFolder folder; // The folder where this email is stored + final String? mailboxName; // real IMAP mailbox this email came from + // Added for IMAP operations + final int uid; // IMAP UID for server operations (used to identify emails remotely) + + const Email({ + required this.id, + required this.sender, + required this.senderEmail, + required this.recipients, + required this.subject, + required this.body, + this.htmlBody, + required this.date, + this.isUnread = false, + this.isStarred = false, + this.attachments = const [], + this.folder = EmailFolder.inbox, + this.mailboxName, + this.uid = 0, // Default to 0 for local/dummy emails + }); + + // Factory method for generating sample/mock emails (used for testing or UI previews) + factory Email.dummy(int index) => Email( + id: index.toString(), + sender: 'Sender $index', + senderEmail: 'sender$index@example.com', + recipients: ['recipient$index@example.com'], + subject: 'Subject line $index', + body: 'This is the body content of email $index.\n\n' + 'It contains multiple paragraphs of sample text.\n\n' + 'Best regards,\nSender $index', + date: DateTime.now().subtract(Duration(hours: index)), + isUnread: index % 2 == 0, + isStarred: index % 3 == 0, + attachments: index % 4 == 0 ? ['document$index.pdf', 'image$index.jpg'] : [], + uid: 0, // Dummy emails don't have IMAP UIDs + ); + + // Convert Email instance to a JSON map for storage or network transmission + Map toJson() => { + 'id': id, + 'sender': sender, + 'senderEmail': senderEmail, + 'recipients': recipients, + 'subject': subject, + 'body': body, + 'htmlBody': htmlBody, + 'date': date.toIso8601String(), + 'isRead': !isUnread, // Stored as "isRead" for clarity + 'isStarred': isStarred, + 'attachments': attachments, + 'folder': folder.name, + 'uid': uid, + }; + + // Create an Email instance from a JSON map + factory Email.fromJson(Map json) => Email( + id: json['id'], + sender: json['sender'], + senderEmail: json['senderEmail'], + recipients: List.from(json['recipients']), + subject: json['subject'], + body: json['body'], + htmlBody: json['htmlBody'], + date: DateTime.parse(json['date']), + isUnread: !json['isRead'], + isStarred: json['isStarred'], + attachments: List.from(json['attachments']), + folder: EmailFolder.values.byName(json['folder']), + mailboxName: json['mailboxName'], + uid: json['uid'] ?? 0, + ); + + // Create a modified copy of the current Email instance + Email copyWith({ + String? id, + String? sender, + String? senderEmail, + List? recipients, + String? subject, + String? body, + String? htmlBody, + DateTime? date, + bool? isUnread, + bool? isStarred, + List? attachments, + EmailFolder? folder, + String? mailboxName, + int? uid, + bool? isRead, // Optional override using isRead instead of isUnread + }) => + Email( + id: id ?? this.id, + sender: sender ?? this.sender, + senderEmail: senderEmail ?? this.senderEmail, + recipients: recipients ?? this.recipients, + subject: subject ?? this.subject, + body: body ?? this.body, + htmlBody: htmlBody ?? this.htmlBody, + date: date ?? this.date, + isUnread: isRead != null ? !isRead : (isUnread ?? this.isUnread), + isStarred: isStarred ?? this.isStarred, + attachments: attachments ?? this.attachments, + folder: folder ?? this.folder, + mailboxName: mailboxName ?? this.mailboxName, + uid: uid ?? this.uid, + ); + + // Shortened preview text of the email body + String get preview { + return body.length > 50 ? '${body.substring(0, 50)}...' : body; + } + + // Convenience getters for easier access in UI and logic + bool get isRead => !isUnread; // Inverted boolean for clarity + bool get hasAttachments => attachments.isNotEmpty; + String get senderName => sender; // Alias for UI usage + DateTime get timestamp => date; // Alias for sorting or displaying +} + +// Enum representing standard email folders +enum EmailFolder { + inbox, + sent, + drafts, + trash, + archives, + spam, +} diff --git a/lib/pages/email_client/repositories/email_repository.dart b/lib/pages/email_client/repositories/email_repository.dart new file mode 100644 index 00000000..31b986d1 --- /dev/null +++ b/lib/pages/email_client/repositories/email_repository.dart @@ -0,0 +1,57 @@ +// Imports the Email model used throughout the email operations +import 'package:campus_app/pages/email_client/models/email.dart'; + +// Abstract repository defining the interface for any email backend (e.g., IMAP) +abstract class EmailRepository { + // Establish connection to the email server + Future connect(String username, String password); + + // Disconnect from the email server + Future disconnect(); + + // Fetch a list of emails from a specified mailbox (e.g., INBOX) + Future> fetchEmails({required String mailboxName, int count = 50}); + + // Send a new email with optional cc/bcc fields + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + }); + + // Mark a specific email as read using its UID + Future markAsRead(int uid); + + // Mark a specific email as unread + Future markAsUnread(int uid); + + // Delete a specific email from a mailbox (defaults to INBOX) + Future deleteEmail(int uid, { required String mailboxName}); + + // Move an email to a different mailbox (e.g., Archive, Trash) + Future moveEmail(int uid, String targetMailbox); + + // Search emails based on query params in a specific mailbox + Future> searchEmails({ + String? query, + String? from, + String? subject, + bool unreadOnly = false, + //String mailboxName = 'INBOX', + required String mailboxName, + }); + + // Check if a connection to the server is active + bool get isConnected; + + // Save or update a draft email on the server + Future saveDraft(Email draft); + + // Fetch drafts from the "Drafts" mailbox + Future> fetchDrafts({int count = 50}); + + // list all mailboxes folders available on the server + Future> listMailboxes(); +} diff --git a/lib/pages/email_client/repositories/imap_email_repository.dart b/lib/pages/email_client/repositories/imap_email_repository.dart new file mode 100644 index 00000000..17b65323 --- /dev/null +++ b/lib/pages/email_client/repositories/imap_email_repository.dart @@ -0,0 +1,111 @@ +// Imports required Email model, interface, and IMAP service implementation +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; + +// Concrete implementation of EmailRepository using IMAP protocol +class ImapEmailRepository implements EmailRepository { + final ImapEmailService _imapService; + + // Constructor injection of the IMAP email service + ImapEmailRepository(this._imapService); + + @override + Future connect(String username, String password) { + // Connect to the email server using credentials + return _imapService.connect(username, password); + } + + @override + Future disconnect() { + // Disconnect from the email server + return _imapService.disconnect(); + } + + @override + Future> fetchEmails({required String mailboxName, int count = 50}) { + // Fetch emails from a specific mailbox + return _imapService.fetchEmails(mailboxName: mailboxName, count: count); + } + + @override + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + }) { + // Send an email with optional cc/bcc + return _imapService.sendEmail( + to: to, + subject: subject, + body: body, + cc: cc, + bcc: bcc, + ); + } + + @override + Future markAsRead(int uid) { + // Mark an email as read + return _imapService.markAsRead(uid); + } + + @override + Future markAsUnread(int uid) { + // Mark an email as unread + return _imapService.markAsUnread(uid); + } + + @override + Future deleteEmail(int uid, { required String mailboxName }) { + // Delete email from specified mailbox + return _imapService.deleteEmail(uid, mailboxName: mailboxName); + } + + @override + Future moveEmail(int uid, String targetMailbox) { + // Move email to another mailbox + return _imapService.moveEmail(uid, targetMailbox); + } + @override + Future> listMailboxes() async { + try { + // Holen der Mailboxen vom IMAP-Service + final mailboxes = await _imapService.getMailboxes(); + return mailboxes; // Rückgabe der Liste der Mailboxen + } catch (e) { + // Fehlerbehandlung, falls das Abrufen der Mailboxen fehlschlägt + throw Exception('Fehler beim Abrufen der Mailboxen: $e'); + } +} + + @override + Future> searchEmails({ + String? query, + String? from, + String? subject, + bool unreadOnly = false, + String mailboxName = 'INBOX', + }) { + // Search emails based on filters + return _imapService.searchEmails( + query: query, + from: from, + subject: subject, + unreadOnly: unreadOnly, + mailboxName: mailboxName, + ); + } + + @override + bool get isConnected => _imapService.isConnected; // Proxy for connection state + + @override + Future saveDraft(Email draft) => _imapService.appendDraft(draft); // Save draft email + + @override + Future> fetchDrafts({int count = 50}) => + _imapService.fetchEmails(mailboxName: 'Drafts', count: count); // Fetch emails from "Drafts" folder +} diff --git a/lib/pages/email_client/services/email_auth_service.dart b/lib/pages/email_client/services/email_auth_service.dart new file mode 100644 index 00000000..0ac13931 --- /dev/null +++ b/lib/pages/email_client/services/email_auth_service.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/core/exceptions.dart'; + +// Service to handle email-based authentication using secure storage +class EmailAuthService extends ChangeNotifier { + final FlutterSecureStorage _secureStorage = sl(); + + // Storage keys + static const String _emailUsernameKey = 'email_loginId'; + static const String _emailPasswordKey = 'email_password'; + static const String _isAuthenticatedKey = 'email_is_authenticated'; + + // Internal state + bool _isAuthenticated = false; + String? _currentUsername; + String? _currentPassword; + + // Public getters + bool get isAuthenticatedSync => _isAuthenticated; + String? get currentUsername => _currentUsername; + + // Check if user is authenticated (reads from secure storage) + Future isAuthenticated() async { + try { + final authStatus = await _secureStorage.read(key: _isAuthenticatedKey); + final username = await _secureStorage.read(key: _emailUsernameKey); + final password = await _secureStorage.read(key: _emailPasswordKey); + + _isAuthenticated = authStatus == 'true' && username != null && password != null; + + if (_isAuthenticated) { + _currentUsername = username; + _currentPassword = password; + } + + notifyListeners(); + return _isAuthenticated; + } catch (e) { + _isAuthenticated = false; + notifyListeners(); + return false; + } + } + + // Authenticate and store credentials securely + Future authenticate(String username, String password) async { + try { + if (username.isEmpty || password.isEmpty) { + throw InvalidLoginIDAndPasswordException(); + } + + // Simulate API call to RUB email service + await _validateEmailCredentials(username, password); + + // Save credentials + await _secureStorage.write(key: _emailUsernameKey, value: username); + await _secureStorage.write(key: _emailPasswordKey, value: password); + await _secureStorage.write(key: _isAuthenticatedKey, value: 'true'); + + _currentUsername = username; + _currentPassword = password; + _isAuthenticated = true; + + notifyListeners(); + } catch (e) { + await logout(); // Clear state on failure + rethrow; + } + } + + // Simulated email credential validation + Future _validateEmailCredentials(String username, String password) async { + await Future.delayed(const Duration(seconds: 1)); // Simulate network delay + + if (username.length < 3 || password.length < 6) { + throw InvalidLoginIDAndPasswordException(); + } + } + + // Return current credentials if authenticated + Future?> getCredentials() async { + if (!_isAuthenticated) { + await isAuthenticated(); // Ensure auth state is current + } + + if (_isAuthenticated && _currentUsername != null && _currentPassword != null) { + return { + 'username': _currentUsername!, + 'password': _currentPassword!, + }; + } + + return null; + } + + // Log out and clear stored credentials + Future logout() async { + try { + await _secureStorage.delete(key: _emailUsernameKey); + await _secureStorage.delete(key: _emailPasswordKey); + await _secureStorage.delete(key: _isAuthenticatedKey); + } catch (e) { + debugPrint('Error clearing credentials: $e'); + } + + _currentUsername = null; + _currentPassword = null; + _isAuthenticated = false; + + notifyListeners(); + } + + // Refresh authentication state + Future refresh() async { + await isAuthenticated(); + } + + // Validate currently stored credentials + Future validateCurrentCredentials() async { + if (!_isAuthenticated || _currentUsername == null || _currentPassword == null) { + return false; + } + + try { + await _validateEmailCredentials(_currentUsername!, _currentPassword!); + return true; + } catch (e) { + await logout(); // Invalidate session on failure + return false; + } + } +} diff --git a/lib/pages/email_client/services/email_background_service.dart b/lib/pages/email_client/services/email_background_service.dart new file mode 100644 index 00000000..8bbf011f --- /dev/null +++ b/lib/pages/email_client/services/email_background_service.dart @@ -0,0 +1,152 @@ +import 'package:flutter/foundation.dart'; +import 'package:background_fetch/background_fetch.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/repositories/imap_email_repository.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class EmailBackgroundService { + static final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + + static Future?> _loadCredentials() async { + const storage = FlutterSecureStorage(); + + final username = await storage.read(key: 'email_loginId'); + final password = await storage.read(key: 'email_password'); + final isAuth = await storage.read(key: 'email_is_authenticated'); + + if (username != null && password != null && isAuth == 'true') { + return { + 'username': username, + 'password': password, + }; + } + + return null; +} + + /// Call this once at app start (main) + static Future init() async { + await _initNotifications(); + await _initBackgroundFetch(); + } + + static Future _initNotifications() async { + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + const settings = InitializationSettings(android: android); + + await _notifications.initialize(settings); + } + + static Future _initBackgroundFetch() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, // minutes (Android best-effort) + stopOnTerminate: false, + enableHeadless: true, + startOnBoot: true, + requiredNetworkType: NetworkType.ANY, + ), + _onBackgroundFetch, + _onBackgroundTimeout, + ); + + // Optional: run once immediately for testing + await BackgroundFetch.start(); + } + + static void _onBackgroundTimeout(String taskId) { + BackgroundFetch.finish(taskId); + } + + /// Runs while app is in background (not killed) + static Future _onBackgroundFetch(String taskId) async { + try { + await fetchEmailsAndNotify(); + } catch (e) { + debugPrint('Background fetch error: $e'); + } finally { + BackgroundFetch.finish(taskId); + } + } + + /// Runs when app is terminated (Android headless) + static Future headlessTask(HeadlessTask task) async { + final taskId = task.taskId; + try { + await fetchEmailsAndNotify(); + } catch (e) { + debugPrint('Headless fetch error: $e'); + } finally { + BackgroundFetch.finish(taskId); + } + } + + // fetch newest inbox mails + notify if new + static Future fetchEmailsAndNotify() async { + final credentials = await _loadCredentials(); + if (credentials == null) return; + + final username = credentials['username']!; + final password = credentials['password']!; + + final EmailRepository repo = ImapEmailRepository(ImapEmailService()); + + final ok = await repo.connect(username, password); + if (!ok) return; + + final emails = await repo.fetchEmails(mailboxName: 'INBOX', count: 10); + + await repo.disconnect(); + + if (emails.isEmpty) return; + +final prefs = await SharedPreferences.getInstance(); + + +final sorted = List.from(emails) + ..sort((a, b) => b.uid.compareTo(a.uid)); + +final newest = sorted.first; +final newestUid = newest.uid; + +final lastSeenUid = prefs.getInt('last_seen_inbox_uid') ?? 0; + +if (newestUid > lastSeenUid) { + await prefs.setInt('last_seen_inbox_uid', newestUid); + + await _showNotification( + title: newest.subject.isNotEmpty ? newest.subject : 'New Email', + body: newest.sender.isNotEmpty + ? 'From: ${newest.sender}' + : 'You received a new email.', + ); +} +} + + static Future _showNotification({ + required String title, + required String body, + }) async { + const android = AndroidNotificationDetails( + 'email_channel', + 'Email Updates', + channelDescription: 'Notifications for new incoming emails', + importance: Importance.high, + priority: Priority.high, + ); + + const details = NotificationDetails(android: android); + + await _notifications.show( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + title, + body, + details, + ); + } +} \ No newline at end of file diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart new file mode 100644 index 00000000..8cb2cec1 --- /dev/null +++ b/lib/pages/email_client/services/email_service.dart @@ -0,0 +1,495 @@ +import 'package:flutter/foundation.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/select_email.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/core/injection.dart'; + +// Represents a mailbox/folder returned by the mail server. + + +class UserEmailFolder { + final String mailboxName; // Real server mailbox name (IMAP path) + final String displayName; // Friendly name shown in the UI + + UserEmailFolder({ + required this.mailboxName, + required this.displayName, + }); +} + +class EmailService extends ChangeNotifier { + + final List _allEmails = []; + + final List _userFolders = []; + // Stores resolved mailbox names for system folders. + // This starts empty and is only filled if a matching mailbox exists on the server. + final Map _folderMailboxNames = {}; + + final EmailSelectionController _selectionController = EmailSelectionController(); + + final EmailAuthService _authService = sl(); + final EmailRepository _emailRepository; + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + List get allEmails => List.unmodifiable(_allEmails); + List get userFolders => List.unmodifiable(_userFolders); + EmailSelectionController get selectionController => _selectionController; + + + EmailService(this._emailRepository) { + _selectionController.addListener(notifyListeners); + } + + + // Called once when the email client starts. + // Connects to the server, loads folder list, resolves system folders, then loads emails. + Future initialize() async { + try { + final credentials = await _authService.getCredentials(); + if (credentials == null) { + throw Exception('No credentials found'); + } + + await _connectToEmailServer( + credentials['username']!, + credentials['password']!, + ); + + _isInitialized = true; + notifyListeners(); + + // Load folders dynamically + await loadUserFolders(); + + // Load emails for resolved system folders + await refreshEmails(); + } catch (e) { + _isInitialized = false; + notifyListeners(); + rethrow; + } + } + + Future _connectToEmailServer(String username, String password) async { + final success = await _emailRepository.connect(username, password); + if (!success) { + throw Exception('Failed to connect to email server'); + } + } + + void clear() { + _allEmails.clear(); + _userFolders.clear(); + _folderMailboxNames.clear(); + _isInitialized = false; + _emailRepository.disconnect(); + notifyListeners(); + } + + @override + void dispose() { + _selectionController.dispose(); + _emailRepository.disconnect(); + super.dispose(); + } + + + + // Loads all mailbox names from the server. + // We keep them for the drawer/UI and also try to detect system folders. + Future loadUserFolders() async { + if (!_isInitialized) { + throw Exception('Email service not initialized'); + } + + try { + final mailboxes = await _emailRepository.listMailboxes(); + + // Try to resolve which mailbox is Inbox/Sent/Drafts/Trash/Spam/Archive on THIS server. + _resolveSystemMailboxes(mailboxes); + + // Store all mailboxes for UI (including nested ones) + _userFolders + ..clear() + ..addAll( + mailboxes + .where((mb) => mb.trim().isNotEmpty) + .map( + (mb) => UserEmailFolder( + mailboxName: mb, + displayName: _extractDisplayName(mb), + ), + ), + ); + + notifyListeners(); + } catch (e) { + // Folder loading should not crash the app. Worst case is drawer stays minimal. + debugPrint('Failed to load folders: $e'); + } + } + + // Extracts a friendly display name from a mailbox path. + + String _extractDisplayName(String mailboxName) { + final normalized = mailboxName.replaceAll('\\', '/'); + final parts = normalized.split(RegExp(r'[/.]')); + return parts.isNotEmpty ? parts.last : mailboxName; + } + + // Returns the resolved mailbox name for a system folder, or null if not found. + String? _getMailboxNameForFolder(EmailFolder folder) => _folderMailboxNames[folder]; + + // Helper to find a mailbox by common aliases. + // We match either exact name or "endsWith" (because servers often return paths like "INBOX/Sent"). + void _resolveSystemMailboxes(List mailboxes) { + String? findMatch(List candidates) { + for (final mb in mailboxes) { + final lower = mb.toLowerCase(); + for (final c in candidates) { + final cl = c.toLowerCase(); + if (lower == cl || lower.endsWith(cl)) { + return mb; + } + } + } + return null; + } + + // Inbox is special: almost every server has it. + final inbox = findMatch(['inbox']); + if (inbox != null) _folderMailboxNames[EmailFolder.inbox] = inbox; + + final sent = findMatch([ + 'sent', + 'sent messages', + 'gesendet', + 'inbox.sent', + 'inbox/sent', + ]); + if (sent != null) _folderMailboxNames[EmailFolder.sent] = sent; + + final drafts = findMatch([ + 'drafts', + 'entwürfe', + 'inbox.drafts', + 'inbox/drafts', + ]); + if (drafts != null) _folderMailboxNames[EmailFolder.drafts] = drafts; + + final trash = findMatch([ + 'trash', + 'deleted', + 'papierkorb', + 'inbox.trash', + 'inbox/trash', + ]); + if (trash != null) _folderMailboxNames[EmailFolder.trash] = trash; + + final spam = findMatch([ + 'spam', + 'junk', + 'uce-tmp', + 'inbox.spam', + 'inbox/spam', + ]); + if (spam != null) _folderMailboxNames[EmailFolder.spam] = spam; + + final archives = findMatch([ + 'archive', + 'archives', + 'archiv', + 'inbox.archive', + 'inbox/archive', + ]); + if (archives != null) _folderMailboxNames[EmailFolder.archives] = archives; + } + + + + // Reloads emails from the server for all system folders that were actually resolved. + Future refreshEmails() async { + if (!_isInitialized) { + throw Exception('Email service not initialized'); + } + + try { + await _fetchEmailsFromServer(); + notifyListeners(); + } catch (e) { + // If auth dies, force logout so the UI can ask for login again. + if (e.toString().toLowerCase().contains('authentication')) { + await _authService.logout(); + _isInitialized = false; + await _emailRepository.disconnect(); + notifyListeners(); + } + rethrow; + } + } + + // Loads emails for each resolved system folder. + // If a folder could not be resolved on this server, we simply skip it. + Future _fetchEmailsFromServer() async { + _allEmails.clear(); + + final foldersToLoad = [ + EmailFolder.inbox, + EmailFolder.sent, + EmailFolder.drafts, + EmailFolder.trash, + EmailFolder.spam, + EmailFolder.archives, + ]; + + for (final folder in foldersToLoad) { + final mailbox = _getMailboxNameForFolder(folder); + if (mailbox == null) { + // Not found on server → don't crash just skip. + continue; + } + await _fetchEmailsForMailbox (folder, mailbox); + } + } + + // Fetches emails from a single mailbox and merges them into the local cache. + Future _fetchEmailsForMailbox(EmailFolder folder, String mailboxName) async { + try { + final count = folder == EmailFolder.inbox ? 50 : 30; + final emails = await _emailRepository.fetchEmails( + mailboxName: mailboxName, + count: count, + ); + + for (final email in emails) { + _allEmails.add( + email.copyWith( + folder: folder, + mailboxName: mailboxName, + ), + ); + } + } catch (e) { + debugPrint('Failed to fetch ${folder.name} from "$mailboxName": $e'); + } + } + + + Future markAsRead(Email email) async { + if (!_isInitialized || email.uid == 0) return; + final success = await _emailRepository.markAsRead(email.uid); + if (success) updateEmail(email.copyWith(isRead: true)); + } + + Future markAsUnread(Email email) async { + if (!_isInitialized || email.uid == 0) return; + final success = await _emailRepository.markAsUnread(email.uid); + if (success) updateEmail(email.copyWith(isRead: false)); + } + + // Moves an email to trash if possible. + // If Trash is not resolved on this server, we fall back to a local-only move. + Future deleteEmail(Email email) async { + if (!_isInitialized || email.uid == 0) return; + + final trashMailbox = _getMailboxNameForFolder(EmailFolder.trash); + if (trashMailbox == null) { + // Server doesn't expose Trash → local fallback + updateEmail(email.copyWith(folder: EmailFolder.trash)); + return; + } + + // If the email is already in trash, try to delete permanently from that mailbox. + if (email.folder == EmailFolder.trash) { + final success = await _emailRepository.deleteEmail(email.uid, mailboxName: trashMailbox); + if (success) { + _allEmails.removeWhere((e) => e.id == email.id); + notifyListeners(); + } + return; + } + + // Otherwise move it to trash. + final moved = await _emailRepository.moveEmail(email.uid, trashMailbox); + if (moved) updateEmail(email.copyWith(folder: EmailFolder.trash)); + } + + // Moves multiple emails to a target folder . + // If the server mailbox for that folder isn't resolved, we do a local-only move. + void moveEmailsToFolder(Iterable emails, EmailFolder folder) { + final targetMailbox = _getMailboxNameForFolder(folder); + + for (final email in emails) { + if (_isInitialized && email.uid != 0 && targetMailbox != null) { + _emailRepository.moveEmail(email.uid, targetMailbox).catchError((_) {}); + } + + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: folder, mailboxName: targetMailbox ?? email.mailboxName,); + } + } + + notifyListeners(); + } + + /// Sends a new email and refreshes Sent if the server has it. + Future sendEmail({ + required String to, + required String subject, + required String body, + String? cc, + String? bcc, + }) async { + if (!_isInitialized) throw Exception('Email service not initialized'); + + final success = await _emailRepository.sendEmail( + to: to, + subject: subject, + body: body, + cc: cc?.split(',').map((e) => e.trim()).toList(), + bcc: bcc?.split(',').map((e) => e.trim()).toList(), + ); + + if (!success) throw Exception('Failed to send email'); + + // Refresh Sent only if we know the mailbox name + final sentMailbox = _getMailboxNameForFolder(EmailFolder.sent); + if (sentMailbox != null) { + _allEmails.removeWhere((e) => e.folder == EmailFolder.sent); + await _fetchEmailsForMailbox(EmailFolder.sent, sentMailbox); + notifyListeners(); + } + } + + // Searches emails in a folder (only works if that folder has a resolved mailbox). + Future> searchEmails({ + String? query, + String? from, + String? subject, + EmailFolder? folder, + bool unreadOnly = false, + }) async { + if (!_isInitialized) return []; + + final targetFolder = folder ?? EmailFolder.inbox; + final mailboxName = _getMailboxNameForFolder(targetFolder); + + if (mailboxName == null) { + // Folder not available on server, return empty result + return []; + } + + final results = await _emailRepository.searchEmails( + query: query, + from: from, + subject: subject, + unreadOnly: unreadOnly, + mailboxName: mailboxName, + ); + + return results.map((e) => e.copyWith(folder: targetFolder, mailboxName: mailboxName)).toList(); + } + + // Saves or updates a draft locally and on the server . + Future saveOrUpdateDraft(Email draft) async { + // Local update first (fast UI feedback) + if (_isDraftEmpty(draft)) { + _allEmails.removeWhere((e) => e.id == draft.id); + notifyListeners(); + return; + } + + final updatedDraft = draft.copyWith(folder: EmailFolder.drafts); + final index = _allEmails.indexWhere((e) => e.id == draft.id); + if (index != -1) { + _allEmails[index] = updatedDraft; + } else { + _allEmails.add(updatedDraft); + } + notifyListeners(); + + // Server update (best effort) + final draftsMailbox = _getMailboxNameForFolder(EmailFolder.drafts); + if (draftsMailbox == null) { + // Server doesn't expose Drafts → keep local only + return; + } + + try { + await _emailRepository.saveDraft(draft); + } catch (e) { + debugPrint('Failed to save draft on server: $e'); + } + } + + void removeDraft(String draftId) { + final draft = _allEmails.firstWhere( + (e) => e.id == draftId && e.folder == EmailFolder.drafts, + orElse: () => Email( + id: '', + sender: '', + senderEmail: '', + recipients: [], + subject: '', + body: '', + date: DateTime.now(), + ), + ); + + final draftsMailbox = _getMailboxNameForFolder(EmailFolder.drafts); + if (_isInitialized && draft.uid != 0 && draft.id.isNotEmpty && draftsMailbox != null) { + _emailRepository.deleteEmail(draft.uid, mailboxName: draftsMailbox).catchError((_) {}); + } + + _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); + notifyListeners(); + } + + bool _isDraftEmpty(Email draft) { + return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; + } + + + + List filterEmails(String query, EmailFolder folder) { + final filtered = _allEmails.where((e) => e.folder == folder).toList(); + if (query.isEmpty) return filtered; + + final q = query.toLowerCase(); + return filtered.where((email) { + return email.sender.toLowerCase().contains(q) || + email.subject.toLowerCase().contains(q); + }).toList(); + } + List getEmailsForMailbox(String mailboxName) { + final target = mailboxName.trim().toLowerCase(); + + return _allEmails.where((email) { + return (email.mailboxName ?? '') + .trim() + .toLowerCase() == target; + }).toList(); +} + void updateEmail(Email updatedEmail) { + final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); + if (index != -1) { + _allEmails[index] = updatedEmail; + notifyListeners(); + } + } + // unredCount dynamic + int get unreadCount { + final inboxMailbox = _getMailboxNameForFolder(EmailFolder.inbox); + if (inboxMailbox == null) return 0; + + return _allEmails.where((e) { + return (e.mailboxName ?? '').trim() == inboxMailbox.trim() && !e.isRead; + }).length; +} + +} diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart new file mode 100644 index 00000000..c1918e25 --- /dev/null +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class ImapEmailService { + ImapClient? _imapClient; + SmtpClient? _smtpClient; + String? _username; + String? _password; + + // IMAP/SMTP server configuration + static const String _imapHost = 'mail.ruhr-uni-bochum.de'; + static const int _imapPort = 993; + static const String _smtpHost = 'mail.ruhr-uni-bochum.de'; + static const int _smtpPort = 587; + + bool get isConnected => _imapClient?.isConnected ?? false; + + // Connects to the IMAP server and logs in. + Future connect(String username, String password) async { + _imapClient = ImapClient(isLogEnabled: true); + try { + _username = username; + _password = password; + await _imapClient!.connectToServer(_imapHost, _imapPort, isSecure: true); + await _imapClient!.login(_username!, _password!); + debugPrint('IMAP: Connected as $_username'); + return true; + } catch (e) { + debugPrint('IMAP: Connection/login failed: $e'); + return false; + } + } + + // Disconnects both IMAP and SMTP clients cleanly. + Future disconnect() async { + try { + await _imapClient?.disconnect(); + await _smtpClient?.disconnect(); + } catch (e) { + debugPrint('Disconnect error: $e'); + } finally { + _imapClient = null; + _smtpClient = null; + _username = null; + _password = null; + } + } + + // Fetches [count] messages from [mailboxName], newest-first paging. + Future> fetchEmails({ + String mailboxName = 'INBOX', + int count = 50, + int page = 1, + }) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); + } + // 1) Select mailbox + final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); + final total = mailbox.messagesExists; + if (total == 0) return []; + + // 2) Determine sequence range + final start = math.max(1, total - (page * count) + 1); + final end = math.min(total, total - ((page - 1) * count)); + + // 3) Fetch headers and body peek + final result = await _imapClient!.fetchMessages( + MessageSequence.fromRange(start, end), + '(BODY.PEEK[HEADER] BODY.PEEK[TEXT])', + ); + + // 4) Convert and reverse for newest-first order + final emails = await Future.wait( + result.messages.map(_convertMimeMessageToEmail), + ); + return emails.reversed.toList(); + } + + // Fetches a single email by its UID. + Future fetchEmailByUid(int uid, {String mailboxName = 'INBOX'}) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); + } + await _imapClient!.selectMailboxByPath(mailboxName); + final result = await _imapClient!.uidFetchMessage(uid, 'BODY[]'); + if (result.messages.isEmpty) return null; + return await _convertMimeMessageToEmail(result.messages.first); + } + + // Sends an email via SMTP, then appends it into the IMAP “Sent” folder. + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + List? attachments, + }) async { + try { + // ─── 1) Ensure SMTP connection + full STARTTLS handshake ───────────── + if (_smtpClient == null || !_smtpClient!.isConnected) { + _smtpClient = SmtpClient('RUB-Flutter-Client', isLogEnabled: true); + + // Connect without TLS + await _smtpClient!.connectToServer(_smtpHost, _smtpPort, isSecure: false); + // Advertise capabilities + await _smtpClient!.ehlo(); + // Upgrade to TLS + await _smtpClient!.startTls(); + // Re-advertise capabilities after TLS + await _smtpClient!.ehlo(); + // Authenticate + await _smtpClient!.authenticate(_username!, _password!, AuthMechanism.login); + } + + // ─── 2) Build the MIME message ──────────────────────────────────────── + final builder = MessageBuilder.prepareMultipartAlternativeMessage(plainText: body) + ..from = [ + MailAddress( + '', + _username!.contains('@') ? _username! : '$_username@ruhr-uni-bochum.de', + ) + ] + ..to = [MailAddress('', to)] + ..subject = subject; + + if (cc?.isNotEmpty ?? false) { + builder.cc = cc!.map((addr) => MailAddress('', addr)).toList(); + } + if (bcc?.isNotEmpty ?? false) { + builder.bcc = bcc!.map((addr) => MailAddress('', addr)).toList(); + } + + // TODO: handle attachments if needed + + final mimeMessage = builder.buildMimeMessage(); + + // ─── 3) Send the message ─────────────────────────────────────────────── + await _smtpClient!.sendMessage(mimeMessage); + debugPrint('SMTP: Message sent'); + + // ─── 4) Append to IMAP “Sent” folder ────────────────────────────────── + if (_imapClient != null && _imapClient!.isConnected) { + try { + await _imapClient!.selectMailboxByPath('Sent'); + await _imapClient!.appendMessage( + mimeMessage, + flags: [MessageFlags.seen], // mark as read in Sent + ); + debugPrint('IMAP: Appended message to Sent'); + } catch (e) { + debugPrint('IMAP: Failed to append to Sent: $e'); + } + } + + return true; + } catch (e) { + debugPrint('sendEmail error: $e'); + return false; + } + } + + // Appends (or updates) a draft in the IMAP “Drafts” folder. + Future appendDraft(Email draft) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); + } + // Select the Drafts mailbox + await _imapClient!.selectMailboxByPath('Drafts'); + // Build draft MIME + final builder = MessageBuilder.prepareMultipartAlternativeMessage(plainText: draft.body) + ..from = [MailAddress('', _username!)] + ..to = draft.recipients.map((r) => MailAddress('', r)).toList() + ..subject = draft.subject; + final mime = builder.buildMimeMessage(); + + try { + await _imapClient!.appendMessage( + mime, + flags: [MessageFlags.draft], + ); + return true; + } catch (e) { + debugPrint('appendDraft error: $e'); + return false; + } + } + + /// Lists all mailbox names on the server. + Future> getMailboxes() async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); + } + final boxes = await _imapClient!.listMailboxes(); + return boxes.map((m) => m.name).toList(); + } + + /// Searches emails in [mailboxName] matching optional criteria. + Future> searchEmails({ + String mailboxName = 'INBOX', + String? query, + String? from, + String? subject, + DateTime? since, + bool unreadOnly = false, + }) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); + } + + // Build IMAP search criteria + final criteria = []; + if (query?.isNotEmpty ?? false) criteria.add('TEXT "$query"'); + if (from?.isNotEmpty ?? false) criteria.add('FROM "$from"'); + if (subject?.isNotEmpty ?? false) criteria.add('SUBJECT "$subject"'); + if (since != null) { + final formatted = DateFormat('dd-MMM-yyyy').format(since).toUpperCase(); + criteria.add('SINCE $formatted'); + } + if (unreadOnly) criteria.add('UNSEEN'); + if (criteria.isEmpty) criteria.add('ALL'); + + // Execute search + final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); + final total = mailbox.messagesExists; + final result = await _imapClient!.fetchRecentMessages( + messageCount: total, + criteria: criteria.join(' '), + ); + + return Future.wait(result.messages.map(_convertMimeMessageToEmail)); + } + + // Internal helper to add/remove flags (e.g., Seen). + Future _updateEmailFlags( + int uid, + List flags, { + bool remove = false, + String mailboxName = 'INBOX', + }) async { + try { + await _imapClient!.selectMailboxByPath(mailboxName); + await _imapClient!.uidStore( + MessageSequence.fromId(uid), + flags, + action: remove ? StoreAction.remove : StoreAction.add, + ); + return true; + } catch (e) { + debugPrint('Error updating email flags: $e'); + return false; + } + } + + Future markAsRead(int uid, {String mailboxName = 'INBOX'}) => + _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); + + Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) => + _updateEmailFlags(uid, [MessageFlags.seen], remove: true, mailboxName: mailboxName); + + // Deletes a message (marks \Deleted + EXPUNGE). + Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { + try { + await _imapClient!.selectMailboxByPath(mailboxName); + await _imapClient!.uidStore(MessageSequence.fromId(uid), [MessageFlags.deleted]); + await _imapClient!.expunge(); + return true; + } catch (e) { + debugPrint('Error deleting email: $e'); + return false; + } + } + + // Moves a message to [targetMailbox]. + Future moveEmail( + int uid, + String targetMailbox, { + String sourceMailbox = 'INBOX', + }) async { + try { + await _imapClient!.selectMailboxByPath(sourceMailbox); + await _imapClient!.selectMailboxByPath(targetMailbox); + await _imapClient!.uidMove(MessageSequence.fromId(uid)); + return true; + } catch (e) { + debugPrint('Error moving email: $e'); + return false; + } + } + + // Converts a raw [MimeMessage] into your app’s [Email] model. + Future _convertMimeMessageToEmail(MimeMessage msg) async { + final plain = msg.decodeTextPlainPart(); + final html = msg.decodeTextHtmlPart(); + + return Email( + id: msg.uid?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), + subject: msg.decodeSubject() ?? 'No Subject', + body: plain ?? html ?? '', + htmlBody: html, + sender: msg.from?.first.personalName ?? msg.from?.first.email ?? 'Unknown', + senderEmail: msg.from?.first.email ?? '', + recipients: msg.to?.map((a) => a.email).toList() ?? [], + date: msg.decodeDate() ?? DateTime.now(), + isUnread: !msg.isSeen, + isStarred: msg.isFlagged, + attachments: [], + uid: msg.uid ?? 0, + ); + } +} diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart new file mode 100644 index 00000000..11096a82 --- /dev/null +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +/// A tile representing a single email in the inbox list. +class EmailTile extends StatelessWidget { + final Email email; + final bool isSelected; + final VoidCallback onTap; + final VoidCallback? onLongPress; + + const EmailTile({ + super.key, + required this.email, + required this.onTap, + this.onLongPress, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // Set background color based on state: + // - selected emails use a translucent primary color + // - unread emails use surfaceVariant (highlight) + // - read emails use regular surface + final Color bgColor = isSelected + ? theme.colorScheme.primary.withOpacity(0.1) + : email.isUnread + ? theme.colorScheme.surfaceVariant + : theme.colorScheme.surface; + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + color: bgColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLeadingIcon(theme), // Avatar or selection indicator + const SizedBox(width: 16), + _buildEmailContent(theme), // Sender, subject, and preview + _buildTrailingInfo(theme), // Timestamp + ], + ), + ), + ); + } + + /// Displays a selection icon if selected, otherwise a generic avatar. + Widget _buildLeadingIcon(ThemeData theme) { + return isSelected + ? Icon(Icons.check_circle, color: theme.colorScheme.primary) + : CircleAvatar( + radius: 20, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer), + ); + } + + /// Builds the main email content: sender, subject, and preview line. + Widget _buildEmailContent(ThemeData theme) { + final bool isUnread = email.isUnread; + + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sender name + Text( + email.sender, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + + const SizedBox(height: 4), + + // Email subject + Text( + email.subject, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + + const SizedBox(height: 4), + + // Email preview (first line of body) + Text( + email.preview, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + fontWeight: isUnread ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + /// Displays the time of the email (e.g., 14:05). + Widget _buildTrailingInfo(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ); + } +} diff --git a/lib/pages/email_client/widgets/select_email.dart b/lib/pages/email_client/widgets/select_email.dart new file mode 100644 index 00000000..e27d4df2 --- /dev/null +++ b/lib/pages/email_client/widgets/select_email.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +/// Manages selection state and batch actions for emails (e.g. archive, delete, mark as read). +class EmailSelectionController extends ChangeNotifier { + final Set _selectedEmails = {}; + + // Optional async handlers for batch actions + final Future Function(Set)? onDelete; + final Future Function(Set)? onArchive; + final Future Function(Email)? onEmailUpdated; + + EmailSelectionController({ + this.onDelete, + this.onArchive, + this.onEmailUpdated, + }); + + // ==== Public Accessors ==== + + /// Currently selected emails (read-only) + Set get selectedEmails => Set.unmodifiable(_selectedEmails); + + /// Returns true if any email is selected + bool get isSelecting => _selectedEmails.isNotEmpty; + + /// Checks if a specific email is selected + bool isSelected(Email email) => _selectedEmails.contains(email); + + /// Number of selected emails + int get selectionCount => _selectedEmails.length; + + // ==== Selection Management ==== + + /// Selects or deselects an email + void toggleSelection(Email email) { + _selectedEmails.contains(email) ? _selectedEmails.remove(email) : _selectedEmails.add(email); + notifyListeners(); + } + + /// Selects all given emails + void selectAll(Iterable emails) { + _selectedEmails.addAll(emails); + notifyListeners(); + } + + /// Clears all selected emails + void clearSelection() { + _selectedEmails.clear(); + notifyListeners(); + } + + // ==== Async Update Operations ==== + + /// Marks all selected emails as read + Future markAsReadSelected() async { + for (final email in _selectedEmails) { + final updatedEmail = email.copyWith(isUnread: false); + await onEmailUpdated?.call(updatedEmail); + } + notifyListeners(); + } + + /// Marks all selected emails as unread + Future markAsUnreadSelected() async { + for (final email in _selectedEmails) { + final updatedEmail = email.copyWith(isUnread: true); + await onEmailUpdated?.call(updatedEmail); + } + notifyListeners(); + } + + /// Toggles read/unread state for all selected emails + Future toggleReadState() async { + final allUnread = _selectedEmails.every((e) => e.isUnread); + for (final email in _selectedEmails) { + await onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); + } + notifyListeners(); + } + + /// Applies a custom async operation to each selected email + Future performBatchOperation(Future Function(Email) operation) async { + for (final email in _selectedEmails) { + await operation(email); + } + notifyListeners(); + } + + @override + void dispose() { + _selectedEmails.clear(); + super.dispose(); + } +} diff --git a/lib/pages/feed/widgets/feed_filter_popup.dart b/lib/pages/feed/widgets/feed_filter_popup.dart index fc2b6267..883557a7 100644 --- a/lib/pages/feed/widgets/feed_filter_popup.dart +++ b/lib/pages/feed/widgets/feed_filter_popup.dart @@ -6,6 +6,7 @@ import 'package:campus_app/core/settings.dart'; import 'package:campus_app/core/backend/entities/publisher_entity.dart'; import 'package:campus_app/utils/widgets/campus_filter_selection.dart'; import 'package:campus_app/utils/widgets/popup_sheet.dart'; +import 'package:snapping_sheet_2/snapping_sheet.dart' show SnappingSheet; /// This widget displays the filter options that are available for the /// personal news feed and is used in the [SnappingSheet] widget diff --git a/lib/pages/mensa/widgets/preferences_popup.dart b/lib/pages/mensa/widgets/preferences_popup.dart index 3579b199..e511976d 100644 --- a/lib/pages/mensa/widgets/preferences_popup.dart +++ b/lib/pages/mensa/widgets/preferences_popup.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:campus_app/core/themes.dart'; import 'package:campus_app/utils/widgets/popup_sheet.dart'; import 'package:campus_app/utils/widgets/campus_selection.dart'; +import 'package:snapping_sheet_2/snapping_sheet.dart' show SnappingSheet; /// This widget displays the preference options that are available for the mensa /// page and is used in the [SnappingSheet] widget. diff --git a/lib/pages/more/more_page.dart b/lib/pages/more/more_page.dart index 41a75823..25a46e0b 100644 --- a/lib/pages/more/more_page.dart +++ b/lib/pages/more/more_page.dart @@ -1,4 +1,5 @@ import 'dart:io' show Platform; +import 'package:campus_app/pages/email_client/email_pages/email_page.dart'; import 'package:campus_app/pages/more/privacy_policy_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -155,6 +156,31 @@ class MorePageState extends State with AutomaticKeepAliveClientMixin(context, listen: false).currentTheme == AppThemes.light + ? const Color.fromRGBO(245, 246, 250, 1) + : const Color.fromRGBO(34, 40, 54, 1), + borderRadius: BorderRadius.circular(15), + ), + child: Column( + children: [ + ExternalLinkButton( + title: 'RubMail', + leadingIconPath: 'assets/img/icons/mail-link.png', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EmailPage(), + ), + ); + }, + ), + ], + ), + ), // RUB links ButtonGroup( headline: 'Nützliche Links', diff --git a/lib/pages/wallet/ticket_login_screen.dart b/lib/pages/wallet/ticket_login_screen.dart index 7422113f..96062e27 100644 --- a/lib/pages/wallet/ticket_login_screen.dart +++ b/lib/pages/wallet/ticket_login_screen.dart @@ -1,228 +1,177 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:provider/provider.dart'; import 'package:campus_app/core/injection.dart'; -import 'package:campus_app/core/themes.dart'; -import 'package:campus_app/core/exceptions.dart'; +//import 'package:campus_app/core/exceptions.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_repository.dart'; import 'package:campus_app/utils/pages/wallet_utils.dart'; -import 'package:campus_app/utils/widgets/campus_icon_button.dart'; -import 'package:campus_app/utils/widgets/campus_textfield.dart'; -import 'package:campus_app/utils/widgets/campus_button.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; -class TicketLoginScreen extends StatefulWidget { - final void Function() onTicketLoaded; - const TicketLoginScreen({super.key, required this.onTicketLoaded}); - - @override - State createState() => _TicketLoginScreenState(); -} - -class _TicketLoginScreenState extends State { +class TicketCredentialManager { final TicketRepository ticketRepository = sl(); final FlutterSecureStorage secureStorage = sl(); final WalletUtils walletUtils = sl(); - final TextEditingController usernameController = TextEditingController(); - final TextEditingController passwordController = TextEditingController(); - final TextEditingController submitButtonController = TextEditingController(); - - bool showErrorMessage = false; - String errorMessage = ''; - - bool loading = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Provider.of(context).currentThemeData.colorScheme.surface, - body: Padding( - padding: const EdgeInsets.only(top: 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Back button - Padding( - padding: const EdgeInsets.only(bottom: 12, left: 20, right: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CampusIconButton( - iconPath: 'assets/img/icons/arrow-left.svg', - onTap: () { - Navigator.pop(context); - }, - ), - ], - ), - ), - const Padding(padding: EdgeInsets.only(top: 10)), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/img/icons/rub-link.png', - color: Provider.of(context).currentTheme == AppThemes.light - ? const Color.fromRGBO(0, 53, 96, 1) - : Colors.white, - width: 80, - filterQuality: FilterQuality.high, - ), - const Padding(padding: EdgeInsets.only(top: 30)), - CampusTextField( - textFieldController: usernameController, - textFieldText: 'RUB LoginID', - onTap: () { - setState(() { - showErrorMessage = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 10)), - CampusTextField( - textFieldController: passwordController, - obscuredInput: true, - textFieldText: 'RUB Passwort', - onTap: () { - setState(() { - showErrorMessage = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 15)), - if (showErrorMessage) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - 'assets/img/icons/error.svg', - colorFilter: const ColorFilter.mode( - Colors.redAccent, - BlendMode.srcIn, - ), - width: 18, - ), - const Padding( - padding: EdgeInsets.only(left: 5), - ), - Text( - errorMessage, - style: Provider.of(context).currentThemeData.textTheme.labelSmall!.copyWith( - color: Colors.redAccent, - ), - ), - ], - ), - ], - const Padding(padding: EdgeInsets.only(top: 15)), - CampusButton( - text: 'Login', - onTap: () async { - final NavigatorState navigator = Navigator.of(context); - - if (usernameController.text.isEmpty || passwordController.text.isEmpty) { - setState(() { - errorMessage = 'Bitte fülle beide Felder aus!'; - showErrorMessage = true; - }); - return; - } - - if (await walletUtils.hasNetwork() == false) { - setState(() { - errorMessage = 'Überprüfe deine Internetverbindung!'; - showErrorMessage = true; - }); - return; - } - - setState(() { - showErrorMessage = false; - loading = true; - }); - - final previousLoginId = await secureStorage.read(key: 'loginId'); - final previousPassword = await secureStorage.read(key: 'password'); - - await secureStorage.write(key: 'loginId', value: usernameController.text); - await secureStorage.write(key: 'password', value: passwordController.text); - - try { - await ticketRepository.loadTicket(); - widget.onTicketLoaded(); - navigator.pop(); - } catch (e) { - if (e is InvalidLoginIDAndPasswordException) { - setState(() { - errorMessage = 'Falsche LoginID und/oder Passwort!'; - showErrorMessage = true; - }); - } else { - setState(() { - errorMessage = 'Fehler beim Laden des Tickets!'; - showErrorMessage = true; - }); - } - - if (previousLoginId != null && previousPassword != null) { - await secureStorage.write(key: 'loginId', value: previousLoginId); - await secureStorage.write(key: 'password', value: previousPassword); - } - } - setState(() { - loading = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 25)), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - 'assets/img/icons/info.svg', - colorFilter: ColorFilter.mode( - Provider.of(context).currentTheme == AppThemes.light - ? Colors.black - : const Color.fromRGBO(184, 186, 191, 1), - BlendMode.srcIn, - ), - width: 18, - ), - const Padding( - padding: EdgeInsets.only(left: 8), - ), - SizedBox( - width: 320, - child: Text( - 'Deine Daten werden verschlüsselt auf deinem Gerät gespeichert und nur bei der Anmeldung an die RUB gesendet.', - style: Provider.of(context).currentThemeData.textTheme.labelSmall!.copyWith( - color: Provider.of(context).currentTheme == AppThemes.light - ? Colors.black - : const Color.fromRGBO(184, 186, 191, 1), - ), - overflow: TextOverflow.clip, - ), - ), - ], - ), - const Padding(padding: EdgeInsets.only(top: 25)), - if (loading) ...[ - CircularProgressIndicator( - backgroundColor: Provider.of(context).currentThemeData.cardColor, - color: Provider.of(context).currentThemeData.primaryColor, - strokeWidth: 3, - ), - ], - ], - ), - ), - ], + static const String _loginIdKey = 'loginId'; + static const String _passwordKey = 'password'; + + /// Attempts to load ticket with existing credentials, or shows login screen if needed + Future loadTicketWithCredentialCheck( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) async { + try { + // First check if we have saved credentials + final savedUsername = await secureStorage.read(key: _loginIdKey); + final savedPassword = await secureStorage.read(key: _passwordKey); + + if (savedUsername != null && savedPassword != null) { + // Try to load ticket with existing credentials + await ticketRepository.loadTicket(); + onTicketLoaded?.call(); + } else { + // No credentials found, show login screen + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + } catch (e) { + // If existing credentials fail, show login screen + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + } + + /// Forces the login screen to appear (e.g., for re-authentication) + Future showTicketLoginScreen( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) async { + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + + /// Internal method to show the login screen + void _showTicketLoginScreen( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginScreen( + loginType: LoginType.ticket, + onLogin: (username, password) async { + // This is where the actual ticket loading happens + await _performTicketLogin(username, password); + }, + onLoginSuccess: () { + // Called when login is successful + onTicketLoaded?.call(); + }, ), ), ); } + + /// Performs the actual ticket login with the provided credentials + Future _performTicketLogin(String username, String password) async { + // Check network connectivity + if (await walletUtils.hasNetwork() == false) { + throw Exception('Überprüfe deine Internetverbindung!'); + } + await secureStorage.write(key: _loginIdKey, value: username); + await secureStorage.write(key: _passwordKey, value: password); + // Attempt to load the ticket + await ticketRepository.loadTicket(); + } + + /// Checks if ticket credentials are stored + Future hasStoredCredentials() async { + final username = await secureStorage.read(key: _loginIdKey); + final password = await secureStorage.read(key: _passwordKey); + return username != null && password != null; + } + + /// Clears stored ticket credentials (for logout) + Future clearCredentials() async { + await secureStorage.delete(key: _loginIdKey); + await secureStorage.delete(key: _passwordKey); + } + + /// Gets the stored username (if any) + Future getStoredUsername() async { + return await secureStorage.read(key: _loginIdKey); + } + + /// Validates credentials without saving them + Future validateCredentials(String username, String password) async { + try { + // Store current credentials temporarily + final currentUsername = await secureStorage.read(key: _loginIdKey); + final currentPassword = await secureStorage.read(key: _passwordKey); + + // Set the new credentials temporarily + await secureStorage.write(key: _loginIdKey, value: username); + await secureStorage.write(key: _passwordKey, value: password); + + // Try to load ticket + await ticketRepository.loadTicket(); + + // Restore original credentials + if (currentUsername != null && currentPassword != null) { + await secureStorage.write(key: _loginIdKey, value: currentUsername); + await secureStorage.write(key: _passwordKey, value: currentPassword); + } + + return true; + } catch (e) { + return false; + } + } +} + +// Extension or utility class for easy access +class TicketManager { + static final TicketCredentialManager _credentialManager = TicketCredentialManager(); + + /// Main method to load ticket - handles credential checking automatically + static Future loadTicket( + BuildContext context, { + void Function()? onSuccess, + void Function(String error)? onError, + }) async { + await _credentialManager.loadTicketWithCredentialCheck( + context, + onTicketLoaded: onSuccess, + onError: onError, + ); + } + + /// Force login screen to appear + static Future login( + BuildContext context, { + void Function()? onSuccess, + void Function(String error)? onError, + }) async { + await _credentialManager.showTicketLoginScreen( + context, + onTicketLoaded: onSuccess, + onError: onError, + ); + } + + /// Logout (clear credentials) + static Future logout() async { + await _credentialManager.clearCredentials(); + } + + /// Check if user is logged in + static Future isLoggedIn() async { + return await _credentialManager.hasStoredCredentials(); + } + + /// Get current username + static Future getCurrentUsername() async { + return await _credentialManager.getStoredUsername(); + } } diff --git a/lib/pages/wallet/widgets/wallet.dart b/lib/pages/wallet/widgets/wallet.dart index 42fe457f..50112184 100644 --- a/lib/pages/wallet/widgets/wallet.dart +++ b/lib/pages/wallet/widgets/wallet.dart @@ -11,10 +11,11 @@ import 'package:campus_app/core/settings.dart'; import 'package:campus_app/core/themes.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_repository.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_usecases.dart'; -import 'package:campus_app/pages/wallet/ticket_login_screen.dart'; import 'package:campus_app/pages/wallet/ticket_fullscreen.dart'; import 'package:campus_app/pages/wallet/widgets/stacked_card_carousel.dart'; import 'package:campus_app/utils/widgets/custom_button.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class CampusWallet extends StatelessWidget { const CampusWallet({super.key}); @@ -79,8 +80,18 @@ class _BogestraTicketState extends State with AutomaticKeepAlive await Navigator.push( context, MaterialPageRoute( - builder: (context) => TicketLoginScreen( - onTicketLoaded: () async { + builder: (context) => LoginScreen( + loginType: LoginType.ticket, + onLogin: (username, password) async { + // Store credentials first, then load ticket + final secureStorage = sl(); + await secureStorage.write(key: 'loginId', value: username); + await secureStorage.write(key: 'password', value: password); + + // Load ticket with the stored credentials + await ticketRepository.loadTicket(); + }, + onLoginSuccess: () async { await renderTicket(); }, ), diff --git a/lib/utils/pages/mensa_utils.dart b/lib/utils/pages/mensa_utils.dart index 040a6e61..f2eca4a5 100644 --- a/lib/utils/pages/mensa_utils.dart +++ b/lib/utils/pages/mensa_utils.dart @@ -71,7 +71,9 @@ class MensaUtils { if (!(['V', 'VG', 'H'].any(filteredMensaPreferences.contains) && filteredMensaPreferences.any(dish.infos.contains)) && - filteredMensaPreferences.where((e) => e == 'V' || e == 'VG' || e == 'H').isNotEmpty) continue; + filteredMensaPreferences.where((e) => e == 'V' || e == 'VG' || e == 'H').isNotEmpty) { + continue; + } meals.add( MealItem( diff --git a/lib/utils/widgets/login_screen.dart b/lib/utils/widgets/login_screen.dart new file mode 100644 index 00000000..d58aacb6 --- /dev/null +++ b/lib/utils/widgets/login_screen.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/core/themes.dart'; +import 'package:campus_app/core/exceptions.dart'; +import 'package:campus_app/utils/pages/wallet_utils.dart'; +import 'package:campus_app/utils/widgets/campus_icon_button.dart'; +import 'package:campus_app/utils/widgets/campus_textfield.dart'; +import 'package:campus_app/utils/widgets/campus_button.dart'; + +enum LoginType { ticket, email } + +class LoginScreen extends StatefulWidget { + final LoginType loginType; + final Future Function(String username, String password) onLogin; + final void Function()? onLoginSuccess; + final String? customTitle; + final String? customDescription; + + const LoginScreen({ + super.key, + required this.loginType, + required this.onLogin, + this.onLoginSuccess, + this.customTitle, + this.customDescription, + }); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final FlutterSecureStorage secureStorage = sl(); + final WalletUtils walletUtils = sl(); + + final TextEditingController usernameController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + bool showErrorMessage = false; + String errorMessage = ''; + bool loading = false; + bool _disposed = false; + + String get _getDescription { + if (widget.customDescription != null) return widget.customDescription!; + return 'Deine Daten werden verschlüsselt auf deinem Gerät gespeichert und nur bei der Anmeldung an die RUB gesendet.'; + } + + String get _getUsernameLabel { + switch (widget.loginType) { + case LoginType.ticket: + return 'RUB LoginID'; + case LoginType.email: + return 'RUB LoginID'; + } + } + + String get _getPasswordLabel { + switch (widget.loginType) { + case LoginType.ticket: + return 'RUB Passwort'; + case LoginType.email: + return 'RUB Passwort'; + } + } + + String get _getStorageKeyPrefix { + switch (widget.loginType) { + case LoginType.ticket: + return 'ticket_'; + case LoginType.email: + return 'email_'; + } + } + + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + try { + final savedUsername = await secureStorage.read(key: '${_getStorageKeyPrefix}loginId'); + final savedPassword = await secureStorage.read(key: '${_getStorageKeyPrefix}password'); + + if (!_disposed && savedUsername != null) { + usernameController.text = savedUsername; + } + if (!_disposed && savedPassword != null) { + passwordController.text = savedPassword; + } + } catch (e) { + debugPrint('Error loading credentials: $e'); + } + } + + Future _saveCredentials(String username, String password) async { + try { + await secureStorage.write(key: '${_getStorageKeyPrefix}loginId', value: username); + await secureStorage.write(key: '${_getStorageKeyPrefix}password', value: password); + } catch (e) { + debugPrint('Error saving credentials: $e'); + } + } + + Future _restorePreviousCredentials(String? previousUsername, String? previousPassword) async { + try { + if (previousUsername != null && previousPassword != null) { + await secureStorage.write(key: '${_getStorageKeyPrefix}loginId', value: previousUsername); + await secureStorage.write(key: '${_getStorageKeyPrefix}password', value: previousPassword); + } + } catch (e) { + debugPrint('Error restoring credentials: $e'); + } + } + + Future _handleLogin() async { + if (_disposed) return; + + final navigator = Navigator.of(context); + final username = usernameController.text.trim(); + final password = passwordController.text.trim(); + + // Validate inputs + if (username.isEmpty || password.isEmpty) { + _showError('Bitte fülle beide Felder aus!'); + return; + } + + // Check network + final hasNetwork = await walletUtils.hasNetwork(); + if (!hasNetwork) { + _showError('Überprüfe deine Internetverbindung!'); + return; + } + + // Store previous credentials + final previousLoginId = await secureStorage.read(key: '${_getStorageKeyPrefix}loginId'); + final previousPassword = await secureStorage.read(key: '${_getStorageKeyPrefix}password'); + + // Save new credentials + await _saveCredentials(username, password); + + setState(() { + loading = true; + showErrorMessage = false; + }); + + try { + // Add timeout for the login operation + await widget.onLogin(username, password).timeout(const Duration(seconds: 30)); + + if (!_disposed) { + widget.onLoginSuccess?.call(); + navigator.pop(); + } + } on TimeoutException { + _showError('Server antwortet nicht - bitte später versuchen'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } on SocketException { + _showError('Netzwerkfehler - Verbindung prüfen'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } on InvalidLoginIDAndPasswordException { + _showError('Falsche LoginID und/oder Passwort!'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } catch (e) { + debugPrint('Login error type: ${e.runtimeType}, message: $e'); + _showError(_getGenericErrorMessage()); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } finally { + if (!_disposed) { + setState(() => loading = false); + } + } + } + + void _showError(String message) { + if (!_disposed) { + setState(() { + errorMessage = message; + showErrorMessage = true; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Provider.of(context); + final themeData = theme.currentThemeData; + final isLightTheme = theme.currentTheme == AppThemes.light; + + return Scaffold( + backgroundColor: themeData.colorScheme.surface, + body: Padding( + padding: const EdgeInsets.only(top: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button + Padding( + padding: const EdgeInsets.only(bottom: 12, left: 20, right: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CampusIconButton( + iconPath: 'assets/img/icons/arrow-left.svg', + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + const SizedBox(height: 10), + Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/img/icons/rub-link.png', + color: isLightTheme ? const Color.fromRGBO(0, 53, 96, 1) : Colors.white, + width: 80, + filterQuality: FilterQuality.high, + ), + const SizedBox(height: 30), + CampusTextField( + textFieldController: usernameController, + textFieldText: _getUsernameLabel, + onTap: () => setState(() => showErrorMessage = false), + ), + const SizedBox(height: 10), + CampusTextField( + textFieldController: passwordController, + obscuredInput: true, + textFieldText: _getPasswordLabel, + onTap: () => setState(() => showErrorMessage = false), + ), + if (showErrorMessage) ...[ + const SizedBox(height: 15), + _buildErrorWidget(themeData), + ], + const SizedBox(height: 15), + CampusButton( + text: 'Login', + onTap: _handleLogin, + ), + const SizedBox(height: 25), + _buildInfoWidget(themeData, isLightTheme), + if (loading) ...[ + const SizedBox(height: 25), + CircularProgressIndicator( + backgroundColor: themeData.cardColor, + color: themeData.primaryColor, + strokeWidth: 3, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorWidget(ThemeData themeData) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + 'assets/img/icons/error.svg', + colorFilter: const ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), + width: 18, + ), + const SizedBox(width: 5), + Text( + errorMessage, + style: themeData.textTheme.labelSmall?.copyWith(color: Colors.redAccent), + ), + ], + ); + } + + Widget _buildInfoWidget(ThemeData themeData, bool isLightTheme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + 'assets/img/icons/info.svg', + colorFilter: ColorFilter.mode( + isLightTheme ? Colors.black : const Color.fromRGBO(184, 186, 191, 1), + BlendMode.srcIn, + ), + width: 18, + ), + const SizedBox(width: 8), + SizedBox( + width: 320, + child: Text( + _getDescription, + style: themeData.textTheme.labelSmall?.copyWith( + color: isLightTheme ? Colors.black : const Color.fromRGBO(184, 186, 191, 1), + ), + overflow: TextOverflow.clip, + ), + ), + ], + ); + } + + String _getGenericErrorMessage() { + switch (widget.loginType) { + case LoginType.ticket: + return 'Fehler beim Laden des Tickets! Bitte versuche es später erneut.'; + case LoginType.email: + return 'Email-Login ist aktuell nicht verfügbar. Bitte versuche es später.'; + } + } + + @override + void dispose() { + _disposed = true; + usernameController.dispose(); + passwordController.dispose(); + super.dispose(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 795c1eff..c1580107 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -105,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + background_fetch: + dependency: "direct main" + description: + name: background_fetch + sha256: "6f0cec85480eac151f3971f883180d8c0acf6b40001153f1cf7c2c453df4f851" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + basic_utils: + dependency: transitive + description: + name: basic_utils + sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" + url: "https://pub.dev" + source: hosted + version: "5.8.2" boolean_selector: dependency: transitive description: @@ -173,10 +197,10 @@ packages: dependency: transitive description: name: built_value - sha256: ba95c961bafcd8686d1cf63be864eb59447e795e124d98d6a27d91fcd13602fb + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.11.1" + version: "8.9.2" cached_network_image: dependency: "direct main" description: @@ -301,10 +325,10 @@ packages: dependency: transitive description: name: cronet_http - sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840" + sha256: "3af9c4d57bf07ef4b307e77b22be4ad61bea19ee6ff65e62184863f3a09f1415" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.3.2" cross_file: dependency: transitive description: @@ -325,18 +349,18 @@ packages: dependency: transitive description: name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "0.17.3" cupertino_http: dependency: transitive description: name: cupertino_http - sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae" + sha256: "7e75c45a27cc13a886ab0a1e4d8570078397057bd612de9d24fe5df0d9387717" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "1.5.1" dart_earcut: dependency: transitive description: @@ -345,14 +369,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - dart_polylabel2: - dependency: transitive - description: - name: dart_polylabel2 - sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" - url: "https://pub.dev" - source: hosted - version: "1.0.0" dart_style: dependency: transitive description: @@ -397,10 +413,10 @@ packages: dependency: transitive description: name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" url: "https://pub.dev" source: hosted - version: "7.0.3" + version: "7.0.1" dijkstra: dependency: "direct main" description: @@ -413,26 +429,26 @@ packages: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.7.0" dio_cookie_manager: dependency: "direct main" description: name: dio_cookie_manager - sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0 + sha256: e79498b0f632897ff0c28d6e8178b4bc6e9087412401f618c31fa0904ace050d url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.1.1" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.0.0" dismissible_page: dependency: "direct main" description: @@ -441,14 +457,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + encrypter_plus: + dependency: transitive + description: + name: encrypter_plus + sha256: "6f6f3c73e26058af4fd138369a928ccae667e45d254cf6ded6301a2d99551a67" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + enough_convert: + dependency: transitive + description: + name: enough_convert + sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + enough_mail: + dependency: "direct main" + description: + name: enough_mail + sha256: b8b3d4da3f3d727013c5ffc562046ed1d2553a7ffdcc7e7a0e7e1bb96ed445ae + url: "https://pub.dev" + source: hosted + version: "2.1.7" envied: dependency: "direct main" description: name: envied - sha256: f8c347589ab13bed975aa2f6f95630570aa1a358c7e6c8894686e80e4bc60b14 + sha256: cd95ddf0982e53f0b6664e889d4a9ce678b3907a59a5047923404375ef6dcacc url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.1" envied_generator: dependency: "direct dev" description: @@ -461,10 +501,18 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.5" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" fake_async: dependency: transitive description: @@ -501,10 +549,10 @@ packages: dependency: transitive description: name: firebase_core_platform_interface - sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.0.2" firebase_core_web: dependency: transitive description: @@ -586,10 +634,10 @@ packages: dependency: "direct main" description: name: flutter_html - sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.0-beta.2" flutter_inappwebview: dependency: "direct main" description: @@ -674,10 +722,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "20ca0a9c82ce0c855ac62a2e580ab867f3fbea82680a90647f7953832d0850ae" + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" url: "https://pub.dev" source: hosted - version: "19.4.0" + version: "19.5.0" flutter_local_notifications_linux: dependency: transitive description: @@ -698,10 +746,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_windows - sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98 + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" flutter_localizations: dependency: "direct main" description: flutter @@ -711,10 +759,10 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5 + sha256: f7d0379477274f323c3f3bc12d369a2b42eb86d1e7bd2970ae1ea3cff782449a url: "https://pub.dev" source: hosted - version: "8.2.1" + version: "8.1.1" flutter_map_location_marker: dependency: "direct main" description: @@ -727,10 +775,10 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + sha256: ee5c9bd2b74ea8676442fd4ab876b5d41681df49276488854d6c81a5377c0ef1 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.2" flutter_nfc_kit: dependency: "direct main" description: @@ -824,10 +872,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: "578bd8c508144fdaffd4f77b8ef2d8c523602275cd697cc3db284dbd762ef4ce" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.0.14" flutter_test: dependency: "direct dev" description: flutter @@ -987,10 +1035,10 @@ packages: dependency: transitive description: name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.1" gsettings: dependency: transitive description: @@ -1035,18 +1083,18 @@ packages: dependency: "direct main" description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.4" http: dependency: "direct main" description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -1075,10 +1123,10 @@ packages: dependency: "direct main" description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.7.2" image_editor: dependency: "direct main" description: @@ -1131,10 +1179,10 @@ packages: dependency: transitive description: name: jni - sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + sha256: f377c585ea9c08d48b427dc2e03780af2889d1bb094440da853c6883c1acba4b url: "https://pub.dev" source: hosted - version: "0.14.2" + version: "0.10.1" js: dependency: "direct overridden" description: @@ -1163,10 +1211,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -1235,10 +1283,10 @@ packages: dependency: transitive description: name: logger - sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" logging: dependency: transitive description: @@ -1251,10 +1299,10 @@ packages: dependency: "direct main" description: name: lottie - sha256: c5fa04a80a620066c15cf19cc44773e19e9b38e989ff23ea32e5903ef1015950 + sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.3.2" matcher: dependency: transitive description: @@ -1315,18 +1363,18 @@ packages: dependency: transitive description: name: native_device_orientation - sha256: "0c330c068575e4be72cce5968ca479a3f8d5d1e5dfce7d89d5c13a1e943b338c" + sha256: bc0bcccc79752048d2235c10545c5fd554a46035fe0a4a4534d1bb9d8bc85e6c url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" native_dio_adapter: dependency: "direct main" description: name: native_dio_adapter - sha256: "1c51bd42027861d27ccad462ba0903f5e3197461cc6d59a0bb8658cb5ad7bd01" + sha256: "4c925ba15a44478be0eb6e97b62a1c1d07e56b28e566283dbcb15e58418bdaae" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.3.0" ndef: dependency: transitive description: @@ -1343,14 +1391,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7" - url: "https://pub.dev" - source: hosted - version: "7.1.0" octo_image: dependency: transitive description: @@ -1371,18 +1411,18 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "8.1.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.0.1" page_transition: dependency: "direct main" description: @@ -1419,18 +1459,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.12" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -1459,10 +1499,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.0.2" photo_view: dependency: "direct main" description: @@ -1487,6 +1527,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" pool: dependency: transitive description: @@ -1515,10 +1571,10 @@ packages: dependency: "direct main" description: name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted - version: "6.1.5+1" + version: "6.1.2" pub_semver: dependency: transitive description: @@ -1555,18 +1611,18 @@ packages: dependency: "direct main" description: name: screen_brightness - sha256: b6cb9381b83fef7be74187ea043d54598b9a265b4ef6e40b69345ae28699b13e + sha256: "5f70754028f169f059fdc61112a19dcbee152f8b293c42c848317854d650cba3" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.7" screen_brightness_android: dependency: transitive description: name: screen_brightness_android - sha256: fb5fa43cb89d0c9b8534556c427db1e97e46594ac5d66ebdcf16063b773d54ed + sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" screen_brightness_ios: dependency: transitive description: @@ -1587,10 +1643,10 @@ packages: dependency: transitive description: name: screen_brightness_ohos - sha256: af2680660f7df785bcd2b1bef9b9f3c172191166dd27098f2dfe020c50c3dea4 + sha256: a93a263dcd39b5c56e589eb495bcd001ce65cdd96ff12ab1350683559d5c5bb7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" screen_brightness_platform_interface: dependency: transitive description: @@ -1611,18 +1667,18 @@ packages: dependency: transitive description: name: sentry - sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + sha256: "2440763ae96fa8fd1bcdfc224f5232e1b7a09af76a72f4e626ee313a261faf6f" url: "https://pub.dev" source: hosted - version: "8.14.2" + version: "8.10.1" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + sha256: "3b30038b3b9303540a8b2c8b1c8f0bb93a207f8e4b25691c59d969ddeb4734fd" url: "https://pub.dev" source: hosted - version: "8.14.2" + version: "8.10.1" share_plus: dependency: "direct main" description: @@ -1651,10 +1707,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.13" shared_preferences_foundation: dependency: transitive description: @@ -1707,10 +1763,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.0" sky_engine: dependency: transitive description: flutter @@ -1784,18 +1840,18 @@ packages: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.4+5" sqflite_darwin: dependency: transitive description: @@ -1920,18 +1976,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.3.17" + version: "6.3.14" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.3.1" url_launcher_linux: dependency: transitive description: @@ -1944,10 +2000,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.1" url_launcher_platform_interface: dependency: transitive description: @@ -1960,10 +2016,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.3.3" url_launcher_windows: dependency: transitive description: @@ -1984,10 +2040,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "773c9522d66d523e1c7b25dfb95cc91c26a1e17b107039cfe147285e92de7878" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.14" vector_graphics_codec: dependency: transitive description: @@ -2000,10 +2056,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 + sha256: ab9ff38fc771e9ee1139320adbe3d18a60327370c218c60752068ebee4b49ab1 url: "https://pub.dev" source: hosted - version: "1.1.18" + version: "1.1.15" vector_math: dependency: "direct main" description: @@ -2016,10 +2072,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: "53f3b57c7ac88c18e6074d0f94c7146e128c515f0a4503c3061b8e71dea3a0f2" + sha256: a8dc4324f67705de057678372bedb66cd08572fe7c495605ac68c5f503324a39 url: "https://pub.dev" source: hosted - version: "2.8.12" + version: "2.8.15" video_player_avfoundation: dependency: transitive description: @@ -2032,18 +2088,18 @@ packages: dependency: transitive description: name: video_player_platform_interface - sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.5.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + sha256: fb3bbeaf0302cb0c31340ebd6075487939aa1fe3b379d1a8784ef852b679940e url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.0.15" visibility_detector: dependency: "direct main" description: @@ -2056,18 +2112,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.0.0" watcher: dependency: transitive description: name: watcher - sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.0" web: dependency: transitive description: @@ -2080,26 +2136,26 @@ packages: dependency: transitive description: name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.1" win32: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2" url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.8.0" win32_registry: dependency: transitive description: @@ -2150,4 +2206,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2defa49c..c92c4a3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: campus_app description: Simplifie, improve and facilitate everyday students life. -publish_to: "none" -version: 2.4.1+63 +publish_to: 'none' +version: 2.3.4 environment: sdk: ">=3.6.0 <4.0.0" @@ -77,7 +77,8 @@ dependencies: crypto: ^3.0.3 flutter_compass: ^0.8.1 vector_math: ^2.1.4 - + background_fetch: ^1.0.0 + enough_mail: ^2.1.7 dev_dependencies: flutter_test: sdk: flutter