diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index aed20db..23912af 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -10,4 +10,5 @@ class ApiConstants { // Invitation endpoints static const String getAllInvitationsEndpoint = '/invitations/all'; + static const String acceptInvitationEndpoint = '/invitations/accept'; } diff --git a/frontend/lib/screens/pages/invitations_page.dart b/frontend/lib/screens/pages/invitations_page.dart index 84ddc2b..0cf4702 100644 --- a/frontend/lib/screens/pages/invitations_page.dart +++ b/frontend/lib/screens/pages/invitations_page.dart @@ -8,10 +8,14 @@ class InvitationsPage extends StatefulWidget { _InvitationsPageState createState() => _InvitationsPageState(); } -class _InvitationsPageState extends State { +class _InvitationsPageState extends State + with TickerProviderStateMixin { InvitationsData? _invitationsData; bool _isLoading = true; String? _errorMessage; + Map _acceptingInvitations = {}; + Map _acceptedInvitations = {}; + Map _animationControllers = {}; @override void initState() { @@ -19,6 +23,14 @@ class _InvitationsPageState extends State { _loadInvitations(); } + @override + void dispose() { + for (final controller in _animationControllers.values) { + controller.dispose(); + } + super.dispose(); + } + Future _loadInvitations() async { setState(() { _isLoading = true; @@ -44,11 +56,7 @@ class _InvitationsPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.event_busy, - size: 80, - color: Colors.grey[400], - ), + Icon(Icons.event_busy, size: 80, color: Colors.grey[400]), SizedBox(height: 16), Text( 'Nothing here!', @@ -61,10 +69,7 @@ class _InvitationsPageState extends State { SizedBox(height: 8), Text( 'Create the first invitation now!', - style: TextStyle( - fontSize: 16, - color: Colors.grey[500], - ), + style: TextStyle(fontSize: 16, color: Colors.grey[500]), ), ], ), @@ -76,18 +81,11 @@ class _InvitationsPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline, - size: 80, - color: Colors.red[400], - ), + Icon(Icons.error_outline, size: 80, color: Colors.red[400]), SizedBox(height: 16), Text( _errorMessage ?? 'Something went wrong', - style: TextStyle( - fontSize: 16, - color: Colors.red[600], - ), + style: TextStyle(fontSize: 16, color: Colors.red[600]), textAlign: TextAlign.center, ), SizedBox(height: 16), @@ -104,10 +102,55 @@ class _InvitationsPageState extends State { ); } + Future _acceptInvitation(Invitation invitation) async { + final invitationKey = invitation.id.toString(); + setState(() { + _acceptingInvitations[invitationKey] = true; + }); + + final result = await InvitationsService.acceptInvitation(invitation.id); + + if (mounted) { + setState(() { + _acceptingInvitations[invitationKey] = false; + }); + + if (result['success']) { + setState(() { + _acceptedInvitations[invitationKey] = true; + }); + + final controller = AnimationController( + duration: Duration(milliseconds: 1500), + vsync: this, + ); + _animationControllers[invitationKey] = controller; + controller.forward(); + + Future.delayed(Duration(milliseconds: 2000), () { + if (mounted) { + _loadInvitations(); + } + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to accept invitation'), + backgroundColor: Colors.red, + ), + ); + } + } + } + Widget _buildInvitationCard(Invitation invitation, String section) { + final invitationKey = invitation.id.toString(); bool isOwned = section == 'created'; bool isAccepted = section == 'accepted'; - + bool isAccepting = _acceptingInvitations[invitationKey] ?? false; + bool hasBeenAccepted = _acceptedInvitations[invitationKey] ?? false; + AnimationController? animController = _animationControllers[invitationKey]; + return Container( margin: EdgeInsets.only(bottom: 16), padding: EdgeInsets.all(20), @@ -121,10 +164,7 @@ class _InvitationsPageState extends State { offset: Offset(0, 2), ), ], - border: Border.all( - color: Colors.grey.withOpacity(0.2), - width: 1, - ), + border: Border.all(color: Colors.grey.withOpacity(0.2), width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -134,12 +174,16 @@ class _InvitationsPageState extends State { Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( - color: InvitationUtils.getColorFromHex(invitation.tag.colorHex).withOpacity(0.1), + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ).withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( InvitationUtils.getIconFromName(invitation.tag.iconName), - color: InvitationUtils.getColorFromHex(invitation.tag.colorHex), + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ), size: 24, ), ), @@ -158,10 +202,7 @@ class _InvitationsPageState extends State { ), Text( 'by ${invitation.creator.displayName}', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), @@ -169,7 +210,9 @@ class _InvitationsPageState extends State { Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: InvitationUtils.getColorFromHex(invitation.tag.colorHex).withOpacity(0.1), + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ).withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Text( @@ -177,13 +220,16 @@ class _InvitationsPageState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: InvitationUtils.getColorFromHex(invitation.tag.colorHex), + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ), ), ), ), ], ), - if (invitation.description != null && invitation.description!.isNotEmpty) ...[ + if (invitation.description != null && + invitation.description!.isNotEmpty) ...[ SizedBox(height: 12), Text( InvitationUtils.truncateDescription(invitation.description), @@ -202,10 +248,7 @@ class _InvitationsPageState extends State { SizedBox(width: 4), Text( invitation.location!, - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[600]), ), ], ), @@ -218,10 +261,7 @@ class _InvitationsPageState extends State { SizedBox(width: 4), Text( InvitationUtils.getRelativeDateTime(invitation.dateTime!), - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[600]), ), ], ), @@ -233,22 +273,22 @@ class _InvitationsPageState extends State { padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: InvitationUtils.getParticipantsStatusColor( - invitation.currentAttendees, - invitation.maxParticipants + invitation.currentAttendees, + invitation.maxParticipants, ).withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Text( InvitationUtils.getParticipantsStatus( - invitation.currentAttendees, - invitation.maxParticipants + invitation.currentAttendees, + invitation.maxParticipants, ), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: InvitationUtils.getParticipantsStatusColor( - invitation.currentAttendees, - invitation.maxParticipants + invitation.currentAttendees, + invitation.maxParticipants, ), ), ), @@ -265,28 +305,65 @@ class _InvitationsPageState extends State { width: double.infinity, height: 44, child: ElevatedButton( - onPressed: isOwned || isAccepted ? null : () { - // TODO: Implement accept invitation - }, + onPressed: isOwned || isAccepted || isAccepting || hasBeenAccepted + ? null + : () { + _acceptInvitation(invitation); + }, style: ElevatedButton.styleFrom( - backgroundColor: isAccepted + backgroundColor: (isAccepted || hasBeenAccepted) ? Colors.green : (isOwned ? Colors.grey[400] : Color(0xFF6A4C93)), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - elevation: isOwned || isAccepted ? 0 : 2, - ), - child: Text( - isAccepted - ? 'Accepted ✓' - : (isOwned ? 'View/Edit' : 'Accept Invite'), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + elevation: isOwned || isAccepted || hasBeenAccepted ? 0 : 2, ), + child: isAccepting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : hasBeenAccepted && animController != null + ? AnimatedBuilder( + animation: animController, + builder: (context, child) { + if (animController.value < 0.5) { + return Text( + 'Accepted ✓', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ); + } else { + return Opacity( + opacity: 1.0 - ((animController.value - 0.5) * 2), + child: Text( + 'Accepted ✓', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ); + } + }, + ) + : Text( + (isAccepted || hasBeenAccepted) + ? 'Accepted ✓' + : (isOwned ? 'View/Edit' : 'Accept Invite'), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), ), ), ], @@ -294,9 +371,13 @@ class _InvitationsPageState extends State { ); } - Widget _buildInvitationSection(String title, List invitations, String section) { + Widget _buildInvitationSection( + String title, + List invitations, + String section, + ) { if (invitations.isEmpty) return SizedBox.shrink(); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -311,10 +392,14 @@ class _InvitationsPageState extends State { ), ), ), - ...invitations.map((invitation) => Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: _buildInvitationCard(invitation, section), - )).toList(), + ...invitations + .map( + (invitation) => Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _buildInvitationCard(invitation, section), + ), + ) + .toList(), ], ); } @@ -336,46 +421,43 @@ class _InvitationsPageState extends State { ), automaticallyImplyLeading: false, actions: [ - IconButton( - icon: Icon(Icons.refresh), - onPressed: _loadInvitations, - ), + IconButton(icon: Icon(Icons.refresh), onPressed: _loadInvitations), ], ), body: _isLoading ? Center(child: CircularProgressIndicator(color: Color(0xFF6A4C93))) : _errorMessage != null - ? _buildErrorState() - : _invitationsData?.isEmpty ?? true - ? _buildEmptyState() - : RefreshIndicator( - onRefresh: _loadInvitations, - color: Color(0xFF6A4C93), - child: SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: Column( - children: [ - SizedBox(height: 16), - _buildInvitationSection( - 'My Invitations', - _invitationsData?.created ?? [], - 'created', - ), - _buildInvitationSection( - 'Accepted Invitations', - _invitationsData?.accepted ?? [], - 'accepted', - ), - _buildInvitationSection( - 'Available Invitations', - _invitationsData?.available ?? [], - 'available', - ), - SizedBox(height: 80), - ], - ), - ), + ? _buildErrorState() + : _invitationsData?.isEmpty ?? true + ? _buildEmptyState() + : RefreshIndicator( + onRefresh: _loadInvitations, + color: Color(0xFF6A4C93), + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + SizedBox(height: 16), + _buildInvitationSection( + 'My Invitations', + _invitationsData?.created ?? [], + 'created', ), + _buildInvitationSection( + 'Accepted Invitations', + _invitationsData?.accepted ?? [], + 'accepted', + ), + _buildInvitationSection( + 'Available Invitations', + _invitationsData?.available ?? [], + 'available', + ), + SizedBox(height: 80), + ], + ), + ), + ), floatingActionButton: FloatingActionButton( onPressed: () { Navigator.push( diff --git a/frontend/lib/services/invitations_service.dart b/frontend/lib/services/invitations_service.dart index 49c79ea..a2ced42 100644 --- a/frontend/lib/services/invitations_service.dart +++ b/frontend/lib/services/invitations_service.dart @@ -6,21 +6,21 @@ import 'http_service.dart'; class InvitationsService { static Future> getAllInvitations() async { try { - final response = await HttpService.get(ApiConstants.getAllInvitationsEndpoint); + final response = await HttpService.get( + ApiConstants.getAllInvitationsEndpoint, + ); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final invitationsResponse = InvitationsResponse.fromJson(responseData); - + if (invitationsResponse.status) { - return { - 'success': true, - 'data': invitationsResponse.data, - }; + return {'success': true, 'data': invitationsResponse.data}; } else { return { 'success': false, - 'message': invitationsResponse.message ?? 'Failed to fetch invitations', + 'message': + invitationsResponse.message ?? 'Failed to fetch invitations', }; } } else if (response.statusCode == 401) { @@ -47,4 +47,42 @@ class InvitationsService { }; } } -} \ No newline at end of file + + static Future> acceptInvitation(int invitationId) async { + try { + final response = await HttpService.post( + ApiConstants.acceptInvitationEndpoint, + {'id': invitationId}, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? 'Request completed', + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error accepting invitation: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } +} diff --git a/frontend/lib/utils/invitation_utils.dart b/frontend/lib/utils/invitation_utils.dart index 1450108..ea366d2 100644 --- a/frontend/lib/utils/invitation_utils.dart +++ b/frontend/lib/utils/invitation_utils.dart @@ -71,7 +71,8 @@ class InvitationUtils { return 'today at $timeString'; } else if (eventDate == tomorrow) { return 'tomorrow at $timeString'; - } else if (eventDate.isAfter(today) && eventDate.isBefore(today.add(Duration(days: 7)))) { + } else if (eventDate.isAfter(today) && + eventDate.isBefore(today.add(Duration(days: 7)))) { List weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return '${weekdays[eventDate.weekday - 1]} at $timeString'; } else { @@ -94,12 +95,7 @@ class InvitationUtils { } static String getParticipantsStatus(int current, int max) { - int needed = max - current; - if (needed <= 2 && needed > 0) { - return '$needed more person${needed == 1 ? '' : 's'} needed'; - } else { - return '$current/$max'; - } + return '$current/$max'; } static Color getParticipantsStatusColor(int current, int max) { @@ -112,4 +108,4 @@ class InvitationUtils { return Colors.blue; } } -} \ No newline at end of file +}