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..70f204d5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,12 @@ 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'; + Future main() async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); // Keeps the native splash screen onscreen until all loading is done @@ -66,6 +72,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 +89,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..d545f38a --- /dev/null +++ b/lib/pages/email_client/email_pages/email_drawer.dart @@ -0,0 +1,148 @@ +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'; + +class EmailDrawer extends StatelessWidget { + const EmailDrawer({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + 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', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'you@example.com', + 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(), + + // === 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.push( + 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/models/email.dart b/lib/pages/email_client/models/email.dart new file mode 100644 index 00000000..a19914f0 --- /dev/null +++ b/lib/pages/email_client/models/email.dart @@ -0,0 +1,140 @@ +// 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 + + // 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.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']), + 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, + 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, + 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..5c904fff --- /dev/null +++ b/lib/pages/email_client/repositories/email_repository.dart @@ -0,0 +1,53 @@ +// 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, {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', + }); + + // 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}); +} 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..35a5c3c5 --- /dev/null +++ b/lib/pages/email_client/repositories/imap_email_repository.dart @@ -0,0 +1,100 @@ +// 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, {String mailboxName = 'INBOX'}) { + // 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> 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_service.dart b/lib/pages/email_client/services/email_service.dart new file mode 100644 index 00000000..6fc02639 --- /dev/null +++ b/lib/pages/email_client/services/email_service.dart @@ -0,0 +1,333 @@ +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'; + +class EmailService extends ChangeNotifier { + final List _allEmails = []; // Local email cache + final EmailSelectionController _selectionController = EmailSelectionController(); + final EmailAuthService _authService = sl(); + final EmailRepository _emailRepository; + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + EmailService(this._emailRepository) { + _selectionController.addListener(notifyListeners); + } + + List get allEmails => List.unmodifiable(_allEmails); + EmailSelectionController get selectionController => _selectionController; + + // Initialize connection and pull all folders (including drafts). + Future initialize() async { + try { + final credentials = await _authService.getCredentials(); + if (credentials == null) throw Exception('No valid credentials found'); + await _connectToEmailServer(credentials['username']!, credentials['password']!); + _isInitialized = true; + notifyListeners(); + + // Immediately load all folders, including drafts on the server + 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'); + } + + // Refreshes all mailbox folders, including server drafts. + Future refreshEmails() async { + if (!_isInitialized) throw Exception('Email service not initialized'); + try { + await _fetchEmailsFromServer(); + notifyListeners(); + } catch (e) { + if (e.toString().contains('authentication')) { + await _authService.logout(); + _isInitialized = false; + await _emailRepository.disconnect(); + notifyListeners(); + } + rethrow; + } + } + + // Fetches inbox, sent, drafts (server-side), trash, spam. + Future _fetchEmailsFromServer() async { + _allEmails.clear(); + + final folderMappings = { + EmailFolder.inbox: ['INBOX'], + EmailFolder.sent: ['Sent'], + EmailFolder.drafts: ['Drafts'], // ← fetch server drafts separately below + EmailFolder.trash: ['Trash'], + EmailFolder.spam: ['UCE-TMP'], + }; + + // 1) Fetch standard folders + for (final entry in folderMappings.entries) { + final folder = entry.key; + final folderNames = entry.value; + await _fetchEmailsForFolder(folder, folderNames); + } + + // 2) Fetch server-side drafts and merge + try { + final serverDrafts = await _emailRepository.fetchDrafts(); + for (final draft in serverDrafts) { + _allEmails.add(draft.copyWith(folder: EmailFolder.drafts)); + } + } catch (e) { + debugPrint('Could not fetch server drafts: $e'); + } + } + + // Helper to try all mailbox name aliases for a given folder. + Future _fetchEmailsForFolder(EmailFolder folder, List folderNames) async { + for (final folderName in folderNames) { + try { + final count = folder == EmailFolder.inbox ? 50 : 30; + final emails = await _emailRepository.fetchEmails(mailboxName: folderName, count: count); + for (final email in emails) { + _allEmails.add(email.copyWith(folder: folder)); + } + return; + } catch (_) { + // try next alias + } + } + + if (folder != EmailFolder.inbox) { + debugPrint('Could not fetch ${folder.name} emails from: ${folderNames.join(', ')}'); + } + } + + void clear() { + _allEmails.clear(); + _isInitialized = false; + _emailRepository.disconnect(); + notifyListeners(); + } + + // Sends a new email over SMTP and refreshes the Sent folder. + 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'); + + await _refreshSentEmails(); + notifyListeners(); + } + + Future _refreshSentEmails() async { + _allEmails.removeWhere((e) => e.folder == EmailFolder.sent); + await _fetchEmailsForFolder(EmailFolder.sent, ['Sent', 'INBOX.Sent', 'INBOX/Sent']); + } + + 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)); + } + + Future deleteEmail(Email email) async { + if (!_isInitialized || email.uid == 0) return; + + if (email.folder == EmailFolder.trash) { + final success = await _emailRepository.deleteEmail(email.uid, mailboxName: 'Trash'); + if (success) { + _allEmails.removeWhere((e) => e.id == email.id); + notifyListeners(); + } + } else { + final success = await _emailRepository.moveEmail(email.uid, 'Trash'); + if (success) updateEmail(email.copyWith(folder: EmailFolder.trash)); + } + } + + Future> searchEmails({ + String? query, + String? from, + String? subject, + EmailFolder? folder, + bool unreadOnly = false, + }) async { + if (!_isInitialized) return []; + + final mailboxName = _getMailboxNameForFolder(folder ?? EmailFolder.inbox); + final results = await _emailRepository.searchEmails( + query: query, + from: from, + subject: subject, + unreadOnly: unreadOnly, + mailboxName: mailboxName, + ); + + return results.map((e) => e.copyWith(folder: folder ?? EmailFolder.inbox)).toList(); + } + + String _getMailboxNameForFolder(EmailFolder folder) { + switch (folder) { + case EmailFolder.sent: + return 'Sent'; + case EmailFolder.drafts: + return 'Drafts'; + case EmailFolder.trash: + return 'Trash'; + case EmailFolder.spam: + return 'UCE-TMP'; + default: + return 'INBOX'; + } + } + + Future needsReAuthentication() async { + if (!_isInitialized) return true; + if (!_emailRepository.isConnected) return true; + return !(await _authService.validateCurrentCredentials()); + } + + // Local Data Helpers + + List filterEmails(String query, EmailFolder folder) { + final filtered = _allEmails.where((e) => e.folder == folder).toList(); + if (query.isEmpty) return filtered; + return filtered.where((email) { + return email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase()); + }).toList(); + } + + void updateEmail(Email updatedEmail) { + final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); + if (index != -1) { + _allEmails[index] = updatedEmail; + notifyListeners(); + } + } + + void moveEmailsToFolder(Iterable emails, EmailFolder folder) { + for (final email in emails) { + if (_isInitialized && email.uid != 0) { + final targetMailbox = _getMailboxNameForFolder(folder); + _emailRepository.moveEmail(email.uid, targetMailbox).catchError(print); + } + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: folder); + } + } + notifyListeners(); + } + + void deleteEmailsPermanently(Iterable emails) { + for (final email in emails) { + if (_isInitialized && email.uid != 0) { + _emailRepository.deleteEmail(email.uid).catchError(print); + } + } + _allEmails.removeWhere((e) => emails.any((d) => d.id == e.id)); + _selectionController.clearSelection(); + notifyListeners(); + } + + // Save or update a draft both locally and on the IMAP server + Future saveOrUpdateDraft(Email draft) async { + // 1) Local cache update + if (_isDraftEmpty(draft)) { + _allEmails.removeWhere((e) => e.id == draft.id); + notifyListeners(); + } else { + 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(); + } + + // 2) Push to server + try { + final success = await _emailRepository.saveDraft(draft); + if (!success) { + debugPrint('Failed to save draft on server'); + // Optionally, show a user-facing error here + } + } catch (e) { + debugPrint('Error while saving draft: $e'); + // Optionally, rollback local change + } + } + + 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(), + ), + ); + + if (_isInitialized && draft.uid != 0 && draft.id.isNotEmpty) { + _emailRepository.deleteEmail(draft.uid, mailboxName: 'Drafts').catchError(print); + } + + _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); + notifyListeners(); + } + + void cleanEmptyDrafts() { + final emptyDrafts = _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).toList(); + if (emptyDrafts.isNotEmpty) deleteEmailsPermanently(emptyDrafts); + } + + bool _isDraftEmpty(Email draft) { + return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; + } + + int get emptyDraftsCount => _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; + + int get unreadCount => _allEmails.where((e) => e.folder == EmailFolder.inbox && !e.isRead).length; + + @override + void dispose() { + _selectionController.dispose(); + _emailRepository.disconnect(); + super.dispose(); + } +} 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..264191ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "76.0.0" _flutterfire_internals: dependency: transitive description: @@ -17,14 +17,19 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.59" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.11.0" animations: dependency: "direct main" description: @@ -85,18 +90,18 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.6.0" async: dependency: transitive description: @@ -117,10 +122,10 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: @@ -141,26 +146,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.13" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "8.0.0" built_collection: dependency: transitive description: @@ -173,10 +178,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 +306,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 +330,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,22 +350,14 @@ 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: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7" dartz: dependency: "direct main" description: @@ -397,10 +394,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 +410,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: @@ -445,10 +442,10 @@ packages: 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 +458,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.5" fake_async: dependency: transitive description: @@ -501,10 +498,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 +583,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 +671,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 +695,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 +708,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 +724,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 +821,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 +984,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 +1032,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 +1072,10 @@ packages: dependency: "direct main" description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.3.0" image_editor: dependency: "direct main" description: @@ -1131,10 +1128,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,26 +1160,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -1235,10 +1232,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 +1248,18 @@ packages: dependency: "direct main" description: name: lottie - sha256: c5fa04a80a620066c15cf19cc44773e19e9b38e989ff23ea32e5903ef1015950 + sha256: "7afc60865a2429d994144f7d66ced2ae4305fe35d82890b8766e3359872d872c" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.1.3" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -1315,18 +1320,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 +1348,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 +1368,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 +1416,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 +1456,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,22 +1484,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pool: + polylabel: dependency: transitive description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" url: "https://pub.dev" source: hosted - version: "1.5.1" - posix: + version: "1.0.1" + pool: dependency: transitive description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "1.5.1" proj4dart: dependency: transitive description: @@ -1515,10 +1512,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 +1552,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 +1584,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 +1608,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 +1648,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 +1704,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 +1781,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: @@ -1848,10 +1845,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -1864,10 +1861,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.4" timezone: dependency: transitive description: @@ -1920,18 +1917,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 +1941,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 +1957,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 +1981,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,26 +1997,26 @@ 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: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" video_player_android: 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 +2029,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 +2053,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 +2077,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: @@ -2149,5 +2146,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2defa49c..9cb44b8d 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"