diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 0d29021..183f7ca 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -7,6 +7,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + use_build_context_synchronously: ignore + use_null_aware_elements: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/mobile/lib/controllers/auth.dart b/mobile/lib/controllers/auth.dart index 3e1cbfc..49ec2d1 100644 --- a/mobile/lib/controllers/auth.dart +++ b/mobile/lib/controllers/auth.dart @@ -1,13 +1,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:dio/dio.dart'; import 'package:mobile/models/user.dart'; import '../services/auth.dart'; class AuthState extends ChangeNotifier { final AuthService _authService = AuthService(); - final FlutterSecureStorage _storage = const FlutterSecureStorage(); String? _token; bool _isLoading = false; @@ -44,7 +42,7 @@ class AuthState extends ChangeNotifier { } Future checkAutoLogin() async { - _token = await _storage.read(key: "access_token"); + _token = await _authService.getToken(); if (_token != null) { await loadUserProfile(); diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index c73f11b..f9bc849 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -1,33 +1,45 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:isolate'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +// import 'package:mobile/controllers/auth.dart'; import 'package:mobile/models/group.dart'; import 'package:mobile/models/inbox_item.dart'; +import 'package:mobile/pages/chat_details_page.dart'; import '../models/message.dart'; import '../models/conversation.dart'; import '../services/api.dart'; import '../services/ws.dart'; +import '../services/auth.dart'; class ChatController extends ChangeNotifier with WidgetsBindingObserver { final ApiService _api = ApiService(); final WebSocketService _ws = WebSocketService(); + final AuthService _auth = AuthService(); + // final AuthState _user = AuthState(); List activeChat = []; List contactSearchResults = []; List inbox = []; + Map groupMemberNames = {}; + Map userCache = {}; String? currentChatUserId; - String? _sessionToken; bool isPeerTyping = false; bool isPeerOnline = false; bool isSearchLoading = false; - bool _isWsInitialized = false; bool isChatHistoryLoading = false; + bool isCurrentChatGroup = false; + + bool _isLoadingDetails = false; + bool get isLoadingDetails => _isLoadingDetails; + + List _currentGroupMembers = []; + List get currentGroupMembers => _currentGroupMembers; + + bool _isWsInitialized = false; StreamSubscription? _wsSubscription; - Timer? _backgroundSyncTimer; + Timer? _reconnectTimer; ChatController() { WidgetsBinding.instance.addObserver(this); @@ -36,14 +48,13 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - if (_sessionToken != null) { - _isWsInitialized = false; - _connectWebSocket(); - loadInbox(); - _startBackgroundSync(); - } + _isWsInitialized = false; + _connectWebSocket(); + loadInbox(); } else if (state == AppLifecycleState.paused) { - _backgroundSyncTimer?.cancel(); + _reconnectTimer?.cancel(); + _ws.disconnect(); + _isWsInitialized = false; } } @@ -51,35 +62,55 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _wsSubscription?.cancel(); - _backgroundSyncTimer?.cancel(); + _reconnectTimer?.cancel(); + _ws.disconnect(); super.dispose(); } - Future initSession(String token) async { - _sessionToken = token; - _startBackgroundSync(); + Future initSession() async { + inbox.clear(); + activeChat.clear(); + contactSearchResults.clear(); + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + + // _startBackgroundSync(); + if (_isWsInitialized) return; _isWsInitialized = true; + _connectWebSocket(); - await loadInbox(); + notifyListeners(); } - void _startBackgroundSync() { - _backgroundSyncTimer?.cancel(); - _backgroundSyncTimer = Timer.periodic(const Duration(seconds: 4), (_) { - loadInbox(); - - if (currentChatUserId != null) { - syncActiveChatSilently(); - } - }); + void clearSessionData() { + _ws.disconnect(); + _isWsInitialized = false; + inbox.clear(); + activeChat.clear(); + contactSearchResults.clear(); + groupMemberNames.clear(); + userCache.clear(); + _currentGroupMembers.clear(); + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + notifyListeners(); } void _connectWebSocket() async { - if (_sessionToken == null) return; + + if (_ws.isConnected) return; + + _reconnectTimer?.cancel(); + + final freshToken = await _auth.getToken(); + if (freshToken == null) return; await _wsSubscription?.cancel(); - await _ws.connect(_sessionToken!); + _ws.disconnect(); + await _ws.connect(freshToken); _wsSubscription = _ws.stream?.listen( (rawFrame) { @@ -98,42 +129,72 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { onDone: () { _ws.disconnect(); _isWsInitialized = false; - Future.delayed(const Duration(seconds: 3), () { - if (_sessionToken != null) { - _isWsInitialized = true; - _connectWebSocket(); - } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(const Duration(seconds: 3), () { + _isWsInitialized = true; + _connectWebSocket(); }); }, ); } - Future openChat(String targetUid) async { + Future openChat(String targetUid, {bool isGroup = false}) async { if (targetUid.isEmpty || targetUid == 'null') return; currentChatUserId = targetUid; + isCurrentChatGroup = isGroup; activeChat.clear(); isPeerTyping = false; isPeerOnline = false; isChatHistoryLoading = true; + groupMemberNames.clear(); + + if (isGroup) { + _api + .getGroupMembers(targetUid) + .then((memberRes) { + if (currentChatUserId != targetUid) return; + final memberList = _extractDataList(memberRes.data, ['members']); + + _currentGroupMembers = memberList + .map((json) => ChatMember.fromJson(json)) + .toList(); + + for (var member in memberList) { + userCache[member['user_id'].toString()] = + member['display_name'] ?? 'Member'; + groupMemberNames[member['user_id'].toString()] = + member['display_name'] ?? 'Unknown'; + } + notifyListeners(); + }) + .catchError((e) { + debugPrint("Failed to load group members: $e"); + }); + } + notifyListeners(); try { - final res = await _api.getChatHistory(targetUid); + final res = await _api.getChatHistory(targetUid, isGroup: isGroup); if (currentChatUserId != targetUid) return; final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = await Isolate.run(() { - return targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - }); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); if (currentChatUserId != targetUid) return; activeChat = loadedMessages; - _ws.sendReadReceipt(targetId: targetUid); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); + _ws.sendRequestStatus(targetId: targetUid); + unawaited(loadInbox()); } catch (e) { debugPrint("Timeline tracking fail: $e"); @@ -143,6 +204,27 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } } + Future fetchGroupMembers(String groupId) async { + try { + _isLoadingDetails = true; + notifyListeners(); + + final response = await _api.getGroupMembers(groupId); + + if (response.statusCode == 200) { + final List data = response.data['data'] ?? response.data; + _currentGroupMembers = data + .map((json) => ChatMember.fromJson(json)) + .toList(); + } + } catch (e) { + debugPrint("Error fetching members: $e"); + } finally { + _isLoadingDetails = false; + notifyListeners(); + } + } + Future queryUsers(String term) async { final String cleanTerm = term.trim(); if (cleanTerm.isEmpty || cleanTerm.length < 3) { @@ -178,14 +260,15 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { String text, { Message? replyingTo, String? replyingToName, + String? senderId, }) async { final cleanContent = text.trim(); if (currentChatUserId == null || cleanContent.isEmpty) return; - final targetId = currentChatUserId!; final clientMessageId = "cli_${DateTime.now().millisecondsSinceEpoch}"; QuotedMessage? quoted; + if (replyingTo != null) { quoted = QuotedMessage( id: replyingTo.id, @@ -205,30 +288,39 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { replyToMessageId: replyingTo?.id, quotedMessage: quoted, ); - activeChat.add(optimisticMsg); notifyListeners(); try { - await _api.sendMessage( - targetId, - cleanContent, - replyToMessageId: replyingTo?.id, - ); + isCurrentChatGroup && senderId != null + ? _ws.sendGroupChat( + messageId: "", + groupId: targetId, + content: cleanContent, + senderId: senderId, + replyToMessageId: replyingTo?.id, + ) + : _ws.sendChat( + messageId: "", + receiverId: targetId, + content: cleanContent, + replyToMessageId: replyingTo?.id, + ); unawaited(loadInbox()); } catch (e) { - if (e is DioException) { - debugPrint("BACKEND REJECTION REASON: ${e.response?.data}"); - } else { - debugPrint("Failed to send message: $e"); - } + debugPrint("Failed to send message: $e"); activeChat.removeWhere((msg) => msg.id == clientMessageId); notifyListeners(); } } + void sendTypingNotification(bool typing) { if (currentChatUserId != null) { - _ws.sendTyping(targetId: currentChatUserId!, isTyping: typing); + _ws.sendTyping( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + isTyping: typing, + ); } } @@ -236,12 +328,25 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String? type = data['type']; if (type == null) return; - final String? senderId = - data['sender_id'] ?? data['sender'] ?? data['receiver_id']; - final String? cleanSender = senderId?.trim().toLowerCase(); final String? cleanCurrentChat = currentChatUserId?.trim().toLowerCase(); - final bool isCurrentChat = - cleanSender != null && cleanSender == cleanCurrentChat; + + bool isCurrentChat = false; + if (cleanCurrentChat != null) { + if (isCurrentChatGroup) { + final String? eventGroupId = data['group_id'] + ?.toString() + .trim() + .toLowerCase(); + isCurrentChat = (eventGroupId == cleanCurrentChat); + } else { + final String? eventSenderId = + (data['sender_id'] ?? data['sender'] ?? data['receiver_id']) + ?.toString() + .trim() + .toLowerCase(); + isCurrentChat = (eventSenderId == cleanCurrentChat); + } + } switch (type) { case 'user_status': @@ -250,11 +355,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { ?.toString() .trim() .toLowerCase(); - if (eventUserId == cleanCurrentChat) { + if (eventUserId == cleanCurrentChat && !isCurrentChatGroup) { isPeerOnline = data['online'] == true || data['content'] == 'online'; notifyListeners(); - - if (isPeerOnline) unawaited(syncActiveChatSilently()); + // if (isPeerOnline) unawaited(syncActiveChatSilently()); } break; @@ -262,7 +366,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { case 'message': if (isCurrentChat) { activeChat.add(Message.fromJson(data)); - _ws.sendReadReceipt(targetId: currentChatUserId!); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + ); notifyListeners(); } loadInbox(); @@ -272,16 +379,15 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (isCurrentChat) { final bool nowTyping = data['content'] == 'true' || data['content'] == true; - if (isPeerTyping != nowTyping) { isPeerTyping = nowTyping; notifyListeners(); - if (!isPeerTyping) { - Future.delayed(const Duration(milliseconds: 500), () { - unawaited(syncActiveChatSilently()); - }); - } + // if (!isPeerTyping) { + // Future.delayed(const Duration(milliseconds: 500), () { + // unawaited(syncActiveChatSilently()); + // }); + // } } } break; @@ -293,18 +399,26 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String payloadReceiver = (data['receiver_id'] ?? '') .toString() .toLowerCase(); + final String payloadGroup = (data['group_id'] ?? '') + .toString() + .toLowerCase(); final String safeChatId = (currentChatUserId ?? '').toLowerCase(); - final bool isRelevantToThisChat = - safeChatId.isNotEmpty && - (payloadSender == safeChatId || payloadReceiver == safeChatId); + bool isRelevantToThisChat = false; + if (safeChatId.isNotEmpty) { + if (isCurrentChatGroup) { + isRelevantToThisChat = (payloadGroup == safeChatId); + } else { + isRelevantToThisChat = + (payloadSender == safeChatId || payloadReceiver == safeChatId); + } + } if (isRelevantToThisChat) { bool updated = false; activeChat = activeChat.map((msg) { final String msgSenderId = msg.senderId.trim().toLowerCase(); - if (!msg.isRead && (msgSenderId == 'me' || msgSenderId != safeChatId)) { updated = true; @@ -316,8 +430,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (updated) { notifyListeners(); } - - unawaited(syncActiveChatSilently()); + // unawaited(syncActiveChatSilently()); } break; } @@ -328,15 +441,16 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String targetUid = currentChatUserId!; try { - final res = await _api.getChatHistory(targetUid); + final res = await _api.getChatHistory( + targetUid, + isGroup: isCurrentChatGroup, + ); if (currentChatUserId != targetUid) return; final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = await Isolate.run(() { - return targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - }); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); if (currentChatUserId != targetUid) return; @@ -357,8 +471,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { createdAt: loadedMessages[i].createdAt, isRead: loadedMessages[i].isRead, replyToMessageId: loadedMessages[i].replyToMessageId, - quotedMessage: - existingMsg.quotedMessage, + quotedMessage: existingMsg.quotedMessage, ); } } @@ -374,7 +487,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (hasChanges) { activeChat = loadedMessages; notifyListeners(); - _ws.sendReadReceipt(targetId: targetUid); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); } } catch (e) { debugPrint("Silent chat sync fail: $e"); @@ -395,44 +511,88 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { Future loadInbox() async { try { - final responses = await Future.wait([ - _api.getConversations(), - _api.getGroups(), - ]); - - final conversationRes = responses[0]; - final groupRes = responses[1]; - - final rawConversations = _extractDataList(conversationRes.data, [ - 'conversations', - ]); - final rawGroups = _extractDataList(groupRes.data, ['groups']); - - final mappedConversations = rawConversations - .map((json) => Conversation.fromJson(json)) - .map((conv) => InboxItem.fromConversation(conv)) - .toList(); + final response = await _api.getConversations(); + // debugPrint("RAW INBOX DATA: ${response.data}"); + final rawData = _parseResponse(response.data, ['conversations']); + + List combinedInbox = []; + + for (var json in rawData) { + try{final bool isGroup = + json['is_group'] == true || json['type'] == 'group'; + + if (isGroup) { + combinedInbox.add(InboxItem.fromGroup(Group.fromJson(json))); + + final String? groupId = json['id']; + if (groupId != null) { + _api + .getGroupMembers(groupId) + .then((res) { + final members = _extractDataList(res.data, ['members']); + bool updatedCache = false; + for (var m in members) { + final uid = m['user_id'].toString(); + final name = m['display_name'] ?? 'Member'; + if (userCache[uid] != name) { + userCache[uid] = name; + updatedCache = true; + } + } + if (updatedCache) notifyListeners(); + }) + .catchError((_) {}); + } - final mappedGroups = rawGroups - .map((json) => Group.fromJson(json)) - .map((group) => InboxItem.fromGroup(group)) - .toList(); + } else { + combinedInbox.add( + InboxItem.fromConversation(Conversation.fromJson(json)), + ); + }} catch (e){ + debugPrint("❌ CRASH ON ITEM PARSE: $e"); + debugPrint("❌ BAD JSON OBJECT: $json"); + } + } - final combinedInbox = [...mappedConversations, ...mappedGroups]; combinedInbox.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - if (inbox.length != combinedInbox.length || - (inbox.isNotEmpty && - combinedInbox.isNotEmpty && - inbox.first.id != combinedInbox.first.id) || - (inbox.isNotEmpty && - combinedInbox.isNotEmpty && - inbox.first.lastMessage != combinedInbox.first.lastMessage)) { + if (_hasInboxChanged(inbox, combinedInbox)) { inbox = combinedInbox; notifyListeners(); + if (currentChatUserId != null) { + unawaited(syncActiveChatSilently()); + } + } + } catch (e, stackTrace) { + debugPrint("❌ MAJOR INBOX READ ERROR: $e"); + debugPrint("❌ STACK TRACE: $stackTrace"); + } + } + + List _parseResponse(dynamic data, List primaryKeys) { + if (data is List) return data; + + if (data is Map) { + for (var key in primaryKeys) { + if (data.containsKey(key) && data[key] is List) return data[key]; + } + if (data.containsKey('data') && data['data'] is List) return data['data']; + } + return []; + } + + bool _hasInboxChanged(List oldList, List newList) { + if (oldList.length != newList.length) return true; + for (int i = 0; i < oldList.length; i++) { + final old = oldList[i]; + final current = newList[i]; + if (old.id != current.id || + old.lastMessage != current.lastMessage || + old.timestamp != current.timestamp || + old.unreadCount != current.unreadCount) { + return true; } - } catch (e) { - debugPrint("Inbox read error: $e"); } + return false; } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a1be3d8..7d8c399 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,19 +1,30 @@ import 'package:flutter/material.dart'; import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:mobile/themes/theme_provider.dart'; import 'package:provider/provider.dart'; import 'core/constants.dart'; import 'controllers/auth.dart'; import 'controllers/chat.dart'; +import 'services/auth.dart'; import 'pages/login.dart'; import 'pages/home_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Env.init(); + await AuthService().initTokens(); + + final themeProvider = ThemeProvider(); + + while (!themeProvider.isInitialized) { + await Future.delayed(const Duration(milliseconds: 10)); + } + runApp( MultiProvider( providers: [ + ChangeNotifierProvider.value(value: themeProvider), ChangeNotifierProvider(create: (_) => BasicProviders()), ChangeNotifierProvider(create: (_) => AuthState()), ChangeNotifierProvider(create: (_) => ChatController()), @@ -32,11 +43,7 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Elephant', debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - colorSchemeSeed: const Color(0xFF0052CC), - scaffoldBackgroundColor: const Color(0xFFFAFBFC), - ), + theme: context.watch().themeData, home: const SessionGateway(), ); } @@ -65,7 +72,7 @@ class _SessionGatewayState extends State { if (token != null && mounted) { _lastInitializedToken = token; - context.read().initSession(token); + context.read().initSession(); } if (mounted) { @@ -78,10 +85,12 @@ class _SessionGatewayState extends State { @override Widget build(BuildContext context) { if (!_hasCheckedAutoLogin) { - return const Scaffold( - backgroundColor: Colors.white, + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Center( - child: CircularProgressIndicator(color: Color(0xFF0052CC)), + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), ), ); } @@ -91,7 +100,7 @@ class _SessionGatewayState extends State { if (authState.token != null && authState.token != _lastInitializedToken) { _lastInitializedToken = authState.token; WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().initSession(authState.token!); + context.read().initSession(); }); } diff --git a/mobile/lib/models/group.dart b/mobile/lib/models/group.dart index 4532bcc..e91e8aa 100644 --- a/mobile/lib/models/group.dart +++ b/mobile/lib/models/group.dart @@ -3,20 +3,49 @@ class Group { final String name; final String createdBy; final DateTime createdAt; + final String? lastMessageSender; + final String lastMessage; + final DateTime lastMessageAt; Group({ required this.id, required this.name, required this.createdBy, required this.createdAt, + this.lastMessageSender, + required this.lastMessage, + required this.lastMessageAt, }); factory Group.fromJson(Map json) { + String content = 'No messages yet'; + + DateTime msgTime = DateTime.now(); + if (json['last_message_time'] != null) { + msgTime = DateTime.parse(json['last_message_time']); + } else if (json['created_at'] != null) { + msgTime = DateTime.parse(json['created_at']); + } + + final dynamic lastMsgData = json['last_message']; + if (lastMsgData != null) { + if (lastMsgData is Map) { + content = lastMsgData['content'] ?? content; + } else if (lastMsgData is String) { + content = lastMsgData; + } + } + return Group( id: json['id'] ?? '', name: json['name'] ?? '', createdBy: json['created_by'] ?? '', - createdAt: DateTime.parse(json['created_at'] ?? DateTime.now().toIso8601String()), + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + lastMessageSender: json['sender_id'], + lastMessage: content, + lastMessageAt: msgTime, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart index 759fb4c..ed603a1 100644 --- a/mobile/lib/models/inbox_item.dart +++ b/mobile/lib/models/inbox_item.dart @@ -10,6 +10,7 @@ class InboxItem { final bool isGroup; final bool isRead; final int unreadCount; + final String? lastMessageSender; InboxItem({ required this.id, @@ -20,6 +21,7 @@ class InboxItem { this.isRead = true, this.unreadCount = 0, this.username, + this.lastMessageSender, }); InboxItem copyWith({ @@ -61,11 +63,12 @@ class InboxItem { return InboxItem( id: group.id, title: group.name, - lastMessage: 'Tap to view group', - timestamp: group.createdAt, + timestamp: group.lastMessageAt, isGroup: true, isRead: true, unreadCount: 0, + lastMessageSender: group.lastMessageSender, + lastMessage: group.lastMessage, ); } } diff --git a/mobile/lib/pages/chat_details_page.dart b/mobile/lib/pages/chat_details_page.dart new file mode 100644 index 0000000..16bbb21 --- /dev/null +++ b/mobile/lib/pages/chat_details_page.dart @@ -0,0 +1,664 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/controllers/chat.dart'; +import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:provider/provider.dart'; + +class ChatMember { + final String userId; + final String username; + final String displayName; + final String role; + final String? avatarUrl; + + ChatMember({ + required this.userId, + required this.username, + required this.displayName, + required this.role, + this.avatarUrl, + }); + + factory ChatMember.fromJson(Map json) { + return ChatMember( + userId: json['user_id'].toString(), + username: json['username'] ?? '', + displayName: json['display_name'] ?? json['username'] ?? 'Unknown', + role: json['role'] ?? 'member', + avatarUrl: json['avatarUrl'], + ); + } +} + +class ChatDetailsPage extends StatefulWidget { + final bool isGroup; + final String chatName; + final String chatImageUrl; + final String chatId; + + const ChatDetailsPage({ + super.key, + required this.isGroup, + required this.chatName, + required this.chatImageUrl, + required this.chatId, + }); + + @override + State createState() => _ChatDetailsPageState(); +} + +class _ChatDetailsPageState extends State { + @override + void initState() { + super.initState(); + if (widget.isGroup) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchGroupMembers(widget.chatId); + }); + } + } + + @override + Widget build(BuildContext context) { + final chatController = context.watch(); + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: CustomScrollView( + slivers: [ + _buildSliverAppBar(), + SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 10), + _buildCommonActions(), + const SizedBox(height: 10), + _buildMediaSection(), + const SizedBox(height: 10), + _buildNotificationSettings(), + const SizedBox(height: 10), + + if (widget.isGroup) + _buildGroupMembersSection(chatController) + else + _buildOneOnOneDetails(), + + const SizedBox(height: 40), + ]), + ), + ], + ), + ); + } + + Widget _buildSliverAppBar() { + return SliverAppBar( + expandedHeight: 250.0, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + centerTitle: true, + title: Text(widget.chatName), + background: Hero( + tag: 'profile', + child: Padding( + padding: const EdgeInsets.all(50), + child: CircleAvatar(child: Icon(Icons.person, size: 100)), + ), + ), + ), + ); + } + + Widget _buildCommonActions() { + return _SectionContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionIcon(icon: Icons.call, label: 'Audio'), + _ActionIcon(icon: Icons.videocam, label: 'Video'), + _ActionIcon( + icon: Icons.search, + label: 'Search', + onTap: () { + Navigator.pop(context, 'start_search'); + }, + ), + ], + ), + ); + } + + Widget _buildMediaSection() { + return _SectionContainer( + child: ListTile( + title: const Text('Media, links, and docs'), + trailing: const Row( + mainAxisSize: MainAxisSize.min, + children: [Text('0'), Icon(Icons.chevron_right)], + ), + onTap: () { + // TODO: Navigate to Media Page (API calls later) + }, + ), + ); + } + + Widget _buildNotificationSettings() { + return _SectionContainer( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.notifications_off), + title: const Text('Mute notifications'), + trailing: Switch(value: false, onChanged: (val) {}), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.music_note), + title: const Text('Custom notifications'), + onTap: () {}, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Media visibility'), + onTap: () {}, + ), + ], + ), + ); + } + + Widget _buildOneOnOneDetails() { + return _SectionContainer( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.lock), + title: const Text('Encryption'), + subtitle: const Text( + 'Messages and calls are end-to-end encrypted.', + ), + onTap: () {}, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About and phone number'), + subtitle: const Text( + '+1 234 567 8900\nHey there! I am using this app.', + ), + onTap: () {}, + ), + ], + ), + ); + } + + Widget _buildGroupMembersSection(ChatController chatController) { + final members = chatController.currentGroupMembers; + final isLoading = chatController.isLoadingDetails; + + final currentUserId = context.read().currentUser?.id; + final currentUserMember = members + .where((m) => m.userId == currentUserId) + .firstOrNull; + final isCurrentUserAdmin = currentUserMember?.role == 'admin'; + + return Material( + color: Theme.of(context).colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + '${members.length} participants', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + + if (isCurrentUserAdmin) + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.person_add, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: const Text('Add participants'), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + builder: (context) => + _AddParticipantSheet(chatId: widget.chatId), + ); + }, + ), + + if (isLoading && members.isEmpty) + const Padding( + padding: EdgeInsets.only(bottom: 32.0), + child: Center(child: CircularProgressIndicator()), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + final isThisUserAdmin = member.role == 'admin'; + + return ListTile( + leading: CircleAvatar( + backgroundImage: member.avatarUrl != null + ? NetworkImage(member.avatarUrl!) + : null, + child: member.avatarUrl == null + ? const Icon(Icons.person) + : null, + ), + title: Text(member.displayName), + subtitle: isThisUserAdmin + ? Text( + 'Admin', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), + ) + : null, + onTap: () => + _showMemberDetailsDialog(member, isCurrentUserAdmin), + ); + }, + ), + ], + ), + ); + } + + void _showMemberDetailsDialog(ChatMember member, bool isCurrentUserAdmin) { + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text('Message ${member.displayName}'), + onTap: () { + /* Navigate to 1-on-1 chat */ + }, + ), + ListTile( + title: Text('View Profile (@${member.username})'), + onTap: () { + Navigator.pop(context); + }, + ), + if (isCurrentUserAdmin && + member.userId != context.read().currentUser?.id) + ListTile( + title: const Text( + 'Remove from group', + style: TextStyle(color: Colors.red), + ), + onTap: () async { + Navigator.pop(context); + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Participant'), + content: Text( + 'Are you sure you want to remove ${member.displayName} from this group?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + + if (confirm == true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Removing member...'), + duration: Duration(seconds: 1), + ), + ); + + final success = await context + .read() + .removeMemberFromGroup(widget.chatId, member.userId); + + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${member.displayName} removed.'), + ), + ); + context.read().fetchGroupMembers( + widget.chatId, + ); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to remove member.'), + ), + ); + } + } + }, + ), + ], + ), + ); + }, + ); + } +} + +// --- Reusable Helper Widgets --- + +class _SectionContainer extends StatelessWidget { + final Widget child; + const _SectionContainer({required this.child}); + + @override + Widget build(BuildContext context) { + return Material(color: Theme.of(context).colorScheme.surface, child: child); + } +} + +class _ActionIcon extends StatelessWidget { + final IconData icon; + final String label; + final GestureTapCallback? onTap; + + const _ActionIcon({required this.icon, required this.label, this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + children: [ + Icon(icon, size: 28), + const SizedBox(height: 8), + Text(label), + ], + ), + ), + ); + } +} + +class _AddParticipantSheet extends StatefulWidget { + final String chatId; + + const _AddParticipantSheet({required this.chatId}); + + @override + State<_AddParticipantSheet> createState() => _AddParticipantSheetState(); +} + +class _AddParticipantSheetState extends State<_AddParticipantSheet> { + final _searchController = TextEditingController(); + Timer? _debounceTimer; + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value, ChatController chatState) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + if (value.length >= 3) { + chatState.queryUsers(value); + } + }); + setState(() {}); + } + + Future _addMember(String userId, String displayName) async { + FocusScope.of(context).unfocus(); + + final groupCtrl = context.read(); + final chatCtrl = context.read(); + + final success = await groupCtrl.addMemberToGroup(widget.chatId, userId); + + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$displayName added to the group!')), + ); + chatCtrl.fetchGroupMembers(widget.chatId); + Navigator.pop(context); + } else if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to add $displayName.'))); + } + } + + @override + Widget build(BuildContext context) { + final chatState = context.watch(); + final groupState = context.watch(); + + final String searchInput = _searchController.text.trim(); + final bool isSearching = searchInput.isNotEmpty; + final bool hasValidQueryLength = searchInput.length >= 3; + + final currentMemberIds = chatState.currentGroupMembers + .map((m) => m.userId) + .toSet(); + + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + height: MediaQuery.of(context).size.height * 0.7, + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + const Text( + 'Add Participants', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + controller: _searchController, + onChanged: (val) => _onSearchChanged(val, chatState), + decoration: InputDecoration( + hintText: "Search name or username", + prefixIcon: const Icon(Icons.search), + suffixIcon: isSearching + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + chatState.queryUsers(""); + setState(() {}); + }, + ) + : null, + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(height: 10), + + if (groupState.isLoading) + const Padding( + padding: EdgeInsets.all(8.0), + child: LinearProgressIndicator(), + ), + + Expanded( + child: isSearching + ? _buildSearchResults( + chatState, + currentMemberIds, + hasValidQueryLength, + ) + : _buildRecentContacts(chatState, currentMemberIds), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSearchResults( + ChatController chatState, + Set currentMemberIds, + bool hasValidQueryLength, + ) { + if (!hasValidQueryLength) { + return const Center( + child: Text("Type at least 3 characters to search..."), + ); + } + if (chatState.isSearchLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final results = chatState.contactSearchResults + .where((user) => !currentMemberIds.contains(user['id'])) + .toList(); + + if (results.isEmpty) { + return const Center(child: Text("No new users found.")); + } + + return ListView.builder( + itemCount: results.length, + itemBuilder: (context, index) { + final user = results[index]; + final String displayName = user['display_name'] ?? 'User'; + final String username = user['username'] ?? ''; + final String uid = user['id']; + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(displayName), + subtitle: Text("@$username"), + trailing: IconButton( + icon: Icon( + Icons.add_circle, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => _addMember(uid, displayName), + ), + ); + }, + ); + } + + Widget _buildRecentContacts( + ChatController chatState, + Set currentMemberIds, + ) { + final recents = chatState.inbox + .where( + (thread) => !thread.isGroup && !currentMemberIds.contains(thread.id), + ) + .toList(); + + if (recents.isEmpty) { + return const Center(child: Text("No recent contacts to add.")); + } + + return ListView.builder( + itemCount: recents.length, + itemBuilder: (context, index) { + final thread = recents[index]; + if (index == 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8), + child: Text( + "Recent Contacts", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + _buildRecentListTile(thread), + ], + ); + } + return _buildRecentListTile(thread); + }, + ); + } + + Widget _buildRecentListTile(dynamic thread) { + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(thread.title), + subtitle: Text("@${thread.username}"), + trailing: IconButton( + icon: Icon( + Icons.add_circle, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => _addMember(thread.id, thread.title), + ), + ); + } +} diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 5b2e0c1..c97dc5a 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -1,17 +1,28 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/pages/chat_details_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_screen_modular_widgets.dart'; import 'package:mobile/controllers/chat.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ChatPage extends StatefulWidget { final String chatUserId; final String displayName; + final bool isGroup; + final bool isNew; const ChatPage({ super.key, required this.chatUserId, required this.displayName, + this.isGroup = false, + this.isNew = false, }); @override @@ -19,11 +30,25 @@ class ChatPage extends StatefulWidget { } class _ChatPageState extends State with WidgetsBindingObserver { - final ScrollController _scrollController = ScrollController(); + bool _isSearchMode = false; + final TextEditingController _chatSearchController = TextEditingController(); + List _searchResults = []; + + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + bool _showScrollToBottom = false; + // ignore: prefer_final_fields bool _isNearBottom = true; final Set _selectedIndices = {}; dynamic _replyingToMessage; + late ChatController _chatController; + late AuthState _authState; + + Timer? _highlightTimer; + + String? _highlightedMessageId; int _previousMessageCount = 0; @@ -31,11 +56,17 @@ class _ChatPageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _scrollController.addListener(_scrollListener); + _itemPositionsListener.itemPositions.addListener(_scrollListener); + + _chatController = context.read(); + _authState = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.read().openChat(widget.chatUserId); + _chatController.openChat(widget.chatUserId, isGroup: widget.isGroup); + if (widget.isGroup && widget.isNew) { + _sendMessage('Hey Everyone!!'); + } } }); } @@ -43,13 +74,15 @@ class _ChatPageState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _scrollController.removeListener(_scrollListener); - _scrollController.dispose(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().closeChat(); - } + + _itemPositionsListener.itemPositions.removeListener(_scrollListener); + + Future.microtask(() { + _chatController.closeChat(); }); + + _highlightTimer?.cancel(); + super.dispose(); } @@ -65,12 +98,59 @@ class _ChatPageState extends State with WidgetsBindingObserver { } } + void _scrollToAndHighlight(String messageId) async { + setState(() { + _isSearchMode = false; + _chatSearchController.clear(); + _searchResults.clear(); + }); + + final chatState = context.read(); + final activeChat = chatState.activeChat; + final bool isTyping = chatState.isPeerTyping; + + final targetIndex = activeChat.indexWhere((msg) => msg.id == messageId); + + if (targetIndex != -1 && _itemScrollController.isAttached) { + final int realVisualIndex = + (activeChat.length - 1 - targetIndex) + (isTyping ? 3 : 2); + + await _itemScrollController.scrollTo( + index: realVisualIndex, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeInOutCubic, + alignment: 0.4, + ); + + setState(() { + _highlightedMessageId = messageId; + }); + + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(milliseconds: 1500), () { + if (mounted) { + setState(() { + _highlightedMessageId = null; + }); + } + }); + } + } + void _scrollListener() { - if (!_scrollController.hasClients) return; + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; - final offset = _scrollController.offset; - _isNearBottom = offset <= 300; - final isScrolledUp = offset > 300; + final bottomItem = positions.firstWhere( + (p) => p.index == 0, + orElse: () => const ItemPosition( + index: -1, + itemLeadingEdge: 0, + itemTrailingEdge: 0, + ), + ); + + final isScrolledUp = bottomItem.index > 2 || (bottomItem.index == -1); if (isScrolledUp != _showScrollToBottom) { setState(() { @@ -80,16 +160,16 @@ class _ChatPageState extends State with WidgetsBindingObserver { } void _scrollToBottom({bool animated = true}) { - if (!_scrollController.hasClients) return; + if (!_itemScrollController.isAttached) return; if (animated) { - _scrollController.animateTo( - 0.0, + _itemScrollController.scrollTo( + index: 0, duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } else { - _scrollController.jumpTo(0.0); + _itemScrollController.jumpTo(index: 0); } } @@ -109,6 +189,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { text, replyingTo: _replyingToMessage, replyingToName: replyName, + senderId: _authState.currentUser!.displayName, ); setState(() { @@ -140,8 +221,16 @@ class _ChatPageState extends State with WidgetsBindingObserver { final sortedIndices = _selectedIndices.toList()..sort(); final selectedTexts = sortedIndices - .map((index) => activeChat[index].content.toString()) - .join('\n'); + .map((index) { + final msg = activeChat[index]; + final time = DateFormat.jm().format(msg.createdAt); + final senderName = msg.senderId == _authState.currentUser!.id + ? "Me" + : widget.displayName; + + return '[$time] $senderName: ${msg.content}'; + }) + .join('\n\n'); Clipboard.setData(ClipboardData(text: selectedTexts)); @@ -171,6 +260,51 @@ class _ChatPageState extends State with WidgetsBindingObserver { return "${date.day}/${date.month}/${date.year}"; } + Widget _buildSearchAppBar(ThemeData theme) { + return AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container(color: theme.colorScheme.surface), + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + _isSearchMode = false; + _chatSearchController.clear(); + _searchResults.clear(); + }); + }, + ), + title: TextField( + controller: _chatSearchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search in chat...', + border: InputBorder.none, + ), + onChanged: (query) { + if (query.trim().isEmpty) { + setState(() => _searchResults = []); + return; + } + + final chatState = context.read(); + final lowercaseQuery = query.toLowerCase(); + + setState(() { + _searchResults = chatState.activeChat.where((msg) { + return msg.content.toLowerCase().contains(lowercaseQuery); + }).toList(); + }); + }, + ), + ); + } + @override Widget build(BuildContext context) { final chatState = context.watch(); @@ -204,60 +338,89 @@ class _ChatPageState extends State with WidgetsBindingObserver { extendBodyBehindAppBar: true, appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, -0.2), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: isSelectionMode - ? AppBar( - key: const ValueKey('SelectionAppBar'), - backgroundColor: theme.colorScheme.primary, - leading: IconButton( - icon: Icon( - Icons.close, - color: theme.colorScheme.onPrimary, - ), - onPressed: _clearSelection, - ), - title: Text( - '${_selectedIndices.length} Selected', - style: TextStyle(color: theme.colorScheme.onPrimary), - ), - actions: [ - IconButton( - icon: Icon( - Icons.copy, - color: theme.colorScheme.onPrimary, - ), - onPressed: () => _copySelectedMessages(activeChat), - ), - IconButton( - icon: Icon( - Icons.delete, - color: theme.colorScheme.onPrimary, + child: _isSearchMode + ? _buildSearchAppBar(theme) + : AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: + (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, -0.2), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: isSelectionMode + ? AppBar( + key: const ValueKey('SelectionAppBar'), + backgroundColor: theme.colorScheme.primary, + leading: IconButton( + icon: Icon( + Icons.close, + color: theme.colorScheme.onPrimary, + ), + onPressed: _clearSelection, + ), + title: Text( + '${_selectedIndices.length} Selected', + style: TextStyle( + color: theme.colorScheme.onPrimary, + ), + ), + actions: [ + IconButton( + icon: Icon( + Icons.copy, + color: theme.colorScheme.onPrimary, + ), + onPressed: () => + _copySelectedMessages(activeChat), + ), + IconButton( + icon: Icon( + Icons.delete, + color: theme.colorScheme.onPrimary, + ), + onPressed: () { + _clearSelection(); + }, + ), + ], + ) + : GlassAppBar( + isGroup: widget.isGroup, + key: const ValueKey('GlassAppBar'), + name: widget.displayName, + status: widget.isGroup + ? null + : _getPresenceStatusText(chatState), + onTitleTap: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatDetailsPage( + isGroup: widget.isGroup, + chatId: chatState.currentChatUserId!, + chatName: widget.displayName, + chatImageUrl: "", + ), + ), + ); + + // If the user clicked "Search" in ChatDetailsPage + if (result == 'start_search') { + setState(() { + _isSearchMode = true; + }); + } + }, ), - onPressed: () { - _clearSelection(); - }, - ), - ], - ) - : GlassAppBar( - key: const ValueKey('GlassAppBar'), - name: widget.displayName, - status: _getPresenceStatusText(chatState), - ), - ), + ), ), body: Stack( children: [ @@ -296,25 +459,29 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ), ) - : ListView.builder( + : ScrollablePositionedList.builder( key: const ValueKey('list'), - controller: _scrollController, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, reverse: true, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( - bottom: 140, + // bottom: 140, top: 140, left: 16, right: 16, ), itemCount: - activeChat.length + (chatState.isPeerTyping ? 2 : 1), + activeChat.length + + (chatState.isPeerTyping ? 2 : 1) + + 1, itemBuilder: (context, index) { if (index == 0) { + return const SizedBox(height: 140); + } + if (index == 1) { return AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, @@ -324,7 +491,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ); } - if (chatState.isPeerTyping && index == 1) { + if (chatState.isPeerTyping && index == 2) { return _AnimatedMessageItem( key: const ValueKey('typing_indicator'), child: Align( @@ -361,9 +528,12 @@ class _ChatPageState extends State with WidgetsBindingObserver { } final int msgIndex = - index - (chatState.isPeerTyping ? 2 : 1); + index - (chatState.isPeerTyping ? 3 : 2); final int realIndex = activeChat.length - 1 - msgIndex; final msg = activeChat[realIndex]; + + final bool isHighlighted = + msg.id == _highlightedMessageId; final bool isSelected = _selectedIndices.contains( realIndex, ); @@ -381,19 +551,34 @@ class _ChatPageState extends State with WidgetsBindingObserver { final String cleanSenderId = msg.senderId .trim() .toLowerCase(); - final String cleanPeerId = widget.chatUserId - .trim() - .toLowerCase(); + widget.chatUserId.trim().toLowerCase(); + final bool isMe = cleanSenderId == 'me' || - (cleanSenderId.isNotEmpty && - cleanSenderId != cleanPeerId); + (_authState.currentUser?.id != null && + cleanSenderId == + _authState.currentUser!.id.toLowerCase()); + + final String senderId = msg.senderId + .trim() + .toLowerCase(); + final String? displayName = widget.isGroup + ? (chatState.groupMemberNames[senderId] ?? + 'Unknown') + : null; + + bool showSenderName = widget.isGroup; + if (widget.isGroup && realIndex > 0) { + final previousMsg = activeChat[realIndex - 1]; + + if (previousMsg.senderId.trim().toLowerCase() == + cleanSenderId) { + showSenderName = false; + } + } return _AnimatedMessageItem( - key: ValueKey( - msg.id?.toString() ?? - msg.createdAt.millisecondsSinceEpoch.toString(), - ), + key: ValueKey(msg.id.toString()), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -438,11 +623,11 @@ class _ChatPageState extends State with WidgetsBindingObserver { duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 350), decoration: BoxDecoration( - color: isSelected + color: isSelected || isHighlighted ? theme.colorScheme.primary - .withValues(alpha: 0.15) + .withValues(alpha: 0.25) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), @@ -459,6 +644,15 @@ class _ChatPageState extends State with WidgetsBindingObserver { timestamp: msg.createdAt, isRead: msg.isRead, quotedMessage: msg.quotedMessage, + isGroup: widget.isGroup, + senderName: showSenderName + ? displayName + : null, + onQuoteTap: msg.quotedMessage != null + ? () => _scrollToAndHighlight( + msg.quotedMessage!.id, + ) + : null, ), ), ), @@ -470,118 +664,202 @@ class _ChatPageState extends State with WidgetsBindingObserver { }, ), ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - right: 16, - bottom: _replyingToMessage != null ? 180 : 100, - child: AnimatedScale( - scale: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutBack, - child: AnimatedOpacity( - opacity: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: FloatingActionButton( - mini: true, - backgroundColor: theme.colorScheme.surface, - foregroundColor: theme.colorScheme.primary, - elevation: 4, - onPressed: () => _scrollToBottom(animated: true), - child: const Icon(Icons.keyboard_arrow_down), + + if (_isSearchMode) + Positioned.fill( + top: kToolbarHeight + MediaQuery.of(context).padding.top, + child: Container( + color: theme.scaffoldBackgroundColor, + child: _searchResults.isEmpty + ? Center( + child: Text( + _chatSearchController.text.isEmpty + ? 'Type to search...' + : 'No messages found.', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.separated( + itemCount: _searchResults.length, + separatorBuilder: (context, index) => + const Divider(height: 1), + itemBuilder: (context, index) { + final msg = _searchResults[index]; + + String sender = "Unknown"; + if (msg.senderId == _authState.currentUser?.id) { + sender = "You"; + } else if (widget.isGroup) { + sender = + context + .read() + .groupMemberNames[msg.senderId] ?? + 'Someone'; + } else { + sender = widget.displayName; + } + + return Material( + color: Colors.transparent, + child: ListTile( + title: Text( + msg.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '$sender • ${DateFormat.yMd().add_jm().format(msg.createdAt)}', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + onTap: () { + // Close search mode + setState(() { + _isSearchMode = false; + _chatSearchController.clear(); + _searchResults.clear(); + }); + + FocusScope.of(context).unfocus(); + + _scrollToAndHighlight(msg.id); + }, + ), + ); + }, + ), + ), + ), + + if (!_isSearchMode) + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + right: 16, + bottom: _replyingToMessage != null ? 180 : 100, + child: AnimatedScale( + scale: _showScrollToBottom ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + child: AnimatedOpacity( + opacity: _showScrollToBottom ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: FloatingActionButton( + mini: true, + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + elevation: 4, + onPressed: () => _scrollToBottom(animated: true), + child: const Icon(Icons.keyboard_arrow_down), + ), ), ), ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: _replyingToMessage != null ? 1.0 : 0.0, - child: _replyingToMessage != null - ? Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 4, + if (!_isSearchMode) + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: _replyingToMessage != null ? 1.0 : 0.0, + child: _replyingToMessage != null + ? ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, ), - top: BorderSide( - color: theme.dividerColor, - width: 1, - ), - ), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues( - alpha: 0.1, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 4, + ), + top: BorderSide( + color: theme.colorScheme.surface + .withValues(alpha: 0.8), + width: 1, + ), + ), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withValues( + alpha: 0.04, + ), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], ), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + child: Row( children: [ - Text( - "Replying to message", - style: TextStyle( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - fontSize: 12, + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + "Replying to message", + style: TextStyle( + fontWeight: FontWeight.bold, + color: + theme.colorScheme.primary, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Text( + _replyingToMessage.content, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme + .colorScheme + .onSurface, + ), + ), + ], ), ), - const SizedBox(height: 4), - Text( - _replyingToMessage.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.colorScheme.onSurface, + IconButton( + icon: Icon( + Icons.close, + size: 20, + color: theme.iconTheme.color, + ), + onPressed: () => setState( + () => _replyingToMessage = null, ), ), ], ), ), - IconButton( - icon: Icon( - Icons.close, - size: 20, - color: theme.iconTheme.color, - ), - onPressed: () => setState( - () => _replyingToMessage = null, - ), - ), - ], - ), - ) - : const SizedBox(width: double.infinity, height: 0), + ), + ) + : const SizedBox(width: double.infinity, height: 0), + ), ), - ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), - ), - ], + ChatInputArea( + onSendMessage: _sendMessage, + onTypingChanged: (isTyping) => context + .read() + .sendTypingNotification(isTyping), + ), + ], + ), ), - ), ], ), ), diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index ea04f23..a127910 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -2,9 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:mobile/controllers/chat.dart'; -import 'package:mobile/pages/settings%20pages/accounts.dart'; import 'package:mobile/pages/settings_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/services/auth.dart'; import 'package:mobile/widgets/custom_cards.dart'; import 'package:provider/provider.dart'; @@ -46,10 +44,14 @@ class _HomePageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { final chatController = context.read(); - final authService = AuthService(); - final token = await authService.getToken(); + + final token = await AuthService().getToken(); + + if (!mounted) return; + if (token != null) { - await chatController.initSession(token); + await chatController.initSession(); + chatController.loadInbox(); } }); } @@ -136,10 +138,10 @@ class _HomePageState extends State { child: Container( height: 68, decoration: BoxDecoration( - color: theme.colorScheme.surface.withValues(alpha: 0.35), + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(24), border: Border.all( - color: theme.colorScheme.surface.withValues(alpha: 0.45), + color: theme.colorScheme.surface.withValues(alpha: 0.8), width: 1.5, ), boxShadow: [ @@ -178,16 +180,16 @@ class _HomePageState extends State { child: Container( decoration: BoxDecoration( color: theme.colorScheme.surface.withValues( - alpha: 0.85, + alpha: 0.8, ), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: theme.colorScheme.primary.withValues( - alpha: 0.08, + alpha: 0.4, ), - blurRadius: 12, - offset: const Offset(0, 4), + blurRadius: 20, + // offset: const Offset(0, 0), ), ], ), @@ -255,9 +257,7 @@ class _HomePageState extends State { child: BackdropFilter( enabled: true, filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - color: theme.scaffoldBackgroundColor.withValues(alpha: 0.7), - ), + child: Container(color: theme.colorScheme.surface), ), ), leading: _isSelectionMode @@ -279,7 +279,9 @@ class _HomePageState extends State { ), child: CircleAvatar( radius: 18, - backgroundColor: theme.colorScheme.primaryContainer, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.15, + ), child: Icon( Icons.person, color: theme.colorScheme.primary, @@ -372,6 +374,7 @@ class _HomePageState extends State { !_isSelectionMode ? PopupMenuButton( iconColor: theme.colorScheme.onSurface, + color: theme.scaffoldBackgroundColor, onSelected: (String value) {}, borderRadius: BorderRadius.circular(20), itemBuilder: (context) => [ @@ -522,19 +525,50 @@ class _HomePageState extends State { opacity: (_isFabVisible && !_isSelectionMode) ? 1.0 : 0.0, child: Padding( padding: const EdgeInsets.only(bottom: 10), - child: FloatingActionButton( - heroTag: "fab_pen", - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - child: const Icon(Icons.edit), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const NewChatPage(), + child: + SizedBox( + width: 56, + height: 56, + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.5), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: IconButton( + icon: Icon( + Icons.edit, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const NewChatPage(), + ), + ); + }, + ), ), - ); - }, + ), + ), ), ), ), diff --git a/mobile/lib/pages/login.dart b/mobile/lib/pages/login.dart index f5050cc..2679b22 100644 --- a/mobile/lib/pages/login.dart +++ b/mobile/lib/pages/login.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/constants.dart'; @@ -40,130 +42,139 @@ class _LoginPageState extends State showDialog( context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: Colors.white, - title: const Row( - children: [ - Icon(Icons.dns, color: Color(0xFF0052CC)), - SizedBox(width: 8), - Text( - "Server Settings", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Enter your decentralized peer node coordinates:", - style: TextStyle(fontSize: 13, color: Colors.black54), - ), - const SizedBox(height: 16), - const Text( - "Host", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black54, + builder: (dialogContext) => BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: AlertDialog( + backgroundColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + title: Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + SizedBox(width: 8), + Text( + "Server Settings", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - ), - const SizedBox(height: 6), - TextField( - controller: hostController, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: InputDecoration( - hintText: Env.defaultHost, - filled: true, - fillColor: const Color(0xFFF4F5F7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter your decentralized peer node coordinates:", + style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ), - const SizedBox(height: 14), - const Text( - "Port", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black54, + const SizedBox(height: 16), + Text( + "Host", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(height: 6), - TextField( - controller: portController, - keyboardType: TextInputType.number, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: InputDecoration( - hintText: Env.defaultPort, - filled: true, - fillColor: const Color(0xFFF4F5F7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, + const SizedBox(height: 6), + TextField( + controller: hostController, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14), + decoration: InputDecoration( + hintText: Env.defaultHost, + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + ), + const SizedBox(height: 14), + Text( + "Port", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () async { - final navigator = Navigator.of(dialogContext); - final scaffoldMessenger = ScaffoldMessenger.of(context); - - await Env.updateConfig(Env.defaultHost, Env.defaultPort); - - navigator.pop(); - setState(() {}); - scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text( - "Reset connection to elephant.commandlinecoding.in", + const SizedBox(height: 6), + TextField( + controller: portController, + keyboardType: TextInputType.number, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14), + decoration: InputDecoration( + hintText: Env.defaultPort, + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, ), ), - ); - }, - child: const Text( - "Reset Default", - style: TextStyle(color: Colors.black54), - ), + ), + ], ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0052CC), + actions: [ + TextButton( + onPressed: () async { + final navigator = Navigator.of(dialogContext); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + await Env.updateConfig(Env.defaultHost, Env.defaultPort); + + navigator.pop(); + setState(() {}); + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text( + "Reset connection to elephant.commandlinecoding.in", + ), + ), + ); + }, + child: Text( + "Reset Default", + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), ), - onPressed: () async { - final newHost = hostController.text.trim(); - final newPort = portController.text.trim(); - - final navigator = Navigator.of(dialogContext); - final scaffoldMessenger = ScaffoldMessenger.of(context); - - await Env.updateConfig(newHost, newPort); - - navigator.pop(); - setState(() {}); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Target base set to: ${Env.httpBaseUrl}"), - ), - ); - }, - child: const Text("Save", style: TextStyle(color: Colors.white)), - ), - ], + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + ), + onPressed: () async { + final newHost = hostController.text.trim(); + final newPort = portController.text.trim(); + + final navigator = Navigator.of(dialogContext); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + await Env.updateConfig(newHost, newPort); + + navigator.pop(); + setState(() {}); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Target base set to: ${Env.httpBaseUrl}"), + ), + ); + }, + child: const Text("Save", style: TextStyle(color: Colors.white)), + ), + ], + ), ), ); } @@ -190,17 +201,17 @@ class _LoginPageState extends State return Expanded( child: OutlinedButton.icon( onPressed: () {}, - icon: Icon(icon, color: Colors.black87, size: 18), + icon: Icon(icon, color: Theme.of(context).colorScheme.onSurface, size: 18), label: Text( label, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFDFE1E6)), + side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), @@ -213,7 +224,7 @@ class _LoginPageState extends State final authState = context.watch(); return Scaffold( - backgroundColor: const Color(0xFFFAFBFC), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), @@ -221,9 +232,12 @@ class _LoginPageState extends State constraints: const BoxConstraints(maxWidth: 420), padding: const EdgeInsets.all(32), decoration: BoxDecoration( - color: Colors.white, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE3E6EB), width: 1), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), @@ -252,9 +266,7 @@ class _LoginPageState extends State style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, - color: const Color( - 0xFF0052CC, - ).withValues(alpha: 0.95), + color: Theme.of(context).colorScheme.primary, letterSpacing: 0.5, ), ), @@ -267,10 +279,10 @@ class _LoginPageState extends State ? 'Welcome Back' : 'Create Account', textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: Color(0xFF172B4D), + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 6), @@ -279,9 +291,9 @@ class _LoginPageState extends State ? 'Access your secure workspace' : 'Join the peer mesh platform', textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: Color(0xFF5E6C84), + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 28), @@ -290,7 +302,9 @@ class _LoginPageState extends State height: 46, padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: const Color(0xFFEBECF0), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: TabBar( @@ -298,10 +312,11 @@ class _LoginPageState extends State onTap: (index) => setState(() {}), indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, - labelColor: const Color(0xFF0052CC), - unselectedLabelColor: const Color(0xFF5E6C84), - indicator: BoxDecoration( - color: Colors.white, + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + indicator: BoxDecoration(color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(6), boxShadow: [ BoxShadow( @@ -342,14 +357,16 @@ class _LoginPageState extends State vertical: 10, ), decoration: BoxDecoration( - color: const Color(0xFFFFEBE6), + color: Theme.of(context).colorScheme.errorContainer, borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFFFBDAD)), + border: Border.all( + color: Theme.of(context).colorScheme.error, + ), ), child: Text( authState.errorMessage!, - style: const TextStyle( - color: Color(0xFFBF2600), + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, fontSize: 13, fontWeight: FontWeight.w500, ), @@ -370,12 +387,15 @@ class _LoginPageState extends State const SizedBox(height: 6), TextFormField( controller: _usernameController, - style: const TextStyle(color: Colors.black87, fontSize: 14), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14), decoration: InputDecoration( hintText: "username", hintStyle: const TextStyle(color: Colors.black38), filled: true, - fillColor: const Color(0xFFF4F5F7), + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, @@ -403,15 +423,17 @@ class _LoginPageState extends State const SizedBox(height: 6), TextFormField( controller: _displayNameController, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14, ), decoration: InputDecoration( hintText: "John Doe", hintStyle: const TextStyle(color: Colors.black38), filled: true, - fillColor: const Color(0xFFF4F5F7), + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, @@ -461,12 +483,15 @@ class _LoginPageState extends State TextFormField( controller: _passwordController, obscureText: true, - style: const TextStyle(color: Colors.black87, fontSize: 14), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14), decoration: InputDecoration( hintText: "••••••••", hintStyle: const TextStyle(color: Colors.black38), filled: true, - fillColor: const Color(0xFFF4F5F7), + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, @@ -485,7 +510,7 @@ class _LoginPageState extends State ElevatedButton( onPressed: authState.isLoading ? null : _submit, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0052CC), + backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( @@ -494,11 +519,11 @@ class _LoginPageState extends State elevation: 0, ), child: authState.isLoading - ? const SizedBox( + ? SizedBox( height: 20, width: 20, child: CircularProgressIndicator( - color: Colors.white, + color: Theme.of(context).colorScheme.onPrimary, strokeWidth: 2.5, ), ) @@ -512,20 +537,26 @@ class _LoginPageState extends State ), const SizedBox(height: 24), - const Row( + Row( children: [ - Expanded(child: Divider(color: Color(0xFFDFE1E6))), + Expanded(child: Divider( + color: Theme.of(context).colorScheme.outlineVariant, + )), Padding( - padding: EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( "or sign in with", style: TextStyle( - color: Color(0xFF5E6C84), + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontSize: 12, ), ), ), - Expanded(child: Divider(color: Color(0xFFDFE1E6))), + Expanded(child: Divider( + color: Theme.of(context).colorScheme.outlineVariant, + )), ], ), const SizedBox(height: 20), @@ -549,23 +580,25 @@ class _LoginPageState extends State vertical: 6, ), decoration: BoxDecoration( - color: const Color(0xFFE3FCEF), + color: Theme.of(context).colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFABF5D1)), + border: Border.all( + color: Theme.of(context).colorScheme.tertiary, + ), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check_circle, - color: Color(0xFF36B37E), + color: Theme.of(context).colorScheme.onTertiaryContainer, size: 14, ), - SizedBox(width: 6), + const SizedBox(width: 6), Text( "End-to-end encrypted session", style: TextStyle( - color: Color(0xFF006644), + color: Theme.of(context).colorScheme.onTertiaryContainer, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -579,15 +612,15 @@ class _LoginPageState extends State Center( child: TextButton.icon( onPressed: _openServerConfigDialog, - icon: const Icon( + icon: Icon( Icons.dns_outlined, size: 14, - color: Color(0xFF5E6C84), + color: Theme.of(context).colorScheme.primary, ), label: Text( "Connected to: ${Env.host}:${Env.port} ⚙️", - style: const TextStyle( - color: Color(0xFF5E6C84), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w600, ), diff --git a/mobile/lib/pages/new_chat_page.dart b/mobile/lib/pages/new_chat_page.dart index 36d311a..0a5a87b 100644 --- a/mobile/lib/pages/new_chat_page.dart +++ b/mobile/lib/pages/new_chat_page.dart @@ -37,11 +37,11 @@ class _NewChatPageState extends State { showDialog( context: context, builder: (context) => AlertDialog( - backgroundColor: Colors.white, - title: const Text( + backgroundColor: Theme.of(context).colorScheme.surface, + title: Text( "Find by Username", style: TextStyle( - color: Colors.black87, + color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), @@ -49,29 +49,37 @@ class _NewChatPageState extends State { content: TextField( controller: inputController, autofocus: true, - style: const TextStyle(color: Colors.black87), - decoration: const InputDecoration( + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + decoration: InputDecoration( hintText: "Enter minimum 3 characters...", - hintStyle: TextStyle(color: Colors.black38), + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.black26), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), ), focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF1890FF)), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text( + child: Text( "Cancel", - style: TextStyle(color: Colors.black54), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1890FF), + backgroundColor: Theme.of(context).colorScheme.primary, ), onPressed: () { final text = inputController.text.trim(); @@ -83,16 +91,24 @@ class _NewChatPageState extends State { Navigator.pop(context); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( "Search query must be at least 3 characters.", + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), ), - backgroundColor: Colors.redAccent, + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, ), ); } }, - child: const Text("Search", style: TextStyle(color: Colors.white)), + child: Text( + "Search", + style: TextStyle(color: Theme.of(context).colorScheme.onPrimary), + ), ), ], ), @@ -108,13 +124,17 @@ class _NewChatPageState extends State { contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), leading: CircleAvatar( radius: 20, - backgroundColor: const Color(0xFFF0F2F5), - child: Icon(icon, color: Colors.black54, size: 20), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + icon, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), ), title: Text( label, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 15, fontWeight: FontWeight.w500, ), @@ -131,16 +151,18 @@ class _NewChatPageState extends State { final bool hasValidQueryLength = searchInput.length >= 3; return Scaffold( - backgroundColor: Colors.white, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0, scrolledUnderElevation: 0, - iconTheme: const IconThemeData(color: Colors.black87), - title: const Text( + iconTheme: IconThemeData( + color: Theme.of(context).colorScheme.onSurface, + ), + title: Text( 'New message', style: TextStyle( - color: Colors.black87, + color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), @@ -171,14 +193,23 @@ class _NewChatPageState extends State { child: TextField( controller: _searchController, onChanged: (val) => _onSearchChanged(val, chatState), - style: const TextStyle(color: Colors.black87), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( hintText: "Name, username or number", - hintStyle: const TextStyle(color: Colors.black38, fontSize: 15), - prefixIcon: const Icon(Icons.search, color: Colors.black38), + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 15, + ), + prefixIcon: Icon( + Icons.search, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), suffixIcon: isSearching ? IconButton( - icon: const Icon(Icons.clear, color: Colors.black38), + icon: Icon( + Icons.clear, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), onPressed: () { _searchController.clear(); chatState.queryUsers(""); @@ -186,7 +217,9 @@ class _NewChatPageState extends State { ) : null, filled: true, - fillColor: const Color(0xFFF5F5F5), + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric(vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), @@ -198,26 +231,31 @@ class _NewChatPageState extends State { Expanded( child: isSearching ? (!hasValidQueryLength - ? const Center( + ? Center( child: Text( "Type at least 3 characters to search...", style: TextStyle( - color: Colors.black45, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontSize: 14, ), ), ) : chatState.isSearchLoading - ? const Center( + ? Center( child: CircularProgressIndicator( - color: Color(0xFF1890FF), + color: Theme.of(context).colorScheme.primary, ), ) : chatState.contactSearchResults.isEmpty - ? const Center( + ? Center( child: Text( "No users found", - style: TextStyle(color: Colors.grey, fontSize: 15), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontSize: 15), ), ) : ListView.builder( @@ -233,23 +271,31 @@ class _NewChatPageState extends State { horizontal: 16, vertical: 4, ), - leading: const CircleAvatar( - backgroundColor: Color(0xFFD6E4FF), + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, child: Icon( Icons.person, - color: Color(0xFF1890FF), + color: Theme.of(context).colorScheme.primary, ), ), title: Text( displayName, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), subtitle: Text( "@$username", - style: const TextStyle(color: Colors.black45), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), onTap: () { final String uid = user['id']; @@ -291,12 +337,14 @@ class _NewChatPageState extends State { label: "Find by phone number", onTap: () {}, ), - const Padding( - padding: EdgeInsets.only(left: 16, top: 20, bottom: 8), + Padding( + padding: const EdgeInsets.only(left: 16, top: 20, bottom: 8), child: Text( "Recent Contacts", style: TextStyle( - color: Colors.black38, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold, fontSize: 13, ), @@ -311,23 +359,33 @@ class _NewChatPageState extends State { horizontal: 16, vertical: 4, ), - leading: const CircleAvatar( - backgroundColor: Color(0xFFE8E8E8), + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: Icon( Icons.person, - color: Colors.black54, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), ), title: Text( thread.title, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), subtitle: Text( "@${thread.username}", - style: const TextStyle(color: Colors.black45), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), onTap: () { chatState.openChat(thread.id); diff --git a/mobile/lib/pages/select_contact_page.dart b/mobile/lib/pages/select_contact_page.dart index e0d52b3..e1c66c9 100644 --- a/mobile/lib/pages/select_contact_page.dart +++ b/mobile/lib/pages/select_contact_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:mobile/controllers/chat.dart'; import 'package:mobile/pages/chat_page.dart'; @@ -21,11 +22,13 @@ class _SelectContactPageState extends State { final _searchController = TextEditingController(); final _groupNameController = TextEditingController(); - void _onSearchChanged(String value, ChatController chatState) { + void _onSearchChanged(String value) { if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 300), () { - chatState.queryUsers(value); + if (mounted) { + context.read().queryUsers(value); + } }); } @@ -59,26 +62,31 @@ class _SelectContactPageState extends State { return ListTile( selected: isSelected, - selectedTileColor: Colors.blue.withValues(alpha: 0.1), + selectedTileColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), leading: Stack( children: [ - const CircleAvatar( - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF)), + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.person, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), ), if (isSelected) Positioned( right: 0, bottom: 0, child: Container( - decoration: const BoxDecoration( - color: Colors.white, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.check_circle, - color: Colors.blue, + color: Theme.of(context).colorScheme.primary, size: 18, ), ), @@ -87,14 +95,14 @@ class _SelectContactPageState extends State { ), title: Text( displayName, - style: const TextStyle( - color: Colors.black87, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), subtitle: Text( - "@$username", - style: const TextStyle(color: Colors.black45), + username.startsWith('@') ? username : "@$username", + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), onTap: () => _toggleSelection(id, displayName, username), ); @@ -106,9 +114,9 @@ class _SelectContactPageState extends State { final bool isSearching = _searchController.text.trim().isNotEmpty; return Scaffold( - backgroundColor: Colors.white, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, scrolledUnderElevation: 0, title: AnimatedSwitcher( switchInCurve: Curves.decelerate, @@ -119,37 +127,43 @@ class _SelectContactPageState extends State { begin: const Offset(0.5, 0.0), end: Offset.zero, ); - final offsetAnimation = tween.animate(animation); - return SlideTransition(position: offsetAnimation, child: child); + return SlideTransition( + position: tween.animate(animation), + child: child, + ); }, child: _isSearchOpen ? SizedBox( height: 40, child: TextField( controller: _searchController, - onChanged: (val) => _onSearchChanged(val, chatState), - style: const TextStyle(color: Colors.black87), + onChanged: _onSearchChanged, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), autofocus: true, decoration: InputDecoration( hintText: "Name, username or number", - hintStyle: const TextStyle( - color: Colors.black38, + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 15, ), suffixIcon: isSearching ? IconButton( - icon: const Icon( + icon: Icon( Icons.clear, - color: Colors.black38, + color: Theme.of(context).colorScheme.onSurface, ), onPressed: () { _searchController.clear(); - chatState.queryUsers(""); + context.read().queryUsers(""); }, ) : null, filled: true, - fillColor: const Color(0xFFF5F5F5), + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric( vertical: 10, horizontal: 16, @@ -161,15 +175,15 @@ class _SelectContactPageState extends State { ), ), ) - : const Text( + : Text( 'Select Contacts', style: TextStyle( letterSpacing: 0.5, fontSize: 20, fontWeight: FontWeight.bold, - color: Colors.black87, + color: Theme.of(context).colorScheme.onSurface, ), - key: ValueKey('title'), + key: const ValueKey('title'), ), ), actions: [ @@ -179,13 +193,13 @@ class _SelectContactPageState extends State { _isSearchOpen = !_isSearchOpen; if (!_isSearchOpen) { _searchController.clear(); - chatState.queryUsers(""); + context.read().queryUsers(""); } }); }, icon: Icon( _isSearchOpen ? Icons.close : Icons.search, - color: Colors.black87, + color: Theme.of(context).colorScheme.onSurface, ), ), ], @@ -198,15 +212,17 @@ class _SelectContactPageState extends State { child: _selectedContacts.isNotEmpty ? Container( width: double.infinity, - color: Colors.blue.withValues(alpha: 0.1), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: Text( '${_selectedContacts.length} selected', - style: const TextStyle( - color: Colors.blue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, fontSize: 14, ), @@ -217,16 +233,22 @@ class _SelectContactPageState extends State { Expanded( child: isSearching ? (chatState.isSearchLoading - ? const Center( + ? Center( child: CircularProgressIndicator( - color: Color(0xFF1890FF), + color: Theme.of(context).colorScheme.primary, ), ) : chatState.contactSearchResults.isEmpty - ? const Center( + ? Center( child: Text( - "No users found", - style: TextStyle(color: Colors.grey, fontSize: 15), + "No users found (try entering at least 3 characters to search)", + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 15, + ), ), ) : ListView.builder( @@ -252,8 +274,8 @@ class _SelectContactPageState extends State { : ListView( children: [ if (_selectedContacts.isNotEmpty) ...[ - const Padding( - padding: EdgeInsets.only( + Padding( + padding: const EdgeInsets.only( left: 16, top: 20, bottom: 8, @@ -261,7 +283,9 @@ class _SelectContactPageState extends State { child: Text( "Selected Contacts", style: TextStyle( - color: Colors.black38, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold, fontSize: 13, ), @@ -275,12 +299,18 @@ class _SelectContactPageState extends State { ), ), ], - const Padding( - padding: EdgeInsets.only(left: 16, top: 20, bottom: 8), + Padding( + padding: const EdgeInsets.only( + left: 16, + top: 20, + bottom: 8, + ), child: Text( "Recent Contacts", style: TextStyle( - color: Colors.black38, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold, fontSize: 13, ), @@ -297,7 +327,9 @@ class _SelectContactPageState extends State { (thread) => _buildContactTile( id: thread.id, displayName: thread.title, - username: thread.title, + username: thread.title + .replaceAll(' ', '') + .toLowerCase(), ), ), ], @@ -307,13 +339,18 @@ class _SelectContactPageState extends State { ), floatingActionButton: _selectedContacts.isNotEmpty ? FloatingActionButton( - backgroundColor: Colors.blue, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, elevation: 4, - child: const Icon(Icons.arrow_forward, color: Colors.white), + child: Icon( + Icons.arrow_forward, + color: Theme.of(context).colorScheme.onPrimary, + ), onPressed: () { - context.read().addContacts(_selectedContacts); + context.read().setContacts(_selectedContacts); showModalBottomSheet( + backgroundColor: Colors.transparent, context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -323,113 +360,195 @@ class _SelectContactPageState extends State { ), builder: (BuildContext bottomSheetContext) { return Consumer( - builder: (context, groupState, child) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of( - bottomSheetContext, - ).viewInsets.bottom, - left: 16, - right: 16, - top: 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'New Group', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + builder: (modalContext, groupState, child) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.85), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), ), - ), - const SizedBox(height: 16), - TextField( - controller: _groupNameController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Enter group name', - border: OutlineInputBorder(), + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.surface + .withValues(alpha: 0.5), + width: 1, + ), ), ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - onPressed: groupState.isLoading - ? null - : () async { - final groupName = - _groupNameController.text - .trim(); - if (groupName.isEmpty) return; + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of( + bottomSheetContext, + ).viewInsets.bottom, + left: 16, + right: 16, + top: 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'New Group', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + TextField( + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + controller: _groupNameController, + autofocus: true, + decoration: InputDecoration( + hintText: 'Enter group name', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: groupState.isLoading + ? null + : () async { + final groupName = + _groupNameController.text + .trim(); + if (groupName.isEmpty) return; - final memberIds = _selectedContacts - .keys - .toList(); + final memberIds = + _selectedContacts.keys + .toList(); - final newGroup = await context - .read() - .createGroup( - groupName: groupName, - memberIds: memberIds, - ); + final rootNavigator = + Navigator.of(context); - if (newGroup != null) { - if (!context.mounted) return; + final newGroup = + await modalContext + .read< + GroupController + >() + .createGroup( + groupName: + groupName, + memberIds: + memberIds, + ); - context - .read() - .loadInbox(); + if (newGroup != null) { + if (!context.mounted) + return; - Navigator.of( - bottomSheetContext, - ).pop(); - Navigator.of( - context, - ).pushReplacement( - MaterialPageRoute( - builder: (context) => - ChatPage( - chatUserId: newGroup.id, - displayName: - newGroup.name, - ), - ), - ); + context + .read() + .loadInbox(); + + Navigator.of( + bottomSheetContext, + ).pop(); + + _groupNameController + .clear(); - context - .read() - .openChat(newGroup.id); - } else { - if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar( - const SnackBar( - content: Text( - 'Failed to create group', + rootNavigator + .pushReplacement( + MaterialPageRoute( + builder: + ( + context, + ) => ChatPage( + isNew: true, + chatUserId: + newGroup + .id, + displayName: + newGroup + .name, + isGroup: true, + ), + ), + ); + } else { + if (!context.mounted) + return; + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'Failed to create group', + style: TextStyle( + color: + Theme.of( + context, + ) + .colorScheme + .onSurface, + ), + ), + ), + ); + } + }, + child: groupState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: + CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + 'Create', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface, ), ), - ); - } - }, - child: groupState.isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : const Text('Create'), - ), - ], + ), + ], + ), + const SizedBox(height: 16), + ], + ), ), - const SizedBox(height: 16), - ], + ), ), ); }, diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index b85a4db..82e7ef8 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth.dart'; -import 'package:mobile/providers/basic_providers.dart'; +import 'package:mobile/controllers/chat.dart'; import 'package:mobile/main.dart'; +import 'package:mobile/providers/group_controller_provider.dart'; import 'package:provider/provider.dart'; class AccountsSettings extends StatelessWidget { @@ -11,28 +12,41 @@ class AccountsSettings extends StatelessWidget { @override Widget build(BuildContext context) { final user = context.watch().currentUser; + final theme = Theme.of(context); return Scaffold( - backgroundColor: Colors.white, + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Accounts'), - backgroundColor: Colors.white, + title: Text( + 'Accounts', + style: TextStyle(color: theme.colorScheme.onSurface), + ), + iconTheme: IconThemeData(color: theme.colorScheme.onSurface), + backgroundColor: theme.scaffoldBackgroundColor, elevation: 0, scrolledUnderElevation: 0, ), body: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - const SizedBox( + SizedBox( width: double.infinity, height: 150, child: Padding( // Moved padding OUTSIDE the Hero - padding: EdgeInsets.only(left: 16.0, top: 10.0, bottom: 10.0), + padding: const EdgeInsets.only( + left: 16.0, + top: 10.0, + bottom: 10.0, + ), child: Hero( tag: 'User Profile', child: CircleAvatar( - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF), size: 40), + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + Icons.person, + color: theme.colorScheme.onPrimaryContainer, + size: 40, + ), ), ), ), @@ -48,9 +62,10 @@ class AccountsSettings extends StatelessWidget { children: [ Text( user != null ? user.displayName : 'Profile N/A', - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, ), ), GestureDetector( @@ -71,7 +86,10 @@ class AccountsSettings extends StatelessWidget { user != null ? '@${user.username}' : 'Could not load profile', - style: const TextStyle(fontSize: 12), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), ), ), ], @@ -80,12 +98,12 @@ class AccountsSettings extends StatelessWidget { ), ), const SizedBox(height: 32), - const Padding( - padding: EdgeInsets.only(left: 16, bottom: 8), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), child: Text( "Details", style: TextStyle( - color: Colors.black38, + color: theme.colorScheme.primary, fontWeight: FontWeight.bold, fontSize: 13, ), @@ -94,15 +112,22 @@ class AccountsSettings extends StatelessWidget { // Email Tile ListTile( - leading: const Icon(Icons.email_outlined, color: Colors.black54), + leading: Icon( + Icons.email_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), title: const Text("Email"), subtitle: Text( user?.email != null && user!.email!.isNotEmpty ? user.email! : "Not provided", - style: const TextStyle(color: Colors.black87), + style: TextStyle(color: theme.colorScheme.onSurface), + ), + trailing: Icon( + Icons.edit, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), onTap: () { // TODO: Navigate to edit email page }, @@ -110,44 +135,62 @@ class AccountsSettings extends StatelessWidget { // Phone Number Tile ListTile( - leading: const Icon(Icons.phone_outlined, color: Colors.black54), + leading: Icon( + Icons.phone_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), title: const Text("Phone Number"), - subtitle: const Text( + subtitle: Text( "Not provided", - style: TextStyle(color: Colors.black87), + style: TextStyle(color: theme.colorScheme.onSurface), + ), + trailing: Icon( + Icons.edit, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), onTap: () {}, ), // Bio Tile ListTile( - leading: const Icon(Icons.info_outline, color: Colors.black54), + leading: Icon( + Icons.info_outline, + color: theme.colorScheme.onSurfaceVariant, + ), title: const Text("About"), - subtitle: const Text( + subtitle: Text( "Hey there! I am using Elephant.", - style: TextStyle(color: Colors.black87), + style: TextStyle(color: theme.colorScheme.onSurface), + ), + trailing: Icon( + Icons.edit, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), onTap: () {}, ), const SizedBox(height: 30), FilledButton( - style: const ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.redAccent), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(theme.colorScheme.error), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), ), onPressed: () async { await context.read().logout(); - await context.read().logOutSave(); if (context.mounted) { + context.read().clearSessionData(); + context.read().clearGroupData(); Navigator.pushAndRemoveUntil( context, MaterialPageRoute( builder: (context) => const SessionGateway(), ), - (route) => false, + (Route route) => false, ); } }, diff --git a/mobile/lib/pages/settings pages/appearance.dart b/mobile/lib/pages/settings pages/appearance.dart index b887b2b..709d0ee 100644 --- a/mobile/lib/pages/settings pages/appearance.dart +++ b/mobile/lib/pages/settings pages/appearance.dart @@ -1,10 +1,173 @@ import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/themes/theme_provider.dart'; +import 'package:provider/provider.dart'; class AppearanceSettings extends StatelessWidget { const AppearanceSettings({super.key}); @override Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text('Appearance'))); + final themeProvider = context.watch(); + + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + title: const Text('Appearance'), + backgroundColor: Theme.of( + context, + ).colorScheme.surface, // Glass effect color + elevation: 0, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Select a Theme', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + Expanded( + child: GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.65, // Makes the cards taller like a phone + children: [ + _ThemeCard( + title: 'Frost', + type: ThemeType.frost, + themeData: AppThemes.frost, + isSelected: themeProvider.currentTheme == ThemeType.frost, + ), + _ThemeCard( + title: 'Aura', + type: ThemeType.aura, + themeData: AppThemes.aura, + isSelected: themeProvider.currentTheme == ThemeType.aura, + ), + _ThemeCard( + title: 'Onyx', + type: ThemeType.onyx, + themeData: AppThemes.onyx, + isSelected: themeProvider.currentTheme == ThemeType.onyx, + ), + _ThemeCard( + title: 'Nebula', + type: ThemeType.nebula, + themeData: AppThemes.nebula, + isSelected: + themeProvider.currentTheme == ThemeType.nebula, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ThemeCard extends StatelessWidget { + final String title; + final ThemeType type; + final ThemeData themeData; + final bool isSelected; + + const _ThemeCard({ + required this.title, + required this.type, + required this.themeData, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + context.read().setTheme(type); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: themeData.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isSelected + ? themeData.colorScheme.primary + : Colors.transparent, + width: 3, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: themeData.colorScheme.primary.withValues(alpha: 0.4), + blurRadius: 12, + ), + ] + : [const BoxShadow(color: Colors.black12, blurRadius: 8)], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(21), + child: Stack( + children: [ + // Mock Chat bubble + Positioned( + right: 16, + top: 60, + child: Container( + width: 60, + height: 30, + decoration: BoxDecoration( + color: themeData.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + // Mock Glass AppBar + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 40, + color: themeData.colorScheme.surface, + ), + ), + // Mock Glass NavBar + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 40, + color: themeData.colorScheme.surface, + ), + ), + // Theme Title + Positioned( + bottom: 50, + left: 0, + right: 0, + child: Center( + child: Text( + title, + style: TextStyle( + color: themeData.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ); } } diff --git a/mobile/lib/pages/settings_page.dart b/mobile/lib/pages/settings_page.dart index 9b342c7..2ad31c0 100644 --- a/mobile/lib/pages/settings_page.dart +++ b/mobile/lib/pages/settings_page.dart @@ -19,58 +19,67 @@ void showUserCard(BuildContext context) { final user = context.read().currentUser; final String qrData = user?.username ?? 'unknown'; - return Dialog( - backgroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Share Your QR!', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Dialog( + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: Theme.of(context).colorScheme.surface, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Share Your QR!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - QrImageView( - data: qrData, - version: QrVersions.auto, - size: 200.0, - backgroundColor: Colors.white, - eyeStyle: const QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Colors.black87, + QrImageView( + data: qrData, + version: QrVersions.auto, + size: 200.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).colorScheme.onSurface, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).colorScheme.onSurface, + ), ), - dataModuleStyle: const QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Colors.black87, - ), - ), - const SizedBox(height: 16), - Text( - '@$qrData', - style: const TextStyle( - fontSize: 16, - color: Colors.black54, - fontWeight: FontWeight.w500, + const SizedBox(height: 16), + Text( + '@$qrData', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), ), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: () => Navigator.pop(context), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF1890FF), - minimumSize: const Size(double.infinity, 45), + const SizedBox(height: 24), + FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + minimumSize: const Size(double.infinity, 45), + ), + child: const Text('Close'), ), - child: const Text('Close'), - ), - ], + ], + ), ), ), ); @@ -78,7 +87,6 @@ void showUserCard(BuildContext context) { ); } - class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -87,6 +95,7 @@ class SettingsPage extends StatelessWidget { final user = context.watch().currentUser; return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: CustomScrollView( slivers: [ SliverAppBar( @@ -102,16 +111,18 @@ class SettingsPage extends StatelessWidget { child: BackdropFilter( enabled: true, filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - color: const Color.fromARGB(78, 255, 255, 255), - ), + child: Container(color: Theme.of(context).colorScheme.surface), ), ), actionsPadding: const EdgeInsets.symmetric(horizontal: 15), - title: const Text( + title: Text( 'Settings', - style: TextStyle(letterSpacing: 1, fontWeight: FontWeight.w400), - key: ValueKey('title'), + style: TextStyle( + letterSpacing: 1, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface, + ), + key: const ValueKey('title'), ), actions: [ @@ -125,59 +136,77 @@ class SettingsPage extends StatelessWidget { ), SliverList( delegate: SliverChildListDelegate([ - Container( - margin: EdgeInsets.all(20), - padding: EdgeInsets.all(20), - width: double.infinity, - height: 150, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(width: 2, color: Colors.black26), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Hero( - tag: 'User Profile', - child: CircleAvatar( - radius: 40, - backgroundImage: user?.avatarUrl != null - ? NetworkImage(user!.avatarUrl!) - : null, - child: user?.avatarUrl == null - ? const Icon(Icons.person, size: 50) - : null, - ), + GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => AccountsSettings()), + ); + }, + child: Container( + margin: EdgeInsets.all(20), + padding: EdgeInsets.all(20), + width: double.infinity, + height: 150, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + width: 1, + color: Theme.of(context).colorScheme.outlineVariant, ), - SizedBox(width: 20), - Hero( - tag: 'User Data', - child: Material( - type: MaterialType.transparency, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Spacer(), - Text( - user != null ? user.displayName : 'Profile N/A', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Hero( + tag: 'User Profile', + child: CircleAvatar( + radius: 40, + backgroundImage: user?.avatarUrl != null + ? NetworkImage(user!.avatarUrl!) + : null, + child: user?.avatarUrl == null + ? const Icon(Icons.person, size: 50) + : null, + ), + ), + SizedBox(width: 20), + Hero( + tag: 'User Data', + child: Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Spacer(), + Text( + user != null ? user.displayName : 'Profile N/A', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + Text( + user != null + ? '@${user.username}' + : 'Could not load profile', + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), - ), - Text( - user != null - ? '@${user.username}' - : 'Could not load profile', - style: TextStyle(fontSize: 12), - ), - Spacer(), - ], + Spacer(), + ], + ), ), ), - ), - ], + ], + ), ), ), @@ -186,9 +215,12 @@ class SettingsPage extends StatelessWidget { margin: EdgeInsets.all(20), width: double.infinity, decoration: BoxDecoration( - color: Colors.white, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), - border: Border.all(width: 2, color: Colors.black26), + border: Border.all( + width: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), ), child: Column( children: [ @@ -259,7 +291,7 @@ Widget _settingsOption({ required BuildContext context, required IconData icon, required String settingName, - Widget trailing = const Icon(Icons.chevron_right), + Widget trailing = const SizedBox.shrink(), Widget? whereTo, }) { return InkWell( @@ -276,11 +308,32 @@ Widget _settingsOption({ height: 60, child: Row( children: [ - CircleAvatar(child: Icon(icon)), - SizedBox(width: 20), - Text(settingName), - Spacer(), - trailing, + CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: Icon( + icon, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + ), + const SizedBox(width: 20), + Text( + settingName, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontSize: 15, + ), + ), + const Spacer(), + trailing == const SizedBox.shrink() + ? Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : trailing, ], ), ), diff --git a/mobile/lib/pages/phone_number_page.dart b/mobile/lib/pages/temp/phone_number_page.dart similarity index 85% rename from mobile/lib/pages/phone_number_page.dart rename to mobile/lib/pages/temp/phone_number_page.dart index a448a01..3eecdc5 100644 --- a/mobile/lib/pages/phone_number_page.dart +++ b/mobile/lib/pages/temp/phone_number_page.dart @@ -1,5 +1,4 @@ -import 'package:mobile/pages/profile_setup_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; +import 'package:mobile/pages/temp/profile_setup_page.dart'; import 'package:mobile/providers/timer_provider.dart'; import 'package:flutter/material.dart'; import 'package:intl_phone_field/intl_phone_field.dart'; @@ -32,18 +31,8 @@ class PhoneNumberPage extends StatelessWidget { initialCountryCode: 'IN', controller: numberController, decoration: InputDecoration(border: OutlineInputBorder()), - onChanged: (value) { - context.read().updateConCode(value.countryCode); - context.read().updateNumber(value.number); - context.read().setNumberValid( - value.isValidNumber(), - ); - }, - onCountryChanged: (value) { - context.read().updateConCode( - '+${value.dialCode}', - ); - }, + onChanged: (value) {}, + onCountryChanged: (value) {}, ), Spacer(), Container( @@ -54,11 +43,8 @@ class PhoneNumberPage extends StatelessWidget { Size(double.infinity, 40), ), ), - onPressed: context.watch().isNumberValid - ? () { - context.read().updateNumber( - numberController.text, - ); + onPressed: + () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => ChangeNotifierProvider( @@ -67,8 +53,7 @@ class PhoneNumberPage extends StatelessWidget { ), ), ); - } - : null, + }, child: Text('Next'), ), ), @@ -99,7 +84,7 @@ class NumberVerificationPage extends StatelessWidget { ), const SizedBox(height: 10), Text( - "We've sent a 6-digit code to\n${context.read().getCompleteNumber}", + "We've sent a 6-digit code to\n", textAlign: .center, ), const SizedBox(height: 25), diff --git a/mobile/lib/pages/profile_setup_page.dart b/mobile/lib/pages/temp/profile_setup_page.dart similarity index 96% rename from mobile/lib/pages/profile_setup_page.dart rename to mobile/lib/pages/temp/profile_setup_page.dart index 57161f5..11d5fed 100644 --- a/mobile/lib/pages/profile_setup_page.dart +++ b/mobile/lib/pages/temp/profile_setup_page.dart @@ -1,5 +1,4 @@ import 'package:mobile/pages/home_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/providers/image_picker_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -78,7 +77,7 @@ class ProfileSetupPage extends StatelessWidget { context, MaterialPageRoute(builder: (context) => const HomePage()), ); - context.read().logInSave(); + // context.read().logInSave(); }, child: Row( mainAxisAlignment: .center, diff --git a/mobile/lib/pages/welcome_page.dart b/mobile/lib/pages/temp/welcome_page.dart similarity index 96% rename from mobile/lib/pages/welcome_page.dart rename to mobile/lib/pages/temp/welcome_page.dart index 6009ee7..0064340 100644 --- a/mobile/lib/pages/welcome_page.dart +++ b/mobile/lib/pages/temp/welcome_page.dart @@ -1,4 +1,4 @@ -import 'package:mobile/pages/phone_number_page.dart'; +import 'package:mobile/pages/temp/phone_number_page.dart'; import 'package:mobile/players/intro_animation.dart'; import 'package:flutter/material.dart'; diff --git a/mobile/lib/providers/basic_providers.dart b/mobile/lib/providers/basic_providers.dart index e4d752c..5f5729a 100644 --- a/mobile/lib/providers/basic_providers.dart +++ b/mobile/lib/providers/basic_providers.dart @@ -2,12 +2,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class BasicProviders extends ChangeNotifier { - String _phoneNumber = ''; - String _conCode = ''; - bool _isNumberValid = false; - bool _hasLoggedIn = false; - bool _isInitialized = false; - bool _notificationsSwitch = false; bool get notificationsSwitch => _notificationsSwitch; @@ -26,52 +20,4 @@ class BasicProviders extends ChangeNotifier { pref.setBool('notificationToggle', _notificationsSwitch); } - bool get isInitialized => _isInitialized; - - bool get hasLoggedIn => _hasLoggedIn; - bool get isNumberValid => _isNumberValid; - String get getCompleteNumber => '$_conCode $_phoneNumber'; - - BasicProviders() { - _loadLoginState(); - } - - Future _loadLoginState() async { - final pref = await SharedPreferences.getInstance(); - _hasLoggedIn = pref.getBool('hasLoggedIn') ?? false; - _isInitialized = true; - - notifyListeners(); - } - - Future logInSave() async { - _hasLoggedIn = true; - notifyListeners(); - - final pref = await SharedPreferences.getInstance(); - await pref.setBool('hasLoggedIn', true); - } - - Future logOutSave() async { - _hasLoggedIn = false; - notifyListeners(); - - final pref = await SharedPreferences.getInstance(); - await pref.setBool('hasLoggedIn', false); - } - - void setNumberValid(bool val) { - _isNumberValid = val; - notifyListeners(); - } - - void updateNumber(String num) { - _phoneNumber = num; - notifyListeners(); - } - - void updateConCode(String val) { - _conCode = val; - notifyListeners(); - } } diff --git a/mobile/lib/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index 4811571..18229bb 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -5,16 +5,24 @@ import 'package:mobile/services/api.dart'; class GroupController extends ChangeNotifier { final Map> _selectedContacts = {}; - Map> get selectedContacts => _selectedContacts; - void addContacts(Map> contacts) { + void setContacts(Map> contacts) { _selectedContacts.clear(); _selectedContacts.addAll(contacts); notifyListeners(); } +//hhhh.1585 + void toggleContact(String id, Map contactData) { + if (_selectedContacts.containsKey(id)) { + _selectedContacts.remove(id); + } else { + _selectedContacts[id] = contactData; + } + notifyListeners(); + } - // Group Creation + // --- Group Creation --- final ApiService _api = ApiService(); @@ -47,10 +55,18 @@ class GroupController extends ChangeNotifier { if (memberIds.isNotEmpty) { final memberRequests = memberIds.map((userId) { - return _api.post( - '/groups/${newGroup.id}/members', - data: {"user_id": userId}, - ); + return _api + .post( + '/groups/${newGroup.id}/members', + data: {"user_id": userId}, + ) + .catchError((error) { + debugPrint("Failed to add user $userId to group: $error"); + return Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ); + }); }); await Future.wait(memberRequests); @@ -62,8 +78,6 @@ class GroupController extends ChangeNotifier { return null; } on DioException catch (e) { debugPrint("Dio Error: ${e.message}"); - debugPrint("URL: ${e.requestOptions.uri}"); - if (e.response != null) { debugPrint("Payload: ${e.response?.data}"); } @@ -76,4 +90,53 @@ class GroupController extends ChangeNotifier { notifyListeners(); } } + + Future addMemberToGroup(String groupId, String userId) async { + try { + _isLoading = true; + notifyListeners(); + + final response = await _api.post( + '/groups/$groupId/members', + data: {"user_id": userId}, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return true; + } + return false; + } on DioException catch (e) { + debugPrint("Add Member Error: ${e.response?.data}"); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future removeMemberFromGroup(String groupId, String userId) async { + try { + _isLoading = true; + notifyListeners(); + + final response = await _api.delete('/groups/$groupId/members/$userId'); + + if (response.statusCode == 200) { + return true; + } + return false; + } on DioException catch (e) { + debugPrint("Remove Member Error: ${e.response?.data}"); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void clearGroupData() { + _selectedContacts.clear(); + _isLoading = false; + notifyListeners(); + } } diff --git a/mobile/lib/services/api.dart b/mobile/lib/services/api.dart index 5f1b62b..00be614 100644 --- a/mobile/lib/services/api.dart +++ b/mobile/lib/services/api.dart @@ -7,6 +7,8 @@ class ApiService { final Dio _dio = Dio(); final AuthService _auth = AuthService(); + Future? _refreshFuture; + ApiService() { _dio.interceptors.add( InterceptorsWrapper( @@ -23,38 +25,21 @@ class ApiService { }, onError: (DioException e, handler) async { if (e.response?.statusCode == 401) { - final refreshToken = await _auth.getRefreshToken(); - - if (refreshToken != null) { - try { - final refreshResponse = await _auth.refreshAccessToken( - refreshToken, - ); - final resData = refreshResponse.data; - - if (resData['success'] == true) { - final newAccessToken = resData['data']['access_token']; - final newRefreshToken = resData['data']['refresh_token']; - - await _auth.saveTokens(newAccessToken, newRefreshToken); - - final cloneOptions = e.requestOptions; - cloneOptions.headers["Authorization"] = - "Bearer $newAccessToken"; - - final retryResponse = await _dio.fetch(cloneOptions); - return handler.resolve(retryResponse); - } - } catch (refreshError) { - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); - } - return handler.next(e); - } - } - - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); + try { + _refreshFuture ??= _refreshToken(); + await _refreshFuture; + + final clone = e.requestOptions; + clone.headers["Authorization"] = + "Bearer ${await _auth.getToken()}"; + + final retry = await _dio.fetch(clone); + return handler.resolve(retry); + } catch (refreshError) { + AuthState.onGlobalUnauthorized?.call(); + rethrow; + } finally { + _refreshFuture = null; } } return handler.next(e); @@ -63,19 +48,55 @@ class ApiService { ); } + Future _refreshToken() async { + final refreshToken = await _auth.getRefreshToken(); + + if (refreshToken == null) { + throw Exception("No refresh token"); + } + + final response = await _auth.refreshAccessToken(refreshToken); + final data = response.data; + + if (data["success"] != true) { + throw Exception("Refresh failed"); + } + + await _auth.saveTokens( + data["data"]["access_token"], + data["data"]["refresh_token"], + ); + } + Future getConversations() async { return await _dio.get("/messages/conversations"); } - Future getGroups() async { - return await _dio.get("/groups"); - } + Future getChatHistory( + String targetId, { + String? before, + bool isGroup = false, + }) async { + final Map params = {"limit": 40}; + if (before != null) params["before"] = before; + if (isGroup) { + return await _dio.get( + "/groups/$targetId/messages", + queryParameters: params, + ); + } else { + params["with"] = targetId; + return await _dio.get("/messages", queryParameters: params); + } + } - Future getChatHistory(String partnerId, {String? before}) async { - final Map params = {"with": partnerId, "limit": 40}; - if (before != null) params["before"] = before; - return await _dio.get("/messages", queryParameters: params); + Future getGroupMembers(String groupId) async { + try { + return await _dio.get("/groups/$groupId/members"); + } catch (e) { + rethrow; + } } Future searchUsers(String query) async { @@ -92,21 +113,6 @@ class ApiService { ); } - Future sendMessage( - String receiverId, - String content, { - String? replyToMessageId, - }) async { - return await _dio.post( - '/messages', - data: { - "receiver_id": receiverId, - "content": content, - if (replyToMessageId != null) "reply_to_message_id": replyToMessageId, - }, - ); - } - Future post( String path, { dynamic data, @@ -115,4 +121,22 @@ class ApiService { return await _dio.post(path, data: data, queryParameters: queryParameters); } + Future get( + String path, { + Map? queryParameters, + }) async { + return await _dio.get(path, queryParameters: queryParameters); + } + + Future delete( + String path, { + dynamic data, + Map? queryParameters, + }) async { + return await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + ); + } } diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index 80bbf48..5e8cc1a 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -1,48 +1,39 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../core/constants.dart'; -import '../controllers/auth.dart'; class AuthService { final Dio _dio = Dio(); final FlutterSecureStorage _storage = const FlutterSecureStorage(); - AuthService() { - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - options.baseUrl = Env.httpBaseUrl; - return handler.next(options); - }, - onResponse: (response, handler) { - return handler.next(response); - }, - onError: (DioException e, handler) { - if (e.response?.statusCode == 401) { - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); - } - } - return handler.next(e); - }, - ), - ); - } + static final AuthService _instance = AuthService._internal(); + factory AuthService() => _instance; - Future getToken() async { - return await _storage.read(key: "access_token"); + AuthService._internal() { + _dio.options.baseUrl = Env.httpBaseUrl; } - Future getRefreshToken() async { - return await _storage.read(key: "refresh_token"); + String? _cachedAccessToken; + String? _cachedRefreshToken; + + Future initTokens() async { + _cachedAccessToken = await _storage.read(key: "access_token"); + _cachedRefreshToken = await _storage.read(key: "refresh_token"); } + Future getToken() async => _cachedAccessToken; + Future getRefreshToken() async => _cachedRefreshToken; + Future saveTokens(String accessToken, String refreshToken) async { + _cachedAccessToken = accessToken; + _cachedRefreshToken = refreshToken; await _storage.write(key: "access_token", value: accessToken); await _storage.write(key: "refresh_token", value: refreshToken); } Future logout() async { + _cachedAccessToken = null; + _cachedRefreshToken = null; await _storage.delete(key: "access_token"); await _storage.delete(key: "refresh_token"); } @@ -82,4 +73,5 @@ class AuthService { options: Options(headers: {'Authorization': 'Bearer $token'}), ); } + } diff --git a/mobile/lib/services/ws.dart b/mobile/lib/services/ws.dart index c03a62a..279fc54 100644 --- a/mobile/lib/services/ws.dart +++ b/mobile/lib/services/ws.dart @@ -15,42 +15,74 @@ class WebSocketService { try { final wsUrl = Uri.parse("${Env.wsBaseUrl}?token=$token"); _channel = WebSocketChannel.connect(wsUrl); + + await _channel!.ready; + _isConnected = true; debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); } catch (e) { _isConnected = false; - debugPrint("WebSocket connection failure: $e"); + debugPrint("WebSocket connection failure (Handshake rejected): $e"); } } void emit(Map payload) { if (!_isConnected) return; + debugPrint("Sending Payload to WS: ${jsonEncode(payload)}"); _channel?.sink.add(jsonEncode(payload)); } void sendChat({ required String messageId, - required String targetId, + required String receiverId, + required String content, + String? replyToMessageId, + }) { + emit({ + "type": "chat", + "message_id": messageId, + "receiver_id": receiverId, + "content": content, + "reply_to_message_id": replyToMessageId, + }); + } + + void sendGroupChat({ + required String messageId, + required String groupId, required String content, + String? replyToMessageId, + required String senderId, }) { emit({ "type": "chat", "message_id": messageId, - "receiver_id": targetId, + "group_id": groupId, "content": content, + "reply_to_message_id": replyToMessageId, + "sender_id": senderId, }); } - void sendTyping({required String targetId, required bool isTyping}) { + void sendTyping({ + String? receiverId, + String? groupId, + required bool isTyping, + }) { emit({ "type": "typing", - "receiver_id": targetId, + if (receiverId != null) "receiver_id": receiverId, + if (groupId != null) "group_id": groupId, "content": isTyping.toString(), }); } - void sendReadReceipt({required String targetId}) { - emit({"type": "read_receipt", "receiver_id": targetId}); + void sendReadReceipt({String? receiverId, String? groupId}) { + emit({ + "type": "read_receipt", + if (receiverId != null) "receiver_id": receiverId, + if (groupId != null) "group_id": groupId, + }); } void sendRequestStatus({required String targetId}) { @@ -63,5 +95,4 @@ class WebSocketService { _channel = null; debugPrint("WebSocket Pipeline Terminated Cleanly."); } - -} \ No newline at end of file +} diff --git a/mobile/lib/themes/app_themes.dart b/mobile/lib/themes/app_themes.dart new file mode 100644 index 0000000..95e4515 --- /dev/null +++ b/mobile/lib/themes/app_themes.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +enum ThemeType { frost, aura, onyx, nebula } + +class AppThemes { + static final ThemeData frost = ThemeData( + brightness: Brightness.light, + scaffoldBackgroundColor: const Color(0xFFF5F7FA), + colorScheme: const ColorScheme.light( + primary: Color(0xFF007AFF), + surface: Color(0x99FFFFFF), + onSurface: Colors.black87, + ), + useMaterial3: true, + ); + + static final ThemeData aura = ThemeData( + brightness: Brightness.light, + scaffoldBackgroundColor: const Color(0xFFFFFBF7), + colorScheme: const ColorScheme.light( + primary: Color(0xFFFF8A65), + surface: Color(0x99FFF0E5), + onSurface: Color(0xFF3E2723), + ), + useMaterial3: true, + ); + + static final ThemeData onyx = ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: const Color(0xFF121212), + colorScheme: const ColorScheme.dark( + primary: Color(0xFFE0E0E0), + surface: Color(0x991E1E1E), + onSurface: Colors.white, + ), + useMaterial3: true, + ); + + static final ThemeData nebula = ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: const Color(0xFF0B0914), + colorScheme: const ColorScheme.dark( + primary: Color(0xFF8A2BE2), + surface: Color(0x99191330), + onSurface: Color(0xFFE6E6FA), + ), + useMaterial3: true, + ); + + static ThemeData getTheme(ThemeType type) { + switch (type) { + case ThemeType.frost: + return frost; + case ThemeType.aura: + return aura; + case ThemeType.onyx: + return onyx; + case ThemeType.nebula: + return nebula; + } + } +} diff --git a/mobile/lib/themes/theme_provider.dart b/mobile/lib/themes/theme_provider.dart new file mode 100644 index 0000000..a98380d --- /dev/null +++ b/mobile/lib/themes/theme_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeProvider extends ChangeNotifier { + ThemeType _currentTheme = ThemeType.onyx; + bool _isInitialized = false; + + ThemeType get currentTheme => _currentTheme; + ThemeData get themeData => AppThemes.getTheme(_currentTheme); + bool get isInitialized => _isInitialized; + + ThemeProvider() { + _loadTheme(); + } + + Future _loadTheme() async { + final prefs = await SharedPreferences.getInstance(); + final savedThemeIndex = prefs.getInt('app_theme'); + + if (savedThemeIndex != null && + savedThemeIndex >= 0 && + savedThemeIndex < ThemeType.values.length) { + _currentTheme = ThemeType.values[savedThemeIndex]; + } else { + _currentTheme = ThemeType.onyx; + } + + _isInitialized = true; + notifyListeners(); + } + + Future setTheme(ThemeType themeType) async { + if (_currentTheme != themeType) { + _currentTheme = themeType; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('app_theme', themeType.index); + } + } +} diff --git a/mobile/lib/widgets/chat_screen_modular_widgets.dart b/mobile/lib/widgets/chat_screen_modular_widgets.dart index 0099d23..5f4fba1 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -5,20 +5,22 @@ import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:intl/intl.dart'; import 'package:mobile/models/message.dart'; -import 'package:provider/provider.dart'; -import 'package:mobile/controllers/chat.dart'; enum UserStatus { offline, online } class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { final String name; - final UserStatus status; + final UserStatus? status; + final bool isGroup; + final GestureTapCallback? onTitleTap; - const GlassAppBar({super.key, required this.name, required this.status}); - - String _getStatusText() { - return status == UserStatus.online ? "Online" : "Offline"; - } + const GlassAppBar({ + super.key, + required this.name, + required this.status, + required this.isGroup, + this.onTitleTap, + }); @override Widget build(BuildContext context) { @@ -28,42 +30,53 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: AppBar( - backgroundColor: theme.scaffoldBackgroundColor.withValues(alpha: 0.4), + backgroundColor: theme.colorScheme.surface, elevation: 0, scrolledUnderElevation: 0, leading: IconButton( icon: Icon(Icons.arrow_back, color: theme.iconTheme.color), onPressed: () => Navigator.pop(context), ), - title: Row( - children: [ - CircleAvatar( - backgroundColor: theme.colorScheme.primaryContainer, - child: Icon(Icons.person, color: theme.colorScheme.primary), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - color: theme.textTheme.titleLarge?.color, - fontSize: 16, - fontWeight: FontWeight.bold, + title: GestureDetector( + onTap: onTitleTap, + child: Row( + children: [ + Hero( + tag: 'profile', + child: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + Icons.person, + color: theme.colorScheme.onPrimary, ), ), - Text( - _getStatusText(), - style: TextStyle( - color: theme.textTheme.bodySmall?.color, - fontSize: 12, - fontWeight: FontWeight.normal, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + color: theme.textTheme.titleLarge?.color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - ], - ), - ], + status != null + ? Text( + status! == UserStatus.online ? "Online" : "Offline", + style: TextStyle( + color: theme.textTheme.bodySmall?.color, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ) + : SizedBox.shrink(), + ], + ), + ], + ), ), ), ), @@ -75,11 +88,14 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { } class ChatBubble extends StatelessWidget { + final VoidCallback? onQuoteTap; final String message; final bool isMe; final DateTime timestamp; final bool isRead; final QuotedMessage? quotedMessage; + final String? senderName; + final bool isGroup; const ChatBubble({ super.key, @@ -88,13 +104,30 @@ class ChatBubble extends StatelessWidget { required this.timestamp, required this.isRead, this.quotedMessage, + this.senderName, + this.isGroup = false, + this.onQuoteTap, }); + Color _getSenderColor(String name) { + final hash = name.hashCode; + final colors = [ + Colors.red, + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.teal, + ]; + return colors[hash % colors.length]; + } + MarkdownStyleSheet _getMarkdownStyle( bool isMe, Color primaryColor, Color onPrimary, Color onSurface, + BuildContext context, ) { return MarkdownStyleSheet( p: TextStyle(color: isMe ? onPrimary : onSurface, fontSize: 15), @@ -116,7 +149,7 @@ class ChatBubble extends StatelessWidget { backgroundColor: isMe ? primaryColor.withValues(alpha: 0.8) : Colors.grey.withValues(alpha: 0.3), - color: isMe ? onPrimary : Colors.red[800], + color: isMe ? onPrimary : Theme.of(context).colorScheme.error, fontFamily: 'monospace', ), a: TextStyle( @@ -155,56 +188,60 @@ class ChatBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (quotedMessage != null) - Container( - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only(bottom: 6), - decoration: BoxDecoration( - color: isMe - ? theme.colorScheme.onPrimary.withValues(alpha: 0.15) - : theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.15, - ), - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide( - color: isMe - ? theme.colorScheme.onPrimary - : theme.colorScheme.primary, - width: 3, - ), + if (isGroup && !isMe && senderName != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + senderName!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _getSenderColor(senderName!), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - quotedMessage!.senderDisplayName, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + ), + if (quotedMessage != null) + GestureDetector( + onTap: onQuoteTap, + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: isMe + ? theme.colorScheme.onPrimary.withValues(alpha: 0.15) + : theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.15, + ), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide( color: isMe ? theme.colorScheme.onPrimary : theme.colorScheme.primary, + width: 3, ), ), - const SizedBox(height: 2), - Text( - quotedMessage!.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: isMe - ? theme.colorScheme.onPrimary.withValues( - alpha: 0.8, - ) - : theme.colorScheme.onSurfaceVariant, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + quotedMessage!.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: isMe + ? theme.colorScheme.onPrimary.withValues( + alpha: 0.8, + ) + : theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ), MarkdownBody( @@ -215,6 +252,7 @@ class ChatBubble extends StatelessWidget { theme.colorScheme.primary, theme.colorScheme.onPrimary, theme.colorScheme.onSurface, + context, ), ), const SizedBox(height: 4), @@ -238,7 +276,9 @@ class ChatBubble extends StatelessWidget { isRead ? Icons.done_all : Icons.done, size: 14, color: isRead - ? Colors.lightBlueAccent + ? (isMe + ? Colors.lightBlueAccent + : theme.colorScheme.primary) : theme.colorScheme.onPrimary.withValues( alpha: 0.7, ), @@ -340,928 +380,121 @@ class _ChatInputAreaState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final bottomPadding = MediaQuery.of(context).padding.bottom; - return SafeArea( - top: false, - bottom: true, - child: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _controller, - focusNode: _focusNode, - onChanged: _handleOnChange, - keyboardType: TextInputType.multiline, - minLines: 1, - maxLines: 5, - maxLength: _charLimit, - maxLengthEnforcement: MaxLengthEnforcement.none, - style: TextStyle( - color: _isOverLimit - ? theme.colorScheme.error - : theme.colorScheme.onSurface, - fontSize: 15, - ), - buildCounter: - ( - context, { - required currentLength, - required isFocused, - required maxLength, - }) => _isOverLimit - ? Text('$currentLength/$maxLength') - : null, - decoration: InputDecoration( - hintText: "Write a message...", - hintStyle: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - ), - fillColor: _isOverLimit - ? theme.colorScheme.error.withValues(alpha: 0.1) - : theme.colorScheme.surfaceContainerHighest, - filled: true, - errorText: _isOverLimit - ? 'character limit exceeded' - : null, - counterStyle: TextStyle( - color: _isOverLimit - ? theme.colorScheme.error - : theme.colorScheme.onSurfaceVariant, - fontWeight: _isOverLimit - ? FontWeight.bold - : FontWeight.normal, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 1.5, - ) - : BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 2, - ) - : BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 1.5, - ) - : BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - ), - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: IconButton( - icon: _isOverLimit - ? const Icon(Icons.not_interested) - : Icon(Icons.send, color: theme.colorScheme.primary), - onPressed: _isOverLimit ? null : _submit, - ), - ), - ], + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.surface.withValues(alpha: 0.8), + width: 1, + ), ), ), - ), - ), - ); - } -} - -class ChatPage extends StatefulWidget { - final String chatUserId; - final String displayName; - - const ChatPage({ - super.key, - required this.chatUserId, - required this.displayName, - }); - - @override - State createState() => _ChatPageState(); -} - -class _ChatPageState extends State with WidgetsBindingObserver { - final ScrollController _scrollController = ScrollController(); - bool _showScrollToBottom = false; - bool _isNearBottom = true; - final Set _selectedIndices = {}; - dynamic _replyingToMessage; - - int _previousMessageCount = 0; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _scrollController.addListener(_scrollListener); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().openChat(widget.chatUserId); - } - }); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _scrollController.removeListener(_scrollListener); - _scrollController.dispose(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().closeChat(); - } - }); - super.dispose(); - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - if (_isNearBottom) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _scrollToBottom(animated: true); - } - }); - } - } - - void _scrollListener() { - if (!_scrollController.hasClients) return; - - final offset = _scrollController.offset; - _isNearBottom = offset <= 300; - final isScrolledUp = offset > 300; - - if (isScrolledUp != _showScrollToBottom) { - setState(() { - _showScrollToBottom = isScrolledUp; - }); - } - } - - void _scrollToBottom({bool animated = true}) { - if (!_scrollController.hasClients) return; - - if (animated) { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } else { - _scrollController.jumpTo(0.0); - } - } - - void _sendMessage(String text) { - if (text.trim().isEmpty) return; - - context.read().sendTextMessage( - text, - replyingTo: _replyingToMessage, - ); - - setState(() { - _replyingToMessage = null; - }); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); - }); - } - - void _toggleSelection(int index) { - setState(() { - if (_selectedIndices.contains(index)) { - _selectedIndices.remove(index); - } else { - _selectedIndices.add(index); - } - }); - } - - void _clearSelection() { - setState(() { - _selectedIndices.clear(); - }); - } - - void _copySelectedMessages(List activeChat) { - final sortedIndices = _selectedIndices.toList()..sort(); - - final selectedTexts = sortedIndices - .map((index) => activeChat[index].content.toString()) - .join('\n'); - - Clipboard.setData(ClipboardData(text: selectedTexts)); - - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); - - _clearSelection(); - } - - UserStatus _getPresenceStatusText(ChatController state) { - return state.isPeerOnline ? UserStatus.online : UserStatus.offline; - } - - bool _isSameDay(DateTime date1, DateTime date2) { - return date1.year == date2.year && - date1.month == date2.month && - date1.day == date2.day; - } - - String _formatDateSeparator(DateTime date) { - final now = DateTime.now(); - if (_isSameDay(date, now)) return 'Today'; - if (_isSameDay(date, now.subtract(const Duration(days: 1)))) { - return 'Yesterday'; - } - return "${date.day}/${date.month}/${date.year}"; - } - - @override - Widget build(BuildContext context) { - final chatState = context.watch(); - final activeChat = chatState.activeChat; - final isSelectionMode = _selectedIndices.isNotEmpty; - final theme = Theme.of(context); - - if (activeChat.length != _previousMessageCount) { - final isNewMessage = activeChat.length > _previousMessageCount; - _previousMessageCount = activeChat.length; - - if (isNewMessage) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToBottom(animated: true); - }); - } - } - - return PopScope( - canPop: !isSelectionMode, - onPopInvokedWithResult: (didPop, result) { - if (!didPop) { - setState(() { - _selectedIndices.clear(); - }); - } - }, - child: Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBody: true, - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, -0.2), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: isSelectionMode - ? AppBar( - key: const ValueKey('SelectionAppBar'), - backgroundColor: theme.colorScheme.primary, - leading: IconButton( - icon: Icon( - Icons.close, - color: theme.colorScheme.onPrimary, - ), - onPressed: _clearSelection, - ), - title: Text( - '${_selectedIndices.length} Selected', - style: TextStyle(color: theme.colorScheme.onPrimary), - ), - actions: [ - IconButton( - icon: Icon( - Icons.copy, - color: theme.colorScheme.onPrimary, - ), - onPressed: () => _copySelectedMessages(activeChat), - ), - IconButton( - icon: Icon( - Icons.delete, - color: theme.colorScheme.onPrimary, - ), - onPressed: () { - _clearSelection(); - }, - ), - ], - ) - : GlassAppBar( - key: const ValueKey('GlassAppBar'), - name: widget.displayName, - status: _getPresenceStatusText(chatState), - ), + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + top: 20, + bottom: 20 + bottomPadding, ), - ), - body: Stack( - children: [ - if (chatState.isChatHistoryLoading && activeChat.isEmpty) - Center( - child: CircularProgressIndicator( - color: theme.colorScheme.primary, - ), - ) - else if (activeChat.isEmpty) - Center( - child: Text( - "No messages yet.\nSay hi!", - textAlign: TextAlign.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + onChanged: _handleOnChange, + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: 5, + maxLength: _charLimit, + maxLengthEnforcement: MaxLengthEnforcement.none, style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, + color: _isOverLimit + ? theme.colorScheme.error + : theme.colorScheme.onSurface, + fontSize: 15, ), - ), - ) - else - ListView.builder( - key: const ValueKey('list'), - controller: _scrollController, - reverse: true, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.only( - bottom: 140, - top: 140, - left: 16, - right: 16, - ), - itemCount: activeChat.length + (chatState.isPeerTyping ? 2 : 1), - itemBuilder: (context, index) { - if (index == 0) { - return AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - child: SizedBox( - height: _replyingToMessage != null ? 80 : 0, - ), - ); - } - - if (chatState.isPeerTyping && index == 1) { - return _AnimatedMessageItem( - key: const ValueKey('typing_indicator'), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 8, top: 4), - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular( - 16, - ).copyWith(bottomLeft: const Radius.circular(4)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "typing...", - style: TextStyle( - color: theme.colorScheme.primary, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ], - ), - ), - ), - ); - } - - final int msgIndex = index - (chatState.isPeerTyping ? 2 : 1); - final int realIndex = activeChat.length - 1 - msgIndex; - final msg = activeChat[realIndex]; - final bool isSelected = _selectedIndices.contains(realIndex); - - bool showDateSeparator = false; - if (realIndex == 0) { - showDateSeparator = true; - } else { - final prevMsg = activeChat[realIndex - 1]; - if (!_isSameDay(msg.createdAt, prevMsg.createdAt)) { - showDateSeparator = true; - } - } - - final String cleanSenderId = msg.senderId - .trim() - .toLowerCase(); - final String cleanPeerId = widget.chatUserId - .trim() - .toLowerCase(); - final bool isMe = - cleanSenderId == 'me' || - (cleanSenderId.isNotEmpty && - cleanSenderId != cleanPeerId); - - QuotedMessage? displayQuote = msg.quotedMessage; - - if (msg.replyToMessageId != null && - msg.replyToMessageId!.isNotEmpty) { - if (displayQuote == null || - displayQuote.senderDisplayName.isEmpty) { - Message? parentMsg; - try { - parentMsg = activeChat.firstWhere( - (m) => m.id == msg.replyToMessageId, - ); - } catch (_) {} - - if (parentMsg != null) { - final String pSenderId = parentMsg.senderId - .trim() - .toLowerCase(); - displayQuote = QuotedMessage( - id: parentMsg.id, - senderId: pSenderId, - senderDisplayName: - (pSenderId == 'me' || - (pSenderId.isNotEmpty && - pSenderId != cleanPeerId)) - ? "You" - : widget.displayName, - content: parentMsg.content, - ); - } - } - } - - final String? targetReplyId = - msg.replyToMessageId ?? displayQuote?.id; - - if (targetReplyId != null && targetReplyId.isNotEmpty) { - Message? parentMsg; - try { - parentMsg = activeChat.firstWhere( - (m) => m.id == targetReplyId, - ); - } catch (_) {} - - final String pSenderId = - (parentMsg?.senderId ?? displayQuote?.senderId ?? '') - .trim() - .toLowerCase(); - - String calculatedName = - displayQuote?.senderDisplayName ?? ''; - if (calculatedName.trim().isEmpty || - calculatedName == 'Unknown' || - calculatedName == 'null') { - calculatedName = "Previous Message"; - } - - if (pSenderId.isNotEmpty) { - if (pSenderId == 'me' || - (pSenderId != cleanPeerId && - pSenderId != - widget.chatUserId.trim().toLowerCase())) { - calculatedName = "You"; - } else { - calculatedName = widget.displayName; - } - } - - displayQuote = QuotedMessage( - id: targetReplyId, - senderId: pSenderId, - senderDisplayName: calculatedName, - content: - displayQuote?.content ?? - parentMsg?.content ?? - 'Message not loaded', - ); - } - - return _AnimatedMessageItem( - key: ValueKey( - msg.id?.toString() ?? - msg.createdAt.millisecondsSinceEpoch.toString(), + buildCounter: + ( + context, { + required currentLength, + required isFocused, + required maxLength, + }) => _isOverLimit + ? Text('$currentLength/$maxLength') + : null, + decoration: InputDecoration( + hintText: "Write a message...", + hintStyle: TextStyle( + color: theme.colorScheme.onSurfaceVariant, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDateSeparator) - Center( - child: Container( - margin: const EdgeInsets.symmetric(vertical: 16), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: - theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _formatDateSeparator(msg.createdAt), - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ), - GestureDetector( - onLongPress: () { - HapticFeedback.selectionClick(); - _toggleSelection(realIndex); - }, - onTap: () { - if (isSelectionMode) { - _toggleSelection(realIndex); - } - }, - child: AnimatedScale( - scale: isSelected ? 0.95 : 1.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: isSelected - ? theme.colorScheme.primary.withValues( - alpha: 0.15, - ) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - ), - child: SwipeToReply( - isMe: isMe, - onReply: () { - setState(() { - _replyingToMessage = msg; - }); - }, - child: ChatBubble( - message: msg.content, - isMe: isMe, - timestamp: msg.createdAt, - isRead: msg.isRead, - quotedMessage: displayQuote, - ), - ), - ), - ), - ), - ], + fillColor: _isOverLimit + ? theme.colorScheme.error.withValues(alpha: 0.1) + : theme.colorScheme.surfaceContainerHighest, + filled: true, + errorText: _isOverLimit ? 'character limit exceeded' : null, + counterStyle: TextStyle( + color: _isOverLimit + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + fontWeight: _isOverLimit + ? FontWeight.bold + : FontWeight.normal, ), - ); - }, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - right: 16, - bottom: _replyingToMessage != null ? 180 : 100, - child: AnimatedScale( - scale: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutBack, - child: AnimatedOpacity( - opacity: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: FloatingActionButton( - mini: true, - backgroundColor: theme.colorScheme.surface, - foregroundColor: theme.colorScheme.primary, - elevation: 4, - onPressed: () => _scrollToBottom(animated: true), - child: const Icon(Icons.keyboard_arrow_down), - ), - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: _replyingToMessage != null ? 1.0 : 0.0, - child: _replyingToMessage != null - ? Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 4, - ), - top: BorderSide( - color: theme.dividerColor, - width: 1, - ), - ), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues( - alpha: 0.1, - ), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "Replying to ${_replyingToMessage != null && (_replyingToMessage.senderId.trim().toLowerCase() == 'me' || _replyingToMessage.senderId.trim().toLowerCase() != widget.chatUserId.trim().toLowerCase()) ? 'You' : widget.displayName}", - style: TextStyle( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - _replyingToMessage.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - ), - IconButton( - icon: Icon( - Icons.close, - size: 20, - color: theme.iconTheme.color, - ), - onPressed: () => setState( - () => _replyingToMessage = null, - ), - ), - ], - ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide( + color: theme.colorScheme.error, + width: 1.5, ) - : const SizedBox(width: double.infinity, height: 0), + : BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide(color: theme.colorScheme.error, width: 2) + : BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide( + color: theme.colorScheme.error, + width: 1.5, + ) + : BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), - ), - ], + ), ), - ), - ], - ), - ), - ); - } -} - -class _AnimatedMessageItem extends StatefulWidget { - final Widget child; - - const _AnimatedMessageItem({super.key, required this.child}); - - @override - State<_AnimatedMessageItem> createState() => _AnimatedMessageItemState(); -} - -class _AnimatedMessageItemState extends State<_AnimatedMessageItem> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _fadeAnimation; - late Animation _slideAnimation; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - - _slideAnimation = Tween( - begin: const Offset(0, 0.15), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - - _scaleAnimation = Tween( - begin: 0.8, - end: 1.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); - - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: ScaleTransition(scale: _scaleAnimation, child: widget.child), - ), - ); - } -} - -class SwipeToReply extends StatefulWidget { - final Widget child; - final VoidCallback onReply; - final bool isMe; - - const SwipeToReply({ - super.key, - required this.child, - required this.onReply, - required this.isMe, - }); - - @override - State createState() => _SwipeToReplyState(); -} - -class _SwipeToReplyState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - double _dragOffset = 0.0; - bool _vibrated = false; - final double _replyThreshold = 60.0; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 250), - ); - _animation = Tween( - begin: 0.0, - end: 0.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - _controller.addListener(() { - setState(() { - _dragOffset = _animation.value; - }); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _onDragUpdate(DragUpdateDetails details) { - setState(() { - _dragOffset += details.primaryDelta!; - - if (widget.isMe) { - if (_dragOffset > 0) _dragOffset = 0; - if (_dragOffset < -_replyThreshold) { - _dragOffset = - -_replyThreshold + ((_dragOffset + _replyThreshold) * 0.15); - } - } else { - if (_dragOffset < 0) _dragOffset = 0; - if (_dragOffset > _replyThreshold) { - _dragOffset = - _replyThreshold + ((_dragOffset - _replyThreshold) * 0.15); - } - } - - if (_dragOffset.abs() >= _replyThreshold && !_vibrated) { - HapticFeedback.lightImpact(); - _vibrated = true; - } else if (_dragOffset.abs() < _replyThreshold) { - _vibrated = false; - } - }); - } - - void _onDragEnd(DragEndDetails details) { - if (_dragOffset.abs() >= _replyThreshold) { - widget.onReply(); - } - _vibrated = false; - - _animation = Tween( - begin: _dragOffset, - end: 0.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - _controller.forward(from: 0.0); - } - - @override - Widget build(BuildContext context) { - final double iconOpacity = (_dragOffset.abs() / _replyThreshold).clamp( - 0.0, - 1.0, - ); - - return GestureDetector( - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, - behavior: HitTestBehavior.opaque, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - left: widget.isMe ? null : 20, - right: widget.isMe ? 20 : null, - child: Opacity( - opacity: iconOpacity, - child: Transform.scale( - scale: 0.5 + (0.5 * iconOpacity), - child: Icon( - Icons.reply, - color: Theme.of(context).colorScheme.primary, - size: 28, + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: IconButton( + icon: _isOverLimit + ? Icon( + Icons.not_interested, + color: theme.colorScheme.error, + ) + : Icon(Icons.send, color: theme.colorScheme.primary), + onPressed: _isOverLimit ? null : _submit, ), ), - ), - ), - Transform.translate( - offset: Offset(_dragOffset, 0), - child: widget.child, + ], ), - ], + ), ), ); } diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index e1c83be..4a8e71a 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/controllers/chat.dart'; +import 'package:provider/provider.dart'; import 'package:simple_rich_text/simple_rich_text.dart'; import '../models/inbox_item.dart'; import '../pages/chat_page.dart'; @@ -38,10 +41,23 @@ class CustomChatCard extends StatelessWidget { Widget build(BuildContext context) { final String timeLabel = _formatTimestamp(conversation.timestamp); final bool hasUnread = conversation.unreadCount > 0; + final chatState = context.watch(); + + final currentUserId = context.read().currentUser?.id; + + String? senderName; + if (conversation.isGroup && conversation.lastMessageSender != null) { + if (conversation.lastMessageSender == currentUserId) { + senderName = "You"; + } else { + senderName = + chatState.userCache[conversation.lastMessageSender!] ?? "Member"; + } + } return Material( color: isSelected - ? Colors.blue.withValues(alpha: 0.1) + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) : Colors.transparent, child: InkWell( onLongPress: onLongPress, @@ -56,6 +72,7 @@ class CustomChatCard extends StatelessWidget { builder: (_) => ChatPage( chatUserId: conversation.id, displayName: conversation.title, + isGroup: conversation.isGroup, ), ), ); @@ -74,10 +91,12 @@ class CustomChatCard extends StatelessWidget { children: [ CircleAvatar( radius: 26, - backgroundColor: const Color(0xFFD6E4FF), + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, child: Icon( conversation.isGroup ? Icons.group : Icons.person, - color: const Color(0xFF1890FF), + color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), Positioned( @@ -88,13 +107,13 @@ class CustomChatCard extends StatelessWidget { duration: const Duration(milliseconds: 250), curve: Curves.easeOutBack, child: Container( - decoration: const BoxDecoration( - color: Colors.white, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.check_circle, - color: Colors.blue, + color: Theme.of(context).colorScheme.primary, size: 22, ), ), @@ -110,19 +129,23 @@ class CustomChatCard extends StatelessWidget { children: [ Text( conversation.title, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, - color: Colors.black87, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 4), SimpleRichText( - conversation.lastMessage.replaceAll('\n', ' '), + senderName != null + ? "*$senderName:* ${conversation.lastMessage.replaceAll('\n', ' ')}" + : conversation.lastMessage.replaceAll('\n', ' '), maxLines: 1, textOverflow: TextOverflow.ellipsis, style: TextStyle( - color: hasUnread ? Colors.black87 : Colors.black54, + color: hasUnread + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 15, ), ), @@ -137,8 +160,8 @@ class CustomChatCard extends StatelessWidget { timeLabel, style: TextStyle( color: hasUnread - ? const Color(0xFF1890FF) - : Colors.black38, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, fontWeight: hasUnread ? FontWeight.w600 @@ -157,7 +180,7 @@ class CustomChatCard extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: const Color(0xFF1890FF), + color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(10), ), alignment: Alignment.center, @@ -165,8 +188,8 @@ class CustomChatCard extends StatelessWidget { conversation.unreadCount > 99 ? "99+" : "${conversation.unreadCount}", - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, fontSize: 11, fontWeight: FontWeight.bold, ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 01d93fb..b9d1046 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -744,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" scrollview_observer: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0bdcb15..a4e9646 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: intl: ^0.20.3 simple_rich_text: ^2.0.49 qr_flutter: ^4.1.0 + scrollable_positioned_list: ^0.3.8 launcher_name: default: "Elephant"