feat: accepting invites in the front end

This commit is contained in:
sBubshait 2025-07-22 10:34:34 +03:00
parent b65c8a7340
commit 57edd7ab14
4 changed files with 234 additions and 117 deletions

View File

@ -10,4 +10,5 @@ class ApiConstants {
// Invitation endpoints
static const String getAllInvitationsEndpoint = '/invitations/all';
static const String acceptInvitationEndpoint = '/invitations/accept';
}

View File

@ -8,10 +8,14 @@ class InvitationsPage extends StatefulWidget {
_InvitationsPageState createState() => _InvitationsPageState();
}
class _InvitationsPageState extends State<InvitationsPage> {
class _InvitationsPageState extends State<InvitationsPage>
with TickerProviderStateMixin {
InvitationsData? _invitationsData;
bool _isLoading = true;
String? _errorMessage;
Map<String, bool> _acceptingInvitations = {};
Map<String, bool> _acceptedInvitations = {};
Map<String, AnimationController> _animationControllers = {};
@override
void initState() {
@ -19,6 +23,14 @@ class _InvitationsPageState extends State<InvitationsPage> {
_loadInvitations();
}
@override
void dispose() {
for (final controller in _animationControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _loadInvitations() async {
setState(() {
_isLoading = true;
@ -44,11 +56,7 @@ class _InvitationsPageState extends State<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
);
}
Future<void> _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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
),
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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
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<InvitationsPage> {
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<Color>(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<InvitationsPage> {
);
}
Widget _buildInvitationSection(String title, List<Invitation> invitations, String section) {
Widget _buildInvitationSection(
String title,
List<Invitation> invitations,
String section,
) {
if (invitations.isEmpty) return SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -311,10 +392,14 @@ class _InvitationsPageState extends State<InvitationsPage> {
),
),
),
...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<InvitationsPage> {
),
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(

View File

@ -6,21 +6,21 @@ import 'http_service.dart';
class InvitationsService {
static Future<Map<String, dynamic>> 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 {
};
}
}
}
static Future<Map<String, dynamic>> 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.',
};
}
}
}

View File

@ -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<String> 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;
}
}
}
}