payfrit-app/lib/screens/group_order_invite_screen.dart
John Mizerek 65b5b82546 Add customer-to-staff chat feature and group order invites
- Add real-time chat between customers and staff via WebSocket
- Add HTTP polling fallback when WebSocket unavailable
- Chat auto-closes when worker ends conversation with dialog notification
- Add user search API for group order invites (phone/email/name)
- Store group order invites in app state
- Add login check before starting chat with sign-in prompt
- Remove table change button (not allowed currently)
- Fix About screen to show dynamic version from pubspec
- Update snackbar styling to green with black text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:08:09 -08:00

324 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app/app_router.dart';
import '../app/app_state.dart';
import '../services/api.dart';
import '../services/auth_storage.dart';
/// Screen to invite additional Payfrit users to join a group order
/// Shown after selecting Delivery or Takeaway
class GroupOrderInviteScreen extends StatefulWidget {
const GroupOrderInviteScreen({super.key});
@override
State<GroupOrderInviteScreen> createState() => _GroupOrderInviteScreenState();
}
class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
final List<UserSearchResult> _invitedUsers = [];
final TextEditingController _searchController = TextEditingController();
bool _isSearching = false;
int? _currentUserId;
List<UserSearchResult> _searchResults = [];
@override
void initState() {
super.initState();
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final auth = await AuthStorage.loadAuth();
if (auth != null && mounted) {
setState(() {
_currentUserId = auth.userId;
});
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _searchUsers(String query) async {
if (query.isEmpty) {
setState(() {
_searchResults = [];
_isSearching = false;
});
return;
}
if (query.length < 3) {
setState(() {
_searchResults = [];
_isSearching = false;
});
return;
}
setState(() => _isSearching = true);
try {
final results = await Api.searchUsers(
query: query,
currentUserId: _currentUserId,
);
if (mounted && _searchController.text == query) {
// Filter out already invited users
final filteredResults = results.where((user) =>
!_invitedUsers.any((invited) => invited.userId == user.userId)).toList();
setState(() {
_searchResults = filteredResults;
_isSearching = false;
});
}
} catch (e) {
debugPrint('[GroupOrderInvite] Search error: $e');
if (mounted) {
setState(() {
_searchResults = [];
_isSearching = false;
});
}
}
}
void _inviteUser(UserSearchResult user) {
if (!_invitedUsers.any((u) => u.userId == user.userId)) {
setState(() {
_invitedUsers.add(user);
_searchResults = [];
_searchController.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invitation sent to ${user.name}', style: const TextStyle(color: Colors.black)),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
}
}
void _removeInvite(UserSearchResult user) {
setState(() {
_invitedUsers.removeWhere((u) => u.userId == user.userId);
});
}
void _continueToRestaurants() {
// Store invited users in app state
final appState = context.read<AppState>();
final invitedUserIds = _invitedUsers.map((u) => u.userId).toList();
appState.setGroupOrderInvites(invitedUserIds);
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
void _skipInvites() {
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final orderTypeLabel = appState.isDelivery ? 'Delivery' : 'Takeaway';
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text('$orderTypeLabel Order'),
elevation: 0,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
const Icon(
Icons.group_add,
color: Colors.white54,
size: 48,
),
const SizedBox(height: 16),
const Text(
"Invite others to join",
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
"Add Payfrit users to share this order.\n${appState.isDelivery ? 'Split the delivery fee between everyone!' : 'Everyone pays for their own items.'}",
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Search field
TextField(
controller: _searchController,
onChanged: _searchUsers,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Search by phone or email...',
hintStyle: const TextStyle(color: Colors.white38),
prefixIcon: const Icon(Icons.search, color: Colors.white54),
suffixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white54,
),
),
)
: null,
filled: true,
fillColor: Colors.grey.shade900,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
// Search results
if (_searchResults.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: _searchResults.map((user) {
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.withAlpha(50),
child: Text(
user.name[0].toUpperCase(),
style: const TextStyle(color: Colors.blue),
),
),
title: Text(
user.name,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
user.phone,
style: const TextStyle(color: Colors.white54),
),
trailing: IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
onPressed: () => _inviteUser(user),
),
);
}).toList(),
),
),
],
const SizedBox(height: 16),
// Invited users list
if (_invitedUsers.isNotEmpty) ...[
const Text(
'Invited:',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _invitedUsers.map((user) {
return Chip(
avatar: CircleAvatar(
backgroundColor: Colors.green.withAlpha(50),
child: const Icon(
Icons.check,
size: 16,
color: Colors.green,
),
),
label: Text(user.name),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => _removeInvite(user),
backgroundColor: Colors.grey.shade800,
labelStyle: const TextStyle(color: Colors.white),
);
}).toList(),
),
],
const Spacer(),
// Continue button
if (_invitedUsers.isNotEmpty)
FilledButton.icon(
onPressed: _continueToRestaurants,
icon: const Icon(Icons.group),
label: Text('Continue with ${_invitedUsers.length + 1} people'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
)
else
FilledButton(
onPressed: _continueToRestaurants,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Continue Alone'),
),
const SizedBox(height: 12),
// Skip button
TextButton(
onPressed: _skipInvites,
child: const Text(
'Skip this step',
style: TextStyle(color: Colors.white54),
),
),
],
),
),
),
);
}
}