diff --git a/Front/lib/models/app_notification.dart b/Front/lib/models/app_notification.dart new file mode 100644 index 0000000..b80daee --- /dev/null +++ b/Front/lib/models/app_notification.dart @@ -0,0 +1,37 @@ +class AppNotification { + final String id; + final String title; + final String? description; + final String type; // 'PUBLIC' | 'JOIN_REQUEST' + final String? groupId; + final bool? isAccepted; + final String receiverId; + final String? senderId; + + const AppNotification({ + required this.id, + required this.title, + this.description, + required this.type, + this.groupId, + this.isAccepted, + required this.receiverId, + this.senderId, + }); + + factory AppNotification.fromJson(Map json) { + return AppNotification( + id: json['id'].toString(), + title: json['title'] as String, + description: json['description'] as String?, + type: (json['type'] as String?) ?? 'PUBLIC', + groupId: json['group_id']?.toString(), + isAccepted: json['is_accepted'] as bool?, + receiverId: json['receiver_id'].toString(), + senderId: json['sender_id']?.toString(), + ); + } + + bool get isJoinRequest => type == 'JOIN_REQUEST'; + bool get isPending => isAccepted == null; +} diff --git a/Front/lib/models/channel.dart b/Front/lib/models/channel.dart index ec8aab1..3d8a928 100644 --- a/Front/lib/models/channel.dart +++ b/Front/lib/models/channel.dart @@ -1,11 +1,15 @@ class Channel { final String id; final String name; + final String type; // 'PUBLIC' | 'PRIVATE' | 'DIRECT' + final bool isActive; final int memberCount; const Channel({ required this.id, required this.name, + this.type = 'PUBLIC', + this.isActive = true, this.memberCount = 0, }); @@ -13,8 +17,9 @@ class Channel { return Channel( id: json['id'].toString(), name: json['name'] as String, - memberCount: - (json['member_count'] ?? json['members'] ?? 0) as int, + type: (json['type'] as String?) ?? 'PUBLIC', + isActive: (json['is_active'] as bool?) ?? true, + memberCount: (json['member_count'] ?? json['members'] ?? 0) as int, ); } } diff --git a/Front/lib/models/group_member.dart b/Front/lib/models/group_member.dart new file mode 100644 index 0000000..e5a1887 --- /dev/null +++ b/Front/lib/models/group_member.dart @@ -0,0 +1,24 @@ +class GroupMember { + final String userId; + final String username; + final String role; // 'MANAGER' | 'MEMBER' + final bool isOnline; + + const GroupMember({ + required this.userId, + required this.username, + this.role = 'MEMBER', + this.isOnline = false, + }); + + factory GroupMember.fromJson(Map json) { + return GroupMember( + userId: json['user_id'].toString(), + username: json['username'] as String, + role: (json['role'] as String?) ?? 'MEMBER', + isOnline: (json['is_online'] as bool?) ?? false, + ); + } + + bool get isManager => role == 'MANAGER'; +} diff --git a/Front/lib/screens/channel_list_screen.dart b/Front/lib/screens/channel_list_screen.dart index 5f8992f..9e9c2f9 100644 --- a/Front/lib/screens/channel_list_screen.dart +++ b/Front/lib/screens/channel_list_screen.dart @@ -4,6 +4,7 @@ import '../services/api_service.dart'; import '../services/auth_service.dart'; import 'channel_screen.dart'; import 'login_screen.dart'; +import 'notifications_screen.dart'; class ChannelListScreen extends StatefulWidget { const ChannelListScreen({super.key}); @@ -19,12 +20,20 @@ class _ChannelListScreenState extends State { List _channels = []; bool _loading = true; String? _error; + int _pendingNotifCount = 0; + String? _currentUserId; @override void initState() { super.initState(); _api = ApiService(_authService); - _loadChannels(); + _init(); + } + + Future _init() async { + _currentUserId = await _authService.getUserId(); + await _loadChannels(); + await _loadNotifCount(); } Future _loadChannels() async { @@ -47,6 +56,19 @@ class _ChannelListScreenState extends State { } } + Future _loadNotifCount() async { + final notifs = await _api.getNotifications(); + if (!mounted) return; + setState(() { + _pendingNotifCount = notifs.where((n) => n.isPending).length; + }); + } + + Future _refresh() async { + await _loadChannels(); + await _loadNotifCount(); + } + Future _logout() async { await _authService.logout(); if (!mounted) return; @@ -59,10 +81,60 @@ class _ChannelListScreenState extends State { void _enterChannel(Channel ch) { Navigator.push( context, - MaterialPageRoute(builder: (_) => ChannelScreen(channel: ch)), + MaterialPageRoute( + builder: (_) => ChannelScreen(channel: ch, currentUserId: _currentUserId), + ), ); } + void _openNotifications() async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NotificationsScreen()), + ); + // refresh notif count + reload channels after returning (user may have accepted invite) + _refresh(); + } + + Future _showCreateGroupDialog() async { + String groupName = ''; + final confirmed = await showDialog( + context: context, + builder: (ctx) => _CreateGroupDialog( + onNameChanged: (v) => groupName = v, + ), + ); + + if (confirmed == true && groupName.trim().isNotEmpty) { + final newChannel = await _api.createGroup(groupName.trim()); + if (!mounted) return; + if (newChannel != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('گروه ساخته شد', style: TextStyle(fontSize: 11), textAlign: TextAlign.center), + backgroundColor: const Color(0xFF1C1C1E), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ); + _loadChannels(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('خطا در ساخت گروه', style: TextStyle(fontSize: 11), textAlign: TextAlign.center), + backgroundColor: const Color(0xFF333333), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -72,8 +144,7 @@ class _ChannelListScreenState extends State { children: [ // Header Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ const SizedBox(width: 4), @@ -89,9 +160,43 @@ class _ChannelListScreenState extends State { fontWeight: FontWeight.bold), ), ), + // Notifications icon with badge + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + onPressed: _openNotifications, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon(Icons.notifications_outlined, + color: Colors.white54, size: 18), + ), + if (_pendingNotifCount > 0) + Positioned( + top: 4, + right: 4, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFFF1744), + shape: BoxShape.circle, + ), + ), + ), + ], + ), + // Create group + IconButton( + onPressed: _showCreateGroupDialog, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon(Icons.add_circle_outline, + color: Color(0xFF00C853), size: 18), + ), // Refresh IconButton( - onPressed: _loading ? null : _loadChannels, + onPressed: _loading ? null : _refresh, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), @@ -131,7 +236,7 @@ class _ChannelListScreenState extends State { color: Colors.white38, fontSize: 11)), const SizedBox(height: 8), TextButton( - onPressed: _loadChannels, + onPressed: _refresh, child: const Text('تلاش مجدد', style: TextStyle( color: Color(0xFF00C853), @@ -178,7 +283,11 @@ class _ChannelTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon(Icons.radio, color: Color(0xFF00C853), size: 16), + Icon( + channel.type == 'PUBLIC' ? Icons.radio : Icons.lock_outline, + color: const Color(0xFF00C853), + size: 16, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -198,3 +307,84 @@ class _ChannelTile extends StatelessWidget { ); } } + +// ── Create Group Dialog ──────────────────────────────────────────────────── + +class _CreateGroupDialog extends StatefulWidget { + final ValueChanged onNameChanged; + + const _CreateGroupDialog({required this.onNameChanged}); + + @override + State<_CreateGroupDialog> createState() => _CreateGroupDialogState(); +} + +class _CreateGroupDialogState extends State<_CreateGroupDialog> { + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: const Color(0xFF1C1C1E), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_circle_outline, color: Color(0xFF00C853), size: 26), + const SizedBox(height: 8), + const Text( + 'گروه جدید', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + TextField( + controller: _ctrl, + autofocus: true, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 12), + decoration: InputDecoration( + hintText: 'نام گروه', + hintStyle: const TextStyle(color: Colors.white38, fontSize: 11), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onChanged: widget.onNameChanged, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)), + ), + ), + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('ساخت', style: TextStyle( + color: Color(0xFF00C853), + fontSize: 11, + fontWeight: FontWeight.bold, + )), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/Front/lib/screens/channel_screen.dart b/Front/lib/screens/channel_screen.dart index aaa7efd..f08f116 100644 --- a/Front/lib/screens/channel_screen.dart +++ b/Front/lib/screens/channel_screen.dart @@ -8,11 +8,13 @@ import '../models/channel.dart'; import '../services/auth_service.dart'; import '../services/ptt_service.dart'; import '../config/app_config.dart'; +import 'group_members_screen.dart'; class ChannelScreen extends StatefulWidget { final Channel channel; + final String? currentUserId; - const ChannelScreen({super.key, required this.channel}); + const ChannelScreen({super.key, required this.channel, this.currentUserId}); @override State createState() => _ChannelScreenState(); @@ -228,7 +230,7 @@ class _ChannelScreenState extends State ), const SizedBox(height: 10), - // Bottom bar: back + wrist-flip toggle + // Bottom bar: back + members + wrist-flip toggle Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -247,6 +249,30 @@ class _ChannelScreenState extends State ), ), + // Members button + const SizedBox(width: 16), + IconButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GroupMembersScreen( + channel: widget.channel, + currentUserId: widget.currentUserId, + ), + ), + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + icon: const Icon( + Icons.group_outlined, + color: Colors.white54, + size: 18, + ), + ), + // Wrist-flip toggle (فقط اگه ژیروسکوپ دارد) if (_wristAvailable) ...[ const SizedBox(width: 16), diff --git a/Front/lib/screens/group_members_screen.dart b/Front/lib/screens/group_members_screen.dart new file mode 100644 index 0000000..b046f21 --- /dev/null +++ b/Front/lib/screens/group_members_screen.dart @@ -0,0 +1,402 @@ +import 'package:flutter/material.dart'; +import '../models/channel.dart'; +import '../models/group_member.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class GroupMembersScreen extends StatefulWidget { + final Channel channel; + final String? currentUserId; + + const GroupMembersScreen({ + super.key, + required this.channel, + this.currentUserId, + }); + + @override + State createState() => _GroupMembersScreenState(); +} + +class _GroupMembersScreenState extends State { + late final ApiService _api; + List _members = []; + bool _loading = true; + bool _isManager = false; + + @override + void initState() { + super.initState(); + _api = ApiService(AuthService()); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + final list = await _api.getGroupMembers(widget.channel.id); + if (!mounted) return; + final isManager = list.any( + (m) => m.userId == widget.currentUserId && m.isManager, + ); + setState(() { + _members = list; + _isManager = isManager; + _loading = false; + }); + } + + Future _showInviteDialog() async { + String username = ''; + final confirmed = await showDialog( + context: context, + builder: (ctx) => _InviteDialog( + onUsernameChanged: (v) => username = v, + ), + ); + if (confirmed == true && username.trim().isNotEmpty) { + final err = await _api.inviteMember(widget.channel.id, username.trim()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + err ?? 'دعوت‌نامه ارسال شد', + style: const TextStyle(fontSize: 11), + textAlign: TextAlign.center, + ), + backgroundColor: err == null ? const Color(0xFF1C1C1E) : const Color(0xFF333333), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ); + } + } + + Future _removeMember(GroupMember member) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1C1C1E), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_remove_outlined, color: Colors.red, size: 28), + const SizedBox(height: 8), + Text( + 'حذف ${member.username}؟', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)), + ), + ), + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('حذف', style: TextStyle(color: Colors.red, fontSize: 11)), + ), + ), + ], + ), + ], + ), + ), + ); + + if (confirmed == true) { + final err = await _api.removeMember(widget.channel.id, member.userId); + if (!mounted) return; + if (err != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(err, style: const TextStyle(fontSize: 11), textAlign: TextAlign.center), + backgroundColor: const Color(0xFF333333), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ); + } else { + _load(); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white70, size: 14), + ), + const SizedBox(width: 4), + const Icon(Icons.group_outlined, color: Color(0xFF00C853), size: 14), + const SizedBox(width: 4), + const Expanded( + child: Text( + 'اعضا', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + // Invite button + IconButton( + onPressed: _showInviteDialog, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(Icons.person_add_outlined, color: Color(0xFF00C853), size: 16), + ), + IconButton( + onPressed: _loading ? null : _load, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(Icons.refresh, color: Colors.white54, size: 16), + ), + ], + ), + ), + + // Group name chip + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF00C853).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.channel.type == 'PUBLIC' + ? Icons.public + : Icons.lock_outline, + color: const Color(0xFF00C853), + size: 10, + ), + const SizedBox(width: 4), + Text( + widget.channel.name, + style: const TextStyle(color: Color(0xFF00C853), fontSize: 10), + ), + ], + ), + ), + if (!_loading) ...[ + const SizedBox(width: 6), + Text( + '${_members.length} عضو', + style: const TextStyle(color: Colors.white38, fontSize: 10), + ), + ], + ], + ), + ), + const SizedBox(height: 4), + + // Content + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 2), + ) + : _members.isEmpty + ? const Center( + child: Text('عضوی یافت نشد', + style: TextStyle(color: Colors.white38, fontSize: 11)), + ) + : ListView.builder( + padding: const EdgeInsets.only(bottom: 4), + itemCount: _members.length, + itemBuilder: (ctx, i) { + final m = _members[i]; + final isMe = m.userId == widget.currentUserId; + return _MemberTile( + member: m, + isMe: isMe, + canRemove: _isManager && !isMe && !m.isManager, + onRemove: () => _removeMember(m), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _MemberTile extends StatelessWidget { + final GroupMember member; + final bool isMe; + final bool canRemove; + final VoidCallback onRemove; + + const _MemberTile({ + required this.member, + required this.isMe, + required this.canRemove, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + // Online indicator + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: member.isOnline ? const Color(0xFF00C853) : Colors.white24, + ), + ), + const SizedBox(width: 8), + // Username + Expanded( + child: Text( + member.username + (isMe ? ' (من)' : ''), + style: TextStyle( + color: isMe ? const Color(0xFF00C853) : Colors.white, + fontSize: 11, + fontWeight: member.isManager ? FontWeight.bold : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Role badge + if (member.isManager) + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF00C853).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'مدیر', + style: TextStyle(color: Color(0xFF00C853), fontSize: 9), + ), + ), + // Remove button + if (canRemove) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: onRemove, + child: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 16), + ), + ], + ], + ), + ); + } +} + +// ── Invite Dialog ────────────────────────────────────────────────────────── + +class _InviteDialog extends StatefulWidget { + final ValueChanged onUsernameChanged; + + const _InviteDialog({required this.onUsernameChanged}); + + @override + State<_InviteDialog> createState() => _InviteDialogState(); +} + +class _InviteDialogState extends State<_InviteDialog> { + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: const Color(0xFF1C1C1E), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_add_outlined, color: Color(0xFF00C853), size: 26), + const SizedBox(height: 8), + const Text( + 'دعوت عضو', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + TextField( + controller: _ctrl, + autofocus: true, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 12), + decoration: InputDecoration( + hintText: 'نام کاربری', + hintStyle: const TextStyle(color: Colors.white38, fontSize: 11), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onChanged: widget.onUsernameChanged, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)), + ), + ), + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('ارسال', style: TextStyle(color: Color(0xFF00C853), fontSize: 11, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/Front/lib/screens/notifications_screen.dart b/Front/lib/screens/notifications_screen.dart new file mode 100644 index 0000000..1a34914 --- /dev/null +++ b/Front/lib/screens/notifications_screen.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import '../models/app_notification.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + late final ApiService _api; + List _notifications = []; + bool _loading = true; + final Set _processing = {}; + + @override + void initState() { + super.initState(); + _api = ApiService(AuthService()); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + final list = await _api.getNotifications(); + if (!mounted) return; + setState(() { + _notifications = list; + _loading = false; + }); + } + + Future _respond(AppNotification n, bool accepted) async { + setState(() => _processing.add(n.id)); + final err = await _api.respondToNotification(n.id, accepted); + if (!mounted) return; + setState(() => _processing.remove(n.id)); + + if (err != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(err, style: const TextStyle(fontSize: 11), textAlign: TextAlign.center), + backgroundColor: const Color(0xFF333333), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ); + } else { + // refresh + _load(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white70, size: 14), + ), + const SizedBox(width: 4), + const Icon(Icons.notifications_outlined, color: Color(0xFF00C853), size: 14), + const SizedBox(width: 4), + const Expanded( + child: Text( + 'اعلان‌ها', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + IconButton( + onPressed: _loading ? null : _load, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + icon: const Icon(Icons.refresh, color: Colors.white54, size: 16), + ), + ], + ), + ), + + // Content + Expanded( + child: _loading + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 2), + ) + : _notifications.isEmpty + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notifications_off_outlined, color: Colors.white24, size: 28), + SizedBox(height: 6), + Text('اعلانی وجود ندارد', + style: TextStyle(color: Colors.white38, fontSize: 11)), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.only(bottom: 4), + itemCount: _notifications.length, + itemBuilder: (ctx, i) => _NotifTile( + notif: _notifications[i], + isProcessing: _processing.contains(_notifications[i].id), + onAccept: () => _respond(_notifications[i], true), + onReject: () => _respond(_notifications[i], false), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _NotifTile extends StatelessWidget { + final AppNotification notif; + final bool isProcessing; + final VoidCallback onAccept; + final VoidCallback onReject; + + const _NotifTile({ + required this.notif, + required this.isProcessing, + required this.onAccept, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + final isPending = notif.isPending; + final isJoin = notif.isJoinRequest; + + Color statusColor; + if (notif.isAccepted == true) { + statusColor = const Color(0xFF00C853); + } else if (notif.isAccepted == false) { + statusColor = Colors.red; + } else { + statusColor = const Color(0xFFFFAB00); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isPending ? statusColor.withValues(alpha: 0.4) : Colors.transparent, + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isJoin ? Icons.group_add_outlined : Icons.campaign_outlined, + color: statusColor, + size: 13, + ), + const SizedBox(width: 5), + Expanded( + child: Text( + notif.title, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (notif.description != null && notif.description!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + notif.description!, + style: const TextStyle(color: Colors.white60, fontSize: 10), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (isJoin && isPending) ...[ + const SizedBox(height: 6), + if (isProcessing) + const Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 1.5), + ), + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Reject + GestureDetector( + onTap: onReject, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'رد', + style: TextStyle(color: Colors.red, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 8), + // Accept + GestureDetector( + onTap: onAccept, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF00C853).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'قبول', + style: TextStyle(color: Color(0xFF00C853), fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + if (!isPending) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: Text( + notif.isAccepted == true ? 'پذیرفته شد' : 'رد شد', + style: TextStyle(color: statusColor, fontSize: 9), + ), + ), + ], + ], + ), + ); + } +} diff --git a/Front/lib/services/api_service.dart b/Front/lib/services/api_service.dart index beb730f..6ed9494 100644 --- a/Front/lib/services/api_service.dart +++ b/Front/lib/services/api_service.dart @@ -1,8 +1,16 @@ // Backend API endpoints: // POST /auth/login body: {"username":"...","secret":"..."} // → {"access_token":"...","token_type":"bearer"} -// GET /groups/me header: Authorization: Bearer TOKEN -// → [{"id":"uuid","name":"...","description":"...","is_active":true}] +// GET /groups/my header: Authorization: Bearer TOKEN +// → [{"id":"uuid","name":"...","type":"...","is_active":true}] +// POST /groups/ body: {"name":"..."} +// → GroupResponse +// GET /groups/{id}/members → [{"user_id":"...","username":"...","role":"...","is_online":bool}] +// POST /groups/{id}/invite body: {"username":"..."} +// → {"message":"...","notification_id":"..."} +// DELETE /groups/{id}/members/{user_id} → {"message":"..."} +// GET /notifications/ → [NotificationResponse] +// POST /notifications/{id}/respond body: {"is_accepted":bool} // // LiveKit token از طریق WebSocket دریافت می‌شود: // WS /ws/groups/{id}?token={jwt} @@ -11,15 +19,17 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../config/app_config.dart'; import '../models/channel.dart'; +import '../models/group_member.dart'; +import '../models/app_notification.dart'; import 'auth_service.dart'; // ── Fake data (only used when AppConfig.debug == true) ──────────────────── const _fakeChannels = [ - {'id': '1', 'name': 'تیم آلفا'}, - {'id': '2', 'name': 'پشتیبانی میدانی'}, - {'id': '3', 'name': 'فرماندهی'}, - {'id': '4', 'name': 'گروه لجستیک'}, - {'id': '5', 'name': 'واحد امنیت'}, + {'id': '1', 'name': 'تیم آلفا', 'type': 'PUBLIC', 'is_active': true}, + {'id': '2', 'name': 'پشتیبانی میدانی', 'type': 'PRIVATE', 'is_active': true}, + {'id': '3', 'name': 'فرماندهی', 'type': 'PUBLIC', 'is_active': true}, + {'id': '4', 'name': 'گروه لجستیک', 'type': 'PRIVATE', 'is_active': true}, + {'id': '5', 'name': 'واحد امنیت', 'type': 'PUBLIC', 'is_active': true}, ]; // ───────────────────────────────────────────────────────────────────────── @@ -41,9 +51,7 @@ class ApiService { try { final parts = token.split('.'); if (parts.length < 2) return null; - // base64url decode payload var payload = parts[1]; - // اضافه کردن padding اگه لازمه while (payload.length % 4 != 0) { payload += '='; } @@ -55,6 +63,8 @@ class ApiService { } } + // ── Auth ────────────────────────────────────────────────────────────── + Future login(String username, String secret) async { if (AppConfig.debug) { await Future.delayed(const Duration(milliseconds: 600)); @@ -85,6 +95,8 @@ class ApiService { } } + // ── Groups ──────────────────────────────────────────────────────────── + Future> getChannels() async { if (AppConfig.debug) { await Future.delayed(const Duration(milliseconds: 500)); @@ -96,7 +108,7 @@ class ApiService { try { final res = await http .get( - Uri.parse('${AppConfig.baseUrl}/users/me/groups'), + Uri.parse('${AppConfig.baseUrl}/groups/my'), headers: await _headers(), ) .timeout(const Duration(seconds: 10)); @@ -112,4 +124,189 @@ class ApiService { return []; } } + + Future createGroup(String name) async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return Channel(id: 'fake_${DateTime.now().millisecondsSinceEpoch}', name: name); + } + + try { + final res = await http + .post( + Uri.parse('${AppConfig.baseUrl}/groups/'), + headers: await _headers(), + body: jsonEncode({'name': name}), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200 || res.statusCode == 201) { + final data = jsonDecode(res.body) as Map; + return Channel.fromJson(data); + } + return null; + } catch (_) { + return null; + } + } + + Future> getGroupMembers(String groupId) async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return [ + const GroupMember(userId: 'u1', username: 'علی', role: 'MANAGER', isOnline: true), + const GroupMember(userId: 'u2', username: 'رضا', role: 'MEMBER', isOnline: false), + const GroupMember(userId: 'u3', username: 'مریم', role: 'MEMBER', isOnline: true), + ]; + } + + try { + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/groups/$groupId/members'), + headers: await _headers(), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200) { + final list = jsonDecode(res.body) as List; + return list + .map((e) => GroupMember.fromJson(e as Map)) + .toList(); + } + return []; + } catch (_) { + return []; + } + } + + Future inviteMember(String groupId, String username) async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return null; // success + } + + try { + final res = await http + .post( + Uri.parse('${AppConfig.baseUrl}/groups/$groupId/invite'), + headers: await _headers(), + body: jsonEncode({'username': username}), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200 || res.statusCode == 201) { + return null; // success, no error + } + // برگرداندن پیام خطا از بکند + try { + final data = jsonDecode(res.body) as Map; + return (data['detail'] as String?) ?? 'خطا در ارسال دعوت'; + } catch (_) { + return 'خطا در ارسال دعوت'; + } + } catch (_) { + return 'خطا در اتصال به سرور'; + } + } + + Future removeMember(String groupId, String userId) async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return null; + } + + try { + final res = await http + .delete( + Uri.parse('${AppConfig.baseUrl}/groups/$groupId/members/$userId'), + headers: await _headers(), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200) return null; + try { + final data = jsonDecode(res.body) as Map; + return (data['detail'] as String?) ?? 'خطا در حذف عضو'; + } catch (_) { + return 'خطا در حذف عضو'; + } + } catch (_) { + return 'خطا در اتصال به سرور'; + } + } + + // ── Notifications ───────────────────────────────────────────────────── + + Future> getNotifications() async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return [ + AppNotification( + id: 'n1', + title: 'دعوت به گروه', + description: 'شما به گروه "تیم آلفا" دعوت شده‌اید', + type: 'JOIN_REQUEST', + groupId: '1', + isAccepted: null, + receiverId: 'debug_user', + senderId: 'u1', + ), + AppNotification( + id: 'n2', + title: 'اطلاعیه سیستم', + description: 'سیستم به‌روزرسانی شد', + type: 'PUBLIC', + isAccepted: null, + receiverId: 'debug_user', + ), + ]; + } + + try { + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/notifications/'), + headers: await _headers(), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200) { + final list = jsonDecode(res.body) as List; + return list + .map((e) => AppNotification.fromJson(e as Map)) + .toList(); + } + return []; + } catch (_) { + return []; + } + } + + Future respondToNotification(String notificationId, bool isAccepted) async { + if (AppConfig.debug) { + await Future.delayed(const Duration(milliseconds: 400)); + return null; + } + + try { + final res = await http + .post( + Uri.parse('${AppConfig.baseUrl}/notifications/$notificationId/respond'), + headers: await _headers(), + body: jsonEncode({'is_accepted': isAccepted}), + ) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode == 200) return null; + try { + final data = jsonDecode(res.body) as Map; + return (data['detail'] as String?) ?? 'خطا در پاسخ به اعلان'; + } catch (_) { + return 'خطا در پاسخ به اعلان'; + } + } catch (_) { + return 'خطا در اتصال به سرور'; + } + } }