diff --git a/frontend/lib/screens/home_screen.dart b/frontend/lib/screens/home_screen.dart index 1c5aa6f..a856e0c 100644 --- a/frontend/lib/screens/home_screen.dart +++ b/frontend/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'pages/feed_page.dart'; import 'pages/invitations_page.dart'; import 'pages/profile_page.dart'; +import 'pages/wordle_page.dart'; import '../services/invitations_service.dart'; class HomeScreen extends StatefulWidget { @@ -13,7 +14,12 @@ class _HomeScreenState extends State { int _currentIndex = 0; int _availableInvitationsCount = 0; - final List _pages = [FeedPage(), InvitationsPage(), ProfilePage()]; + final List _pages = [ + FeedPage(), + InvitationsPage(), + WordlePage(), + ProfilePage(), + ]; @override void initState() { @@ -43,7 +49,7 @@ class _HomeScreenState extends State { if (_availableInvitationsCount == 0) { return Icon(Icons.mail); } - + return Stack( children: [ Icon(Icons.mail), @@ -56,12 +62,11 @@ class _HomeScreenState extends State { color: Colors.red, borderRadius: BorderRadius.circular(10), ), - constraints: BoxConstraints( - minWidth: 16, - minHeight: 16, - ), + constraints: BoxConstraints(minWidth: 16, minHeight: 16), child: Text( - _availableInvitationsCount > 99 ? '99+' : '$_availableInvitationsCount', + _availableInvitationsCount > 99 + ? '99+' + : '$_availableInvitationsCount', style: TextStyle( color: Colors.white, fontSize: 10, @@ -108,12 +113,22 @@ class _HomeScreenState extends State { backgroundColor: Colors.transparent, elevation: 0, items: [ - BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Feed'), + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Feed', + ), BottomNavigationBarItem( icon: _buildInvitationsBadge(), label: 'Invitations', ), - BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'), + BottomNavigationBarItem( + icon: Icon(Icons.games), + label: 'Puzzles', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person), + label: 'Profile', + ), ], ), ), diff --git a/frontend/lib/screens/pages/invitation_details_page.dart b/frontend/lib/screens/pages/invitation_details_page.dart index 23234d2..7897675 100644 --- a/frontend/lib/screens/pages/invitation_details_page.dart +++ b/frontend/lib/screens/pages/invitation_details_page.dart @@ -3,6 +3,11 @@ import '../../models/invitation_models.dart'; import '../../services/invitations_service.dart'; import '../../services/user_service.dart'; import '../../utils/invitation_utils.dart'; +import '../../widgets/whatsapp_button.dart'; + +// WhatsApp configuration +const String _whatsappNumber = 'PHONE_NUMBER'; +const bool _showWhatsappButton = true; // Set to false for production class InvitationDetailsPage extends StatefulWidget { final int invitationId; @@ -41,7 +46,9 @@ class _InvitationDetailsPageState extends State { _errorMessage = null; }); - final result = await InvitationsService.getInvitationDetails(widget.invitationId); + final result = await InvitationsService.getInvitationDetails( + widget.invitationId, + ); if (mounted) { setState(() { @@ -52,7 +59,7 @@ class _InvitationDetailsPageState extends State { _errorMessage = result['message']; } }); - + // Update participation status after loading details if (result['success']) { await _updateParticipationStatus(); @@ -79,7 +86,9 @@ class _InvitationDetailsPageState extends State { errorBuilder: (context, error, stackTrace) { return Center( child: Text( - displayName.isNotEmpty ? displayName[0].toUpperCase() : '?', + displayName.isNotEmpty + ? displayName[0].toUpperCase() + : '?', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -132,7 +141,9 @@ class _InvitationDetailsPageState extends State { _isCancelling = true; }); - final result = await InvitationsService.cancelInvitation(widget.invitationId); + final result = await InvitationsService.cancelInvitation( + widget.invitationId, + ); if (mounted) { setState(() { @@ -143,7 +154,9 @@ class _InvitationDetailsPageState extends State { Navigator.of(context).pop(true); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['message'] ?? 'Action completed successfully'), + content: Text( + result['message'] ?? 'Action completed successfully', + ), backgroundColor: Colors.green, ), ); @@ -161,11 +174,13 @@ class _InvitationDetailsPageState extends State { Future _updateParticipationStatus() async { if (_invitationDetails == null) return; - + final userResult = await UserService.getCurrentUser(); if (userResult['success'] && userResult['data'] != null) { final currentUserId = userResult['data']['id']; - final isParticipant = _invitationDetails!.attendees.any((attendee) => attendee.id == currentUserId); + final isParticipant = _invitationDetails!.attendees.any( + (attendee) => attendee.id == currentUserId, + ); setState(() { _isCurrentlyParticipant = isParticipant; }); @@ -177,7 +192,9 @@ class _InvitationDetailsPageState extends State { _isAccepting = true; }); - final result = await InvitationsService.acceptInvitation(widget.invitationId); + final result = await InvitationsService.acceptInvitation( + widget.invitationId, + ); if (mounted) { setState(() { @@ -187,7 +204,7 @@ class _InvitationDetailsPageState extends State { if (result['success']) { // Reload invitation details to reflect the new state await _loadInvitationDetails(); - + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Invitation accepted successfully!'), @@ -210,224 +227,337 @@ class _InvitationDetailsPageState extends State { return PopScope( canPop: true, // Allow back navigation to go back to invitations page only child: Scaffold( - backgroundColor: Colors.grey[50], - appBar: AppBar( - title: Text( - 'Invitation Details', - style: TextStyle(fontWeight: FontWeight.w600), + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: Text( + 'Invitation Details', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: Colors.white, + foregroundColor: Color(0xFF6A4C93), + elevation: 0, + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Container(height: 1, color: Colors.grey[200]), + ), ), - backgroundColor: Colors.white, - foregroundColor: Color(0xFF6A4C93), - elevation: 0, - bottom: PreferredSize( - preferredSize: Size.fromHeight(1), - child: Container(height: 1, color: Colors.grey[200]), - ), - ), - body: _isLoading - ? Center( - child: CircularProgressIndicator(color: Color(0xFF6A4C93)), - ) - : _errorMessage != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 80, color: Colors.red[400]), - SizedBox(height: 16), - Text( - _errorMessage!, - style: TextStyle(fontSize: 16, color: Colors.red[600]), - textAlign: TextAlign.center, + body: _isLoading + ? Center(child: CircularProgressIndicator(color: Color(0xFF6A4C93))) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 80, color: Colors.red[400]), + SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(fontSize: 16, color: Colors.red[600]), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadInvitationDetails, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, ), - SizedBox(height: 16), - ElevatedButton( - onPressed: _loadInvitationDetails, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xFF6A4C93), - foregroundColor: Colors.white, - ), - child: Text('Retry'), + child: Text('Retry'), + ), + ], + ), + ) + : SingleChildScrollView( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], ), - ], - ), - ) - : SingleChildScrollView( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 10, - offset: Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: InvitationUtils.getColorFromHex( - _invitationDetails!.tag.colorHex, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - InvitationUtils.getIconFromName( - _invitationDetails!.tag.iconName, - ), - color: InvitationUtils.getColorFromHex( - _invitationDetails!.tag.colorHex, - ), - size: 32, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(16), ), - SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _invitationDetails!.title, + child: Icon( + InvitationUtils.getIconFromName( + _invitationDetails!.tag.iconName, + ), + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ), + size: 32, + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _invitationDetails!.title, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + Container( + margin: EdgeInsets.only(top: 8), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _invitationDetails!.tag.name, style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.black87, + fontSize: 12, + fontWeight: FontWeight.w600, + color: + InvitationUtils.getColorFromHex( + _invitationDetails! + .tag + .colorHex, + ), ), ), - Container( - margin: EdgeInsets.only(top: 8), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: InvitationUtils.getColorFromHex( - _invitationDetails!.tag.colorHex, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: + _invitationDetails!.status == 'Available' + ? Colors.green.withOpacity(0.1) + : Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _invitationDetails!.status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: + _invitationDetails!.status == + 'Available' + ? Colors.green[700] + : Colors.orange[700], + ), + ), + ), + ], + ), + if (_invitationDetails!.description != null && + _invitationDetails!.description!.isNotEmpty) ...[ + SizedBox(height: 20), + Text( + 'Description', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + SizedBox(height: 8), + Text( + _invitationDetails!.description!, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + ], + SizedBox(height: 20), + Row( + children: [ + if (_invitationDetails!.location != null) ...[ + Expanded( + child: Row( + children: [ + Icon( + Icons.location_on, + size: 20, + color: Colors.grey[600], + ), + SizedBox(width: 8), + Expanded( child: Text( - _invitationDetails!.tag.name, + _invitationDetails!.location!, style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: InvitationUtils.getColorFromHex( - _invitationDetails!.tag.colorHex, - ), + fontSize: 14, + color: Colors.grey[700], ), + overflow: TextOverflow.ellipsis, ), ), ], ), ), - Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: _invitationDetails!.status == 'Available' - ? Colors.green.withOpacity(0.1) - : Colors.orange.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _invitationDetails!.status, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: _invitationDetails!.status == 'Available' - ? Colors.green[700] - : Colors.orange[700], - ), + ], + if (_invitationDetails!.dateTime != null) ...[ + if (_invitationDetails!.location != null) + SizedBox(width: 16), + Expanded( + child: Row( + children: [ + Icon( + Icons.schedule, + size: 20, + color: Colors.grey[600], + ), + SizedBox(width: 8), + Expanded( + child: Text( + InvitationUtils.getRelativeDateTime( + _invitationDetails!.dateTime!, + ), + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), ], - ), - if (_invitationDetails!.description != null && - _invitationDetails!.description!.isNotEmpty) ...[ - SizedBox(height: 20), - Text( - 'Description', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + ], + ), + SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.people, + size: 20, + color: Colors.grey[600], ), - SizedBox(height: 8), + SizedBox(width: 8), Text( - _invitationDetails!.description!, + '${_invitationDetails!.currentAttendees} / ${_invitationDetails!.maxParticipants} participants', style: TextStyle( fontSize: 14, color: Colors.grey[700], - height: 1.5, ), ), ], - SizedBox(height: 20), - Row( - children: [ - if (_invitationDetails!.location != null) ...[ - Expanded( - child: Row( - children: [ - Icon(Icons.location_on, size: 20, color: Colors.grey[600]), - SizedBox(width: 8), - Expanded( - child: Text( - _invitationDetails!.location!, - style: TextStyle(fontSize: 14, color: Colors.grey[700]), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + ), + ], + ), + ), + SizedBox(height: 24), + Container( + width: double.infinity, + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Organizer', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + SizedBox(width: 8), + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Color(0xFF6A4C93).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ORGANIZER', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFF6A4C93), ), - ], - if (_invitationDetails!.dateTime != null) ...[ - if (_invitationDetails!.location != null) SizedBox(width: 16), - Expanded( - child: Row( - children: [ - Icon(Icons.schedule, size: 20, color: Colors.grey[600]), - SizedBox(width: 8), - Expanded( - child: Text( - InvitationUtils.getRelativeDateTime( - _invitationDetails!.dateTime!, - ), - style: TextStyle(fontSize: 14, color: Colors.grey[700]), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + ), + ), + ], + ), + SizedBox(height: 12), + Row( + children: [ + _buildAvatarOrInitial( + _invitationDetails!.creator.displayName, + _invitationDetails!.creator.avatar, + ), + SizedBox(width: 12), + Expanded( + child: Text( + _invitationDetails!.creator.displayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, ), - ], - ], - ), - SizedBox(height: 16), - Row( - children: [ - Icon(Icons.people, size: 20, color: Colors.grey[600]), + ), + ), + if ((widget.isOwner || _isCurrentlyParticipant) && + _showWhatsappButton) ...[ SizedBox(width: 8), - Text( - '${_invitationDetails!.currentAttendees} / ${_invitationDetails!.maxParticipants} participants', - style: TextStyle(fontSize: 14, color: Colors.grey[700]), + WhatsAppButton( + phoneNumber: _whatsappNumber, + size: 28, ), ], - ), - ], - ), + ], + ), + ], ), + ), + if (_invitationDetails!.attendees.isNotEmpty) ...[ SizedBox(height: 24), Container( width: double.infinity, @@ -446,87 +576,31 @@ class _InvitationDetailsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - 'Organizer', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - SizedBox(width: 8), - Container( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Color(0xFF6A4C93).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'ORGANIZER', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Color(0xFF6A4C93), - ), - ), - ), - ], - ), - SizedBox(height: 12), - Row( - children: [ - _buildAvatarOrInitial( - _invitationDetails!.creator.displayName, - _invitationDetails!.creator.avatar, - ), - SizedBox(width: 12), - Text( - _invitationDetails!.creator.displayName, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ], - ), - ], - ), - ), - if (_invitationDetails!.attendees.isNotEmpty) ...[ - SizedBox(height: 24), - Container( - width: double.infinity, - padding: EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 10, - offset: Offset(0, 2), + Text( + 'Attendees (${_invitationDetails!.attendees.length})', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Attendees (${_invitationDetails!.attendees.length})', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - SizedBox(height: 16), - ...List.generate(_invitationDetails!.attendees.length, (index) { - final attendee = _invitationDetails!.attendees[index]; + ), + SizedBox(height: 16), + ...List.generate( + _invitationDetails!.attendees.length, + (index) { + final attendee = + _invitationDetails!.attendees[index]; return Container( - margin: EdgeInsets.only(bottom: index < _invitationDetails!.attendees.length - 1 ? 12 : 0), + margin: EdgeInsets.only( + bottom: + index < + _invitationDetails! + .attendees + .length - + 1 + ? 12 + : 0, + ), child: Row( children: [ _buildAvatarOrInitial( @@ -536,7 +610,8 @@ class _InvitationDetailsPageState extends State { SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( attendee.displayName, @@ -556,91 +631,107 @@ class _InvitationDetailsPageState extends State { ], ), ), + if ((widget.isOwner || + _isCurrentlyParticipant) && + _showWhatsappButton) ...[ + SizedBox(width: 8), + WhatsAppButton( + phoneNumber: _whatsappNumber, + size: 24, + ), + ], ], ), ); - }), - ], - ), - ), - ], - - // Accept button for non-participants who are not owners - if (!_isCurrentlyParticipant && !widget.isOwner) ...[ - SizedBox(height: 32), - Container( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: _isAccepting ? null : _acceptInvitation, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xFF6A4C93), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 2, + }, ), - child: _isAccepting - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text( - 'Accept Invitation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), + ], ), - ], - - // Cancel/Leave button for participants - if (_isCurrentlyParticipant) ...[ - SizedBox(height: 32), - Container( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: _isCancelling ? null : _cancelInvitation, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 2, - ), - child: _isCancelling - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text( - widget.isOwner ? 'Cancel Invitation' : 'Leave Invitation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - SizedBox(height: 32), + ), ], - ), + + // Accept button for non-participants who are not owners + if (!_isCurrentlyParticipant && !widget.isOwner) ...[ + SizedBox(height: 32), + Container( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isAccepting ? null : _acceptInvitation, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: _isAccepting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Accept Invitation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + + // Cancel/Leave button for participants + if (_isCurrentlyParticipant) ...[ + SizedBox(height: 32), + Container( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isCancelling ? null : _cancelInvitation, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: _isCancelling + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + widget.isOwner + ? 'Cancel Invitation' + : 'Leave Invitation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + SizedBox(height: 32), + ], ), - ), + ), + ), ); } -} \ No newline at end of file +} diff --git a/frontend/lib/screens/pages/wordle_page.dart b/frontend/lib/screens/pages/wordle_page.dart new file mode 100644 index 0000000..d03e75c --- /dev/null +++ b/frontend/lib/screens/pages/wordle_page.dart @@ -0,0 +1,599 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:io'; + +class WordlePage extends StatefulWidget { + @override + _WordlePageState createState() => _WordlePageState(); +} + +class _WordlePageState extends State with TickerProviderStateMixin { + static const String TARGET_WORD = "HELLO"; + static const int MAX_ATTEMPTS = 6; + static const int WORD_LENGTH = 5; + + List> grid = List.generate( + MAX_ATTEMPTS, + (_) => List.filled(WORD_LENGTH, ''), + ); + List> gridStates = List.generate( + MAX_ATTEMPTS, + (_) => List.filled(WORD_LENGTH, LetterState.empty), + ); + Map keyboardStates = {}; + + int currentRow = 0; + int currentCol = 0; + int animatingRow = -1; + bool gameWon = false; + bool gameLost = false; + + late AnimationController _revealController; + late List> _tileAnimations; + late FocusNode _focusNode; + Set _pressedKeys = {}; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + _initializeKeyboardStates(); + _focusNode = FocusNode(); + + // Request focus for keyboard input on web/desktop + if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + } + + void _initializeAnimations() { + _revealController = AnimationController( + duration: Duration(milliseconds: 1500), + vsync: this, + ); + + _tileAnimations = List.generate( + WORD_LENGTH, + (index) => Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _revealController, + curve: Interval( + index * 0.1, + (index * 0.1) + 0.5, + curve: Curves.elasticOut, + ), + ), + ), + ); + } + + void _initializeKeyboardStates() { + for (int i = 65; i <= 90; i++) { + keyboardStates[String.fromCharCode(i)] = LetterState.empty; + } + } + + @override + void dispose() { + _revealController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _addLetter(String letter) { + if (currentCol < WORD_LENGTH && + currentRow < MAX_ATTEMPTS && + !gameWon && + !gameLost) { + setState(() { + grid[currentRow][currentCol] = letter; + currentCol++; + }); + HapticFeedback.lightImpact(); + } + } + + void _deleteLetter() { + if (currentCol > 0 && !gameWon && !gameLost) { + setState(() { + currentCol--; + grid[currentRow][currentCol] = ''; + }); + HapticFeedback.lightImpact(); + } + } + + Future _submitWord() async { + if (currentCol == WORD_LENGTH && !gameWon && !gameLost) { + String guess = grid[currentRow].join(''); + List newStates = _evaluateGuess(guess); + + // Set the states and start animating the current row + setState(() { + gridStates[currentRow] = newStates; + _updateKeyboardStates(guess, newStates); + animatingRow = currentRow; + }); + + // Start the reveal animation + _revealController.reset(); + await _revealController.forward(); + + // Clear the animating row and move to next row + setState(() { + animatingRow = -1; + }); + + if (guess == TARGET_WORD) { + setState(() { + gameWon = true; + }); + HapticFeedback.heavyImpact(); + _showGameEndDialog(true); + } else if (currentRow == MAX_ATTEMPTS - 1) { + setState(() { + gameLost = true; + }); + HapticFeedback.heavyImpact(); + _showGameEndDialog(false); + } else { + setState(() { + currentRow++; + currentCol = 0; + }); + } + } + } + + List _evaluateGuess(String guess) { + List states = List.filled(WORD_LENGTH, LetterState.absent); + List targetLetters = TARGET_WORD.split(''); + List used = List.filled(WORD_LENGTH, false); + + // First pass: check for correct positions + for (int i = 0; i < WORD_LENGTH; i++) { + if (guess[i] == targetLetters[i]) { + states[i] = LetterState.correct; + used[i] = true; + } + } + + // Second pass: check for present letters + for (int i = 0; i < WORD_LENGTH; i++) { + if (states[i] != LetterState.correct) { + for (int j = 0; j < WORD_LENGTH; j++) { + if (!used[j] && guess[i] == targetLetters[j]) { + states[i] = LetterState.present; + used[j] = true; + break; + } + } + } + } + + return states; + } + + void _updateKeyboardStates(String guess, List states) { + for (int i = 0; i < guess.length; i++) { + String letter = guess[i]; + LetterState currentState = keyboardStates[letter] ?? LetterState.empty; + LetterState newState = states[i]; + + if (currentState == LetterState.correct) continue; + if (currentState == LetterState.present && newState == LetterState.absent) + continue; + + keyboardStates[letter] = newState; + } + } + + void _showGameEndDialog(bool won) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text(won ? 'Congratulations!' : 'Game Over'), + content: Text( + won ? 'You solved today\'s puzzle!' : 'The word was: $TARGET_WORD', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _resetGame(); + }, + child: Text( + 'Play Again', + style: TextStyle(color: Color(0xFF6A4C93)), + ), + ), + ], + ); + }, + ); + } + + void _resetGame() { + setState(() { + grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, '')); + gridStates = List.generate( + MAX_ATTEMPTS, + (_) => List.filled(WORD_LENGTH, LetterState.empty), + ); + currentRow = 0; + currentCol = 0; + animatingRow = -1; + gameWon = false; + gameLost = false; + _initializeKeyboardStates(); + }); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: GestureDetector( + onTap: () { + // Prevent mobile keyboard from showing up + FocusScope.of(context).unfocus(); + // Only request focus on web/desktop platforms + if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { + _focusNode.requestFocus(); + } + }, + child: RawKeyboardListener( + focusNode: _focusNode, + onKey: _handleRawKeyEvent, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + centerTitle: true, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'وصال', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w200, + fontFamily: 'Blaka', + color: Color(0xFF6A4C93), + ), + ), + SizedBox(width: 8), + Text( + 'Daily Challenge', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), + ), + ], + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Container(height: 1, color: Colors.grey[200]), + ), + ), + body: Column( + children: [ + Expanded( + child: Center( + child: Container( + constraints: BoxConstraints(maxWidth: 350), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Game Grid + Container( + padding: EdgeInsets.all(16), + child: Column( + children: List.generate(MAX_ATTEMPTS, (row) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(WORD_LENGTH, (col) { + bool shouldAnimate = + row == animatingRow && + gridStates[row][col] != + LetterState.empty; + + return Container( + margin: EdgeInsets.all(2), + width: 62, + height: 62, + child: shouldAnimate + ? AnimatedBuilder( + animation: _tileAnimations[col], + builder: (context, child) { + double scale = + 0.8 + + (0.2 * + _tileAnimations[col] + .value); + return Transform.scale( + scale: scale, + child: Container( + width: 62, + height: 62, + decoration: BoxDecoration( + border: Border.all( + color: + _getBorderColor( + row, + col, + ), + width: 2, + ), + color: + _getAnimatedTileColor( + row, + col, + _tileAnimations[col] + .value, + ), + borderRadius: + BorderRadius.circular( + 4, + ), + ), + child: Center( + child: Text( + grid[row][col], + style: TextStyle( + fontSize: 32, + fontWeight: + FontWeight.bold, + color: _getAnimatedTextColor( + row, + col, + _tileAnimations[col] + .value, + ), + ), + ), + ), + ), + ); + }, + ) + : Container( + width: 62, + height: 62, + decoration: BoxDecoration( + border: Border.all( + color: _getBorderColor( + row, + col, + ), + width: 2, + ), + color: _getTileColor( + row, + col, + ), + borderRadius: + BorderRadius.circular(4), + ), + child: Center( + child: Text( + grid[row][col], + style: TextStyle( + fontSize: 32, + fontWeight: + FontWeight.bold, + color: _getTextColor( + row, + col, + ), + ), + ), + ), + ), + ); + }), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ), + // Keyboard + Container(padding: EdgeInsets.all(8), child: _buildKeyboard()), + ], + ), + ), + ), + ), + ); + } + + Widget _buildKeyboard() { + List> keyboardLayout = [ + ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], + ['ENTER', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '⌫'], + ]; + + return Column( + children: keyboardLayout.map((row) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: row.map((key) { + bool isSpecial = key == 'ENTER' || key == '⌫'; + double width = isSpecial ? 65 : 35; + + return Container( + margin: EdgeInsets.symmetric(horizontal: 1.5), + width: width, + height: 58, + child: Material( + color: _getKeyColor(key), + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _handleKeyTap(key), + child: Container( + alignment: Alignment.center, + child: Text( + key, + style: TextStyle( + fontSize: isSpecial ? 12 : 14, + fontWeight: FontWeight.bold, + color: _getKeyTextColor(key), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + } + + void _handleKeyTap(String key) { + if (key == 'ENTER') { + _submitWord(); + } else if (key == '⌫') { + _deleteLetter(); + } else { + _addLetter(key); + } + } + + void _handleRawKeyEvent(RawKeyEvent event) { + if (event is RawKeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.enter) { + _submitWord(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + _deleteLetter(); + } else if (event.character != null && event.character!.isNotEmpty) { + String key = event.character!.toUpperCase(); + if (key.codeUnitAt(0) >= 65 && key.codeUnitAt(0) <= 90) { + _addLetter(key); + } + } + } + } + + Color _getBorderColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.grey[300]!; + if (row < currentRow || (row == currentRow && gameWon)) { + return Colors.transparent; + } else if (row == currentRow && col < currentCol) { + return Colors.grey[600]!; + } else { + return Colors.grey[300]!; + } + } + + Color _getTileColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white; + if (row >= currentRow && !gameWon) return Colors.white; + + switch (gridStates[row][col]) { + case LetterState.correct: + return Color(0xFF6AAE7C); + case LetterState.present: + return Color(0xFFC9B037); + case LetterState.absent: + return Color(0xFF787C7E); + default: + return Colors.white; + } + } + + Color _getTextColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black; + if (row >= currentRow && !gameWon) return Colors.black; + return gridStates[row][col] == LetterState.empty + ? Colors.black + : Colors.white; + } + + Color _getAnimatedTileColor(int row, int col, double animationValue) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white; + if (row != animatingRow || gridStates[row][col] == LetterState.empty) { + return _getTileColor(row, col); + } + + // Show color based on animation progress + Color targetColor; + switch (gridStates[row][col]) { + case LetterState.correct: + targetColor = Color(0xFF6AAE7C); + break; + case LetterState.present: + targetColor = Color(0xFFC9B037); + break; + case LetterState.absent: + targetColor = Color(0xFF787C7E); + break; + default: + targetColor = Colors.white; + } + + return Color.lerp(Colors.white, targetColor, animationValue) ?? + Colors.white; + } + + Color _getAnimatedTextColor(int row, int col, double animationValue) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black; + if (row != animatingRow || gridStates[row][col] == LetterState.empty) { + return _getTextColor(row, col); + } + + // Text color changes with animation + return Color.lerp(Colors.black, Colors.white, animationValue) ?? + Colors.black; + } + + Color _getKeyColor(String key) { + if (key == 'ENTER' || key == '⌫') { + return Colors.grey[300]!; + } + + LetterState state = keyboardStates[key] ?? LetterState.empty; + switch (state) { + case LetterState.correct: + return Color(0xFF6AAE7C); + case LetterState.present: + return Color(0xFFC9B037); + case LetterState.absent: + return Color(0xFF787C7E); + default: + return Colors.grey[200]!; + } + } + + Color _getKeyTextColor(String key) { + if (key == 'ENTER' || key == '⌫') { + return Colors.black; + } + + LetterState state = keyboardStates[key] ?? LetterState.empty; + return state == LetterState.empty ? Colors.black : Colors.white; + } +} + +enum LetterState { empty, absent, present, correct } diff --git a/frontend/lib/utils/web_launcher.dart b/frontend/lib/utils/web_launcher.dart new file mode 100644 index 0000000..c1385d9 --- /dev/null +++ b/frontend/lib/utils/web_launcher.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; + +class WebLauncher { + static void openUrl(String url) { + if (kIsWeb) { + // For web platforms, use JavaScript to open URL + // This approach works better in PWA environments + try { + // Use window.open equivalent + print('WebLauncher: Opening URL via JavaScript: $url'); + + // Create an anchor element and click it programmatically + // This is more reliable than window.open in PWA contexts + final script = ''' + (function() { + var link = document.createElement('a'); + link.href = '$url'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + })(); + '''; + + // Execute the script + // Note: In a real implementation, you'd use dart:html + // For now, we'll create a fallback approach + print('WebLauncher: Script prepared for URL opening'); + + // Fallback: try to use the current window location + // This is a simplified approach that works in most web contexts + _openUrlFallback(url); + + } catch (e) { + print('WebLauncher error: $e'); + _openUrlFallback(url); + } + } + } + + static void _openUrlFallback(String url) { + print('WebLauncher: Using fallback method for: $url'); + // In a PWA, this approach often works better + // We'll let the browser handle the URL opening + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/whatsapp_button.dart b/frontend/lib/widgets/whatsapp_button.dart new file mode 100644 index 0000000..4041532 --- /dev/null +++ b/frontend/lib/widgets/whatsapp_button.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:js' as js; + +class WhatsAppButton extends StatefulWidget { + final String phoneNumber; + final double size; + + const WhatsAppButton({ + super.key, + required this.phoneNumber, + this.size = 24, + }); + + @override + State createState() => _WhatsAppButtonState(); +} + +class _WhatsAppButtonState extends State { + bool _isPressed = false; + + void _openWhatsApp() { + if (kIsWeb) { + // Use JavaScript for web/PWA - this actually works + try { + final urls = [ + 'whatsapp://send?phone=${widget.phoneNumber}', + 'https://wa.me/${widget.phoneNumber}', + ]; + + for (String url in urls) { + try { + print('Opening URL with JavaScript: $url'); + js.context.callMethod('open', [url, '_blank']); + print('JavaScript launch successful'); + return; + } catch (e) { + print('JavaScript launch failed for $url: $e'); + continue; + } + } + + // Fallback: show phone number + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Contact via WhatsApp: ${widget.phoneNumber}'), + backgroundColor: Colors.blue, + ), + ); + } + } catch (e) { + print('JavaScript error: $e'); + } + } else { + // For native mobile, just show the phone number for now + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Contact via WhatsApp: ${widget.phoneNumber}'), + backgroundColor: Colors.blue, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _openWhatsApp, + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + borderRadius: BorderRadius.circular((widget.size + 8) / 2), + child: AnimatedContainer( + duration: Duration(milliseconds: 100), + width: widget.size + 8, + height: widget.size + 8, + decoration: BoxDecoration( + color: _isPressed ? Color(0xFF1DA851) : Color(0xFF25D366), + borderRadius: BorderRadius.circular((widget.size + 8) / 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Icon( + Icons.message, + color: Colors.white, + size: widget.size * 0.6, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index 7cafb92..5afda22 100644 --- a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import firebase_messaging import flutter_secure_storage_macos import path_provider_foundation import share_plus +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) @@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index c581007..087dab7 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -493,6 +493,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" url_launcher_linux: dependency: transitive description: @@ -501,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 63ab27a..c6a0ad2 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: flutter_secure_storage: ^9.2.2 share_plus: ^11.0.0 crypto: ^3.0.3 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: