diff --git a/mobile/lib/controllers/auth_state.dart b/mobile/lib/controllers/auth_state.dart index 15abf20..67adac6 100644 --- a/mobile/lib/controllers/auth_state.dart +++ b/mobile/lib/controllers/auth_state.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mobile/services/api_services.dart'; import 'package:mobile/services/db_services.dart'; import 'package:mobile/services/signal_service.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,44 +15,77 @@ import '../services/auth_service.dart'; class AuthState extends ChangeNotifier { final AuthService _authService = AuthService(); final AppLinks _appLinks = AppLinks(); + final FlutterSecureStorage _storage = const FlutterSecureStorage(); StreamSubscription? _linkSubscription; String? _token; bool _isLoading = false; String? _errorMessage; - UserModel? _currentUser; + /// Full username (with discriminator) assigned at registration, shown once + /// so the user knows what to log in with. + String? newlyRegisteredUsername; + String? get token => _token; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; - UserModel? get currentUser => _currentUser; - static VoidCallback? onGlobalUnauthorized; - AuthState() { - onGlobalUnauthorized = logoutSilently; + AuthService.onSessionExpired = logoutSilently; _initDeepLinks(); } + // --- Validation (mirrors the server's rules) --- + + static String? validateUsername(String? value, {required bool isRegister}) { + final text = (value ?? '').trim().toLowerCase(); + if (text.isEmpty) return 'Enter your username'; + if (!isRegister) return null; + if (text.length < ServerLimits.usernameMin || + text.length > ServerLimits.usernameMax) { + return 'Use ${ServerLimits.usernameMin}–${ServerLimits.usernameMax} characters'; + } + if (!RegExp(r'^[a-z0-9]+$').hasMatch(text)) { + return 'Only letters and numbers'; + } + return null; + } + + static String? validatePassword(String? value, {required bool isRegister}) { + final text = value ?? ''; + if (text.isEmpty) return 'Enter your password'; + if (isRegister && text.length < ServerLimits.passwordMin) { + return 'Use at least ${ServerLimits.passwordMin} characters'; + } + return null; + } + + static String? validateDisplayName(String? value) { + return (value ?? '').trim().isEmpty ? 'Enter a display name' : null; + } + + void clearError() { + if (_errorMessage != null) { + _errorMessage = null; + notifyListeners(); + } + } + + // --- Deep links (OAuth handoff) --- + Future _initDeepLinks() async { try { final initialUri = await _appLinks.getInitialLink(); - if (initialUri != null) { - _handleIncomingUri(initialUri); - } + if (initialUri != null) _handleIncomingUri(initialUri); } catch (e) { debugPrint("Error reading initial deep link: $e"); } _linkSubscription = _appLinks.uriLinkStream.listen( - (Uri uri) { - _handleIncomingUri(uri); - }, - onError: (err) { - debugPrint("Deep link stream error: $err"); - }, + _handleIncomingUri, + onError: (err) => debugPrint("Deep link stream error: $err"), ); } @@ -61,46 +95,33 @@ class AuthState extends ChangeNotifier { } } + // --- Session --- + Future loadUserProfile() async { if (_token == null) return; try { - final res = await _authService.getCurrentUser(_token!); - final data = res.data; - final userData = data['data'] ?? data['user'] ?? data; - + final res = await ApiService().getMe(); + final userData = ApiService.dataMap(res.data); _currentUser = UserModel.fromJson(userData); - await _authService.saveUserProfile(jsonEncode(userData)); - notifyListeners(); } catch (e) { - debugPrint("Network failed, attempting to load cached user profile: $e"); - - try { - final cachedData = await _authService.getCachedUserProfile(); - if (cachedData != null) { - final decodedData = jsonDecode(cachedData); - _currentUser = UserModel.fromJson(decodedData); - notifyListeners(); - } else { - _errorMessage = "No internet connection and no cached profile."; - notifyListeners(); - } - } catch (cacheError) { - debugPrint("Cache read failed: $cacheError"); + debugPrint("Profile fetch failed, using cached profile: $e"); + final cachedData = await _authService.getCachedUserProfile(); + if (cachedData != null) { + _currentUser = UserModel.fromJson(jsonDecode(cachedData)); } } + notifyListeners(); } Future checkAutoLogin() async { _token = await _authService.getToken(); if (_token != null) { - await SignalService().ensureIdentityInitialized(); - await SignalService().uploadPublicKeys(); await loadUserProfile(); if (_currentUser != null) { - await SignalService().initializeAndUploadKeys(_currentUser!.id); + unawaited(SignalService().ensureKeysPublished()); } } @@ -108,192 +129,136 @@ class AuthState extends ChangeNotifier { return _token; } - Future handleLogin(String username, String password) async { - _isLoading = true; - _errorMessage = null; - notifyListeners(); + /// Stores the token pair, loads the profile and publishes E2EE keys. + Future _completeSignIn(String accessToken, String refreshToken) async { + await _authService.saveTokens(accessToken, refreshToken); + _token = accessToken; + await loadUserProfile(); - try { - final res = await _authService.login(username, password); - dynamic dataMap = res.data; - - if (dataMap is String) { - try { - dataMap = jsonDecode(dataMap); - } catch (_) {} - } + if (_currentUser == null) { + _errorMessage = "Signed in, but your profile couldn't be loaded."; + _token = null; + await _authService.logout(); + return false; + } - if (dataMap is Map && dataMap['success'] == true) { - Map? tokens = dataMap['data']?['tokens']; - if (tokens != null && - tokens['access_token'] != null && - tokens['refresh_token'] != null) { - _token = tokens['access_token']; - await _authService.saveTokens( - tokens['access_token'], - tokens['refresh_token'], - ); - - await loadUserProfile(); - - if (_currentUser != null) { - final FlutterSecureStorage storage = const FlutterSecureStorage(); - final String? lastUserId = await storage.read(key: "last_user_id"); - - if (lastUserId != null && lastUserId != _currentUser!.id) { - final db = await DatabaseHelper.instance.database; - await db.delete('signal_local_keys'); - await db.delete('signal_identities'); - await db.delete('signal_sessions'); - await db.delete('signal_prekeys'); - await db.delete('signal_signed_prekeys'); - } - await storage.write(key: "last_user_id", value: _currentUser!.id); - - await SignalService().ensureIdentityInitialized(); - await SignalService().uploadPublicKeys(); - } - - _isLoading = false; - notifyListeners(); - return true; - } - } - _errorMessage = - dataMap['error'] ?? dataMap['message'] ?? "Authentication failed"; - } on DioException catch (e) { - _errorMessage = e.response?.data?['error'] ?? "Server validation failed."; - } catch (e) { - _errorMessage = "Connection parsing error occurred"; + // Keys on this device belong to whoever signed in last. + final lastUserId = await _storage.read(key: "last_user_id"); + if (lastUserId != null && lastUserId != _currentUser!.id) { + await SignalService().wipeLocalKeys(); + await DatabaseHelper.instance.wipeChatData(); } + await _storage.write(key: "last_user_id", value: _currentUser!.id); - _isLoading = false; - notifyListeners(); - return false; + await SignalService().ensureKeysPublished(); + return true; } - Future handleRegister( - String username, - String displayName, - String password, + Future _runAuthRequest( + Future Function() request, + String fallbackError, ) async { _isLoading = true; _errorMessage = null; notifyListeners(); + bool success = false; try { - final res = await _authService.register(username, displayName, password); - dynamic dataMap = res.data; - - if (dataMap is String) { - try { - dataMap = jsonDecode(dataMap); - } catch (_) {} - } - - if (dataMap is Map && dataMap['success'] == true) { - Map? tokens = dataMap['data']?['tokens']; - if (tokens != null && - tokens['access_token'] != null && - tokens['refresh_token'] != null) { - _token = tokens['access_token']; - await _authService.saveTokens( - tokens['access_token'], - tokens['refresh_token'], - ); - - await loadUserProfile(); - - if (_currentUser != null) { - await SignalService().ensureIdentityInitialized(); - await SignalService().uploadPublicKeys(); - } - - _isLoading = false; - notifyListeners(); - return true; + final res = await request(); + final data = ApiService.dataMap(res.data); + final tokens = data['tokens']; + if (tokens is Map && + tokens['access_token'] != null && + tokens['refresh_token'] != null) { + final user = data['user']; + if (user is Map) { + newlyRegisteredUsername = res.statusCode == 201 + ? user['username']?.toString() + : null; } + success = await _completeSignIn( + tokens['access_token'], + tokens['refresh_token'], + ); + } else { + _errorMessage = fallbackError; } - _errorMessage = dataMap['error'] ?? "Registration failed"; - } on DioException catch (e) { - _errorMessage = e.response?.data?['error'] ?? "Registration rejected."; } catch (e) { - _errorMessage = "Server error occurred during sign up"; + _errorMessage = ApiService.errorMessage(e, fallback: fallbackError); } _isLoading = false; notifyListeners(); - return false; + return success; + } + + Future handleLogin(String username, String password) { + return _runAuthRequest( + () => _authService.login(username.trim().toLowerCase(), password), + "Couldn't sign you in", + ); + } + + Future handleRegister( + String username, + String displayName, + String password, + ) { + return _runAuthRequest( + () => _authService.register( + username.trim().toLowerCase(), + displayName.trim(), + password, + ), + "Couldn't create your account", + ); } Future handleOAuthLogin(String provider) async { - _isLoading = true; _errorMessage = null; notifyListeners(); try { - final String oAuthUrl = "${Env.httpBaseUrl}/auth/$provider"; - final Uri uri = Uri.parse(oAuthUrl); - - final bool launched = await launchUrl( - uri, + final launched = await launchUrl( + Uri.parse("${Env.httpBaseUrl}/auth/$provider"), mode: LaunchMode.externalApplication, ); - if (!launched) { - _errorMessage = - "Could not launch web browser for $provider authentication."; + _errorMessage = "Couldn't open the browser for $provider sign-in."; + notifyListeners(); } } catch (e) { - _errorMessage = "OAuth launch error: $e"; - } finally { - _isLoading = false; + _errorMessage = "Couldn't start $provider sign-in."; notifyListeners(); } } Future handleOAuthCallback(Uri uri) async { + final accessToken = uri.queryParameters['access_token']; + final refreshToken = uri.queryParameters['refresh_token']; + + if (accessToken == null || refreshToken == null) { + _errorMessage = + uri.queryParameters['error'] ?? "Sign-in was cancelled or failed."; + notifyListeners(); + return false; + } + _isLoading = true; _errorMessage = null; notifyListeners(); - try { - final accessToken = - uri.queryParameters['access_token'] ?? uri.queryParameters['token']; - final refreshToken = uri.queryParameters['refresh_token']; - - if (accessToken != null) { - _token = accessToken; - await _authService.saveTokens(accessToken, refreshToken ?? accessToken); - await loadUserProfile(); - - if (_currentUser != null) { - await SignalService().initializeAndUploadKeys(_currentUser!.id); - } - - _isLoading = false; - notifyListeners(); - return true; - } else if (uri.queryParameters.containsKey('error')) { - _errorMessage = uri.queryParameters['error']; - } else { - _errorMessage = "OAuth callback received no valid token payload."; - } - } catch (e) { - _errorMessage = "Failed to parse authentication callback data."; - } + final success = await _completeSignIn(accessToken, refreshToken); _isLoading = false; notifyListeners(); - return false; + return success; } Future logout() async { _token = null; _currentUser = null; await _authService.logout(); - final storage = const FlutterSecureStorage(); - await storage.delete(key: "last_user_id"); notifyListeners(); } @@ -301,8 +266,8 @@ class AuthState extends ChangeNotifier { if (_token != null) { _token = null; _currentUser = null; + _errorMessage = "Your session expired. Please sign in again."; _authService.logout(); - const FlutterSecureStorage().delete(key: "last_user_id"); notifyListeners(); } } diff --git a/mobile/lib/controllers/chat/active_chat_controller.dart b/mobile/lib/controllers/chat/active_chat_controller.dart index 1016751..5de488d 100644 --- a/mobile/lib/controllers/chat/active_chat_controller.dart +++ b/mobile/lib/controllers/chat/active_chat_controller.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:mobile/core/constants.dart'; +import 'package:mobile/core/message_envelope.dart'; import 'package:mobile/services/signal_service.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -14,6 +16,10 @@ class ActiveChatController extends ChangeNotifier { final WebSocketService _ws = WebSocketService(); final Uuid _uuid = const Uuid(); + /// Resolves the signed-in user's ID (set from main.dart). + String Function() currentUserIdProvider = () => ''; + String get _myId => currentUserIdProvider(); + bool isLoadingMore = false; bool hasMoreMessages = true; int _currentRequestId = 0; @@ -25,76 +31,23 @@ class ActiveChatController extends ChangeNotifier { bool isCurrentChatGroup = false; int chatOpenCount = 0; - void refreshUI() { - notifyListeners(); - } - - Future loadMoreMessages() async { - if (isLoadingMore || !hasMoreMessages || currentChatUserId == null || activeChat.isEmpty) return; - - isLoadingMore = true; - notifyListeners(); - - try { - final targetUid = currentChatUserId!; - final oldestMessageTime = activeChat.first.createdAt.toIso8601String(); + void refreshUI() => notifyListeners(); - final res = await _api.getChatHistory( - targetUid, - isGroup: isCurrentChatGroup, - before: oldestMessageTime - ); - - if (currentChatUserId != targetUid) return; + bool _isMine(Message msg) => msg.isFrom(_myId); - final targetList = _extractDataList(res.data, ['messages']); - - if (targetList.isEmpty) { - hasMoreMessages = false; - return; - } + /// Plaintext byte budget for the open chat. + int get messageByteLimit => isCurrentChatGroup + ? ServerLimits.maxGroupMessageBytes + : ServerLimits.maxDirectMessageBytes; - List loadedOldMessages = []; - for (var json in targetList.reversed) { - Message parsedMsg = Message.fromJson(json); - Message decryptedMsg = await _decryptMessageIfNeeded(parsedMsg); - loadedOldMessages.add(decryptedMsg); - } - - activeChat = [...loadedOldMessages, ...activeChat]; - - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedOldMessages) { - if (!msg.content.contains('ciphertext') && !msg.content.contains('πŸ”’')) { - batch.insert('messages', { - 'id': msg.id, - 'chat_id': targetUid, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.ignore); - } - } - await batch.commit(noResult: true); - - } catch (e) { - debugPrint("Failed to load older messages: $e"); - } finally { - isLoadingMore = false; - notifyListeners(); - } - } + // --- Loading --- Future openChat(String targetUid, {bool isGroup = false}) async { if (targetUid.isEmpty || targetUid == 'null') return; final int requestId = ++_currentRequestId; if (currentChatUserId != targetUid) { - activeChat.clear(); + activeChat = []; isChatHistoryLoading = true; hasMoreMessages = true; chatOpenCount = 0; @@ -114,29 +67,13 @@ class ActiveChatController extends ChangeNotifier { where: 'chat_id = ?', whereArgs: [targetUid], orderBy: 'created_at DESC', - limit: 50, + limit: ServerLimits.historyPageSize, ); if (localData.isNotEmpty && currentChatUserId == targetUid) { - activeChat = localData - .map( - (row) => Message( - id: row['id'] as String, - senderId: row['sender_id'] as String, - receiverId: targetUid, - content: row['content'] as String, - createdAt: DateTime.fromMillisecondsSinceEpoch( - row['created_at'] as int, - ), - isRead: (row['is_read'] as int) == 1, - replyToMessageId: row['reply_to_id'] as String?, - syncStatus: row['sync_status'] as String? ?? 'synced', - ), - ) - .toList() - .reversed - .toList(); - + activeChat = _withResolvedQuotes( + localData.map(Message.fromRow).toList().reversed.toList(), + ); isChatHistoryLoading = false; notifyListeners(); } @@ -146,97 +83,83 @@ class ActiveChatController extends ChangeNotifier { try { final res = await _api.getChatHistory(targetUid, isGroup: isGroup); - - if (requestId != _currentRequestId || currentChatUserId != targetUid) + if (requestId != _currentRequestId || currentChatUserId != targetUid) { return; + } - final targetList = _extractDataList(res.data, ['messages']); - - final db = await DatabaseHelper.instance.database; - final localData = await db.query( - 'messages', - where: 'chat_id = ?', - whereArgs: [targetUid], + final page = await _decodeServerPage( + targetUid, + ApiService.dataList(res.data), ); + if (currentChatUserId != targetUid) return; - final Map localDecryptedContents = {}; - for (var row in localData) { - final content = row['content'] as String; - if (!content.contains('ciphertext') && !content.contains('πŸ”’')) { - localDecryptedContents[row['id'] as String] = content; - } - } + hasMoreMessages = page.length >= ServerLimits.historyPageSize; + _mergeLatestPage(page); + await _cache(targetUid, page); - List loadedMessages = []; - for (var json in targetList.reversed) { - Message parsedMsg = Message.fromJson(json); - - if (localDecryptedContents.containsKey(parsedMsg.id)) { - loadedMessages.add( - parsedMsg.copyWith(content: localDecryptedContents[parsedMsg.id]), - ); - } else { - Message decryptedMsg = await _decryptMessageIfNeeded(parsedMsg); - loadedMessages.add(decryptedMsg); - } + _ws.sendReadReceipt( + receiverId: isGroup ? null : targetUid, + groupId: isGroup ? targetUid : null, + ); + if (!isGroup) _ws.sendRequestStatus(targetId: targetUid); + } catch (e) { + debugPrint("Chat history fetch failed: $e"); + } finally { + if (currentChatUserId == targetUid) { + isChatHistoryLoading = false; + notifyListeners(); } + } + } - if (currentChatUserId != targetUid) return; - - if (loadedMessages.isNotEmpty) { - final pendingMessages = activeChat - .where((m) => m.syncStatus == 'pending') - .toList(); + Future loadMoreMessages() async { + if (isLoadingMore || + isChatHistoryLoading || + !hasMoreMessages || + currentChatUserId == null || + activeChat.isEmpty) { + return; + } - pendingMessages.removeWhere( - (pending) => loadedMessages.any( - (loaded) => - loaded.id == pending.id || - loaded.content.trim() == pending.content.trim(), - ), - ); + isLoadingMore = true; + notifyListeners(); - activeChat = [...loadedMessages, ...pendingMessages]; - - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - if (msg.content.contains('ciphertext') || - msg.content.contains('πŸ”’')) { - continue; - } - - batch.insert('messages', { - 'id': msg.id, - 'chat_id': targetUid, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - await batch.commit(noResult: true); - } + final targetUid = currentChatUserId!; + try { + final oldest = activeChat.firstWhere( + (m) => !m.isPending, + orElse: () => activeChat.first, + ); + final res = await _api.getChatHistory( + targetUid, + isGroup: isCurrentChatGroup, + before: oldest.createdAt.toUtc().toIso8601String(), + ); + if (currentChatUserId != targetUid) return; - _ws.sendReadReceipt( - receiverId: isGroup ? null : targetUid, - groupId: isGroup ? targetUid : null, + final page = await _decodeServerPage( + targetUid, + ApiService.dataList(res.data), ); + if (currentChatUserId != targetUid) return; - _ws.sendRequestStatus(targetId: targetUid); + hasMoreMessages = page.length >= ServerLimits.historyPageSize; + final knownIds = activeChat.map((m) => m.id).toSet(); + final older = page.where((m) => !knownIds.contains(m.id)).toList(); + activeChat = _withResolvedQuotes([...older, ...activeChat]); + await _cache(targetUid, older); } catch (e) { - debugPrint("API Timeline tracking fail: $e"); + debugPrint("Failed to load older messages: $e"); } finally { - isChatHistoryLoading = false; + isLoadingMore = false; notifyListeners(); } } + /// Refetches the newest page, e.g. after resuming the app. Future syncActiveChatSilently() async { - if (currentChatUserId == null) return; - final String targetUid = currentChatUserId!; + final targetUid = currentChatUserId; + if (targetUid == null) return; try { final res = await _api.getChatHistory( @@ -245,155 +168,179 @@ class ActiveChatController extends ChangeNotifier { ); if (currentChatUserId != targetUid) return; - final targetList = _extractDataList(res.data, ['messages']); - - final db = await DatabaseHelper.instance.database; - final localData = await db.query( - 'messages', - where: 'chat_id = ?', - whereArgs: [targetUid], + final page = await _decodeServerPage( + targetUid, + ApiService.dataList(res.data), ); + if (currentChatUserId != targetUid) return; - final Map localDecryptedContents = {}; - for (var row in localData) { - final content = row['content'] as String; - if (!content.contains('ciphertext') && !content.contains('πŸ”’')) { - localDecryptedContents[row['id'] as String] = content; - } - } + _mergeLatestPage(page); + await _cache(targetUid, page); + notifyListeners(); + } catch (e) { + debugPrint("Silent chat sync failed: $e"); + } + } - List loadedMessages = []; - for (var json in targetList.reversed) { - Message parsedMsg = Message.fromJson(json); - - if (localDecryptedContents.containsKey(parsedMsg.id)) { - loadedMessages.add( - parsedMsg.copyWith(content: localDecryptedContents[parsedMsg.id]), - ); - } else { - Message decryptedMsg = await _decryptMessageIfNeeded(parsedMsg); - loadedMessages.add(decryptedMsg); - } + /// Converts server rows to readable messages, reusing cached plaintext and + /// only decrypting what is new or was edited since it was cached. + Future> _decodeServerPage( + String chatId, + List rows, + ) async { + final db = await DatabaseHelper.instance.database; + final cached = await db.query( + 'messages', + where: 'chat_id = ?', + whereArgs: [chatId], + ); + final Map cachedById = { + for (final row in cached) row['id'] as String: Message.fromRow(row), + }; + + final List result = []; + // Server pages are newest-first; decrypt oldest-first to follow the ratchet. + for (final json in rows.reversed) { + final msg = Message.fromJson(Map.from(json)); + final local = cachedById[msg.id]; + final localIsReadable = + local != null && !MessageEnvelope.isUnreadable(local.content); + final editedSinceCache = + msg.editedAt != null && + (local?.editedAt == null || msg.editedAt!.isAfter(local!.editedAt!)); + + String content; + if (localIsReadable && !editedSinceCache) { + content = local.content; + } else if (_isMine(msg) && + !(MessageEnvelope.tryParse(msg.content)?.isPlaintext ?? true)) { + // Our own DMs are encrypted for the recipient and can't be read back. + content = localIsReadable + ? local.content + : MessageEnvelope.locked('Sent from another device'); + } else { + content = await SignalService().decodeIncoming( + msg.senderId, + msg.content, + ); } - if (currentChatUserId != targetUid) return; + result.add(msg.copyWith(content: content)); + } + return result; + } - if (loadedMessages.isNotEmpty) { - for (int i = 0; i < loadedMessages.length; i++) { - final existingMsg = activeChat.firstWhere( - (m) => m.id == loadedMessages[i].id, - orElse: () => loadedMessages[i], - ); - - if (existingMsg.quotedMessage != null && - loadedMessages[i].quotedMessage != null) { - if (loadedMessages[i].quotedMessage!.senderDisplayName.isEmpty) { - loadedMessages[i] = Message( - id: loadedMessages[i].id, - senderId: loadedMessages[i].senderId, - receiverId: loadedMessages[i].receiverId, - content: loadedMessages[i].content, - createdAt: loadedMessages[i].createdAt, - isRead: loadedMessages[i].isRead, - replyToMessageId: loadedMessages[i].replyToMessageId, - quotedMessage: existingMsg.quotedMessage, - ); - } - } - } + /// Replaces the newest window with [page] while keeping older loaded + /// messages and unsent ones. + void _mergeLatestPage(List page) { + if (page.isEmpty) return; + final pageIds = page.map((m) => m.id).toSet(); + final oldestInPage = page.first.createdAt; + final newestInPage = page.last.createdAt; + final outside = activeChat.where((m) => !pageIds.contains(m.id)); + + final older = outside.where( + (m) => !m.isPending && m.createdAt.isBefore(oldestInPage), + ); + // Realtime messages the server hasn't persisted yet, plus unsent ones. + final newer = outside.where( + (m) => m.isPending || m.createdAt.isAfter(newestInPage), + ); - final pendingMessages = activeChat - .where((m) => m.syncStatus == 'pending') - .toList(); + activeChat = _withResolvedQuotes([...older, ...page, ...newer]); + } - pendingMessages.removeWhere( - (pending) => loadedMessages.any( - (loaded) => - loaded.id == pending.id || - loaded.content.trim() == pending.content.trim(), - ), + Future _cache(String chatId, List messages) async { + try { + final db = await DatabaseHelper.instance.database; + final batch = db.batch(); + for (final msg in messages) { + if (MessageEnvelope.isUnreadable(msg.content)) continue; + batch.insert( + 'messages', + msg.toRow(chatId), + conflictAlgorithm: ConflictAlgorithm.replace, ); - - final mergedMessages = [...loadedMessages, ...pendingMessages]; - - try { - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - if (msg.content.contains('ciphertext') || - msg.content.contains('πŸ”’')) { - continue; - } - - batch.insert('messages', { - 'id': msg.id, - 'chat_id': targetUid, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - await batch.commit(noResult: true); - } catch (dbError) { - debugPrint("Silent Sync DB save failed: $dbError"); - } - - bool hasChanges = activeChat.length != mergedMessages.length; - if (!hasChanges && activeChat.isNotEmpty && mergedMessages.isNotEmpty) { - hasChanges = - activeChat.last.id != mergedMessages.last.id || - activeChat.first.id != mergedMessages.first.id || - activeChat.any((m) => m.syncStatus == 'pending'); - } - - if (hasChanges) { - activeChat = mergedMessages; - notifyListeners(); - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : targetUid, - groupId: isCurrentChatGroup ? targetUid : null, - ); - } } + await batch.commit(noResult: true); } catch (e) { - debugPrint("Silent chat sync fail: $e"); + debugPrint("Failed to cache messages: $e"); } } - Future sendTextMessage( - String text, { - Message? replyingTo, - String? replyingToName, - String? senderId, - }) async { + /// Fills reply previews with readable text from the loaded conversation, + /// since the server quotes the raw (possibly encrypted) content. + List _withResolvedQuotes(List messages) { + final byId = {for (final m in messages) m.id: m}; + return messages.map((msg) { + final replyId = msg.replyToMessageId; + if (replyId == null) return msg; + + final original = byId[replyId]; + if (original != null) { + return msg.copyWith( + quotedMessage: QuotedMessage( + id: replyId, + senderId: original.senderId, + senderDisplayName: msg.quotedMessage?.senderDisplayName ?? '', + content: original.content, + ), + ); + } + final quoted = msg.quotedMessage; + if (quoted == null) return msg; + return msg.copyWith( + quotedMessage: quoted.copyWith( + content: MessageEnvelope.preview(quoted.content), + ), + ); + }).toList(); + } + + // --- Sending --- + + /// Sends a message. Returns an error to show the user, or null on success. + Future sendTextMessage(String text, {Message? replyingTo}) async { final cleanContent = text.trim(); - if (currentChatUserId == null || cleanContent.isEmpty) return; + final targetId = currentChatUserId; + if (targetId == null || cleanContent.isEmpty) return null; - final targetId = currentChatUserId!; + if (utf8.encode(cleanContent).length > messageByteLimit) { + return 'Message is too long.'; + } - if (!isCurrentChatGroup) { + final isGroup = isCurrentChatGroup; + String securePayload; + if (isGroup) { + securePayload = MessageEnvelope.plain(cleanContent); + } else { final sessionReady = await SignalService().establishSessionIfNeeded( targetId, ); if (!sessionReady) { - debugPrint("Send aborted: Target user has no E2EE keys on server."); - return; + return "This contact hasn't set up encryption yet. Try again once they've opened Elephant."; + } + try { + securePayload = await SignalService().encryptDirect( + targetId, + cleanContent, + ); + } catch (e) { + debugPrint("Encryption failed: $e"); + return "Couldn't encrypt this message. Try resetting the secure session."; } } final clientMessageId = _uuid.v4(); - - QuotedMessage? quoted; - if (replyingTo != null) { - quoted = QuotedMessage( - id: replyingTo.id, - senderId: replyingTo.senderId, - senderDisplayName: replyingToName ?? 'Unknown', - content: replyingTo.content, - ); + final frame = WebSocketService.chatFrame( + messageId: clientMessageId, + receiverId: isGroup ? null : targetId, + groupId: isGroup ? targetId : null, + content: securePayload, + replyToMessageId: replyingTo?.id, + ); + if (WebSocketService.frameSize(frame) > ServerLimits.maxWsFrameBytes) { + return 'Message is too long.'; } final optimisticMsg = Message( @@ -404,7 +351,14 @@ class ActiveChatController extends ChangeNotifier { createdAt: DateTime.now(), isRead: false, replyToMessageId: replyingTo?.id, - quotedMessage: quoted, + quotedMessage: replyingTo == null + ? null + : QuotedMessage( + id: replyingTo.id, + senderId: replyingTo.senderId, + senderDisplayName: '', + content: replyingTo.content, + ), syncStatus: 'pending', ); @@ -412,306 +366,164 @@ class ActiveChatController extends ChangeNotifier { notifyListeners(); try { - await DatabaseHelper.instance.insertMessage({ - 'id': clientMessageId, - 'chat_id': targetId, - 'sender_id': 'me', - 'content': cleanContent, - 'created_at': DateTime.now().millisecondsSinceEpoch, - 'is_read': 0, - 'reply_to_id': replyingTo?.id, - 'sync_status': 'pending', - }); - - String securePayload; - if (isCurrentChatGroup) { - securePayload = jsonEncode({'type': 0, 'ciphertext': cleanContent}); - } else { - final encryptedData = await SignalService().encryptMessage( - targetId, - cleanContent, - ); - securePayload = jsonEncode({ - 'type': encryptedData['type'], - 'ciphertext': encryptedData['ciphertext'], - }); - } - - final payload = { - 'messageId': clientMessageId, - 'receiverId': isCurrentChatGroup ? null : targetId, - 'groupId': isCurrentChatGroup ? targetId : null, - 'content': securePayload, - 'replyToMessageId': replyingTo?.id, - }; - + await DatabaseHelper.instance.insertMessage( + optimisticMsg.toRow(targetId), + ); await DatabaseHelper.instance.queueAction( clientMessageId, - isCurrentChatGroup ? 'send_group_chat' : 'send_chat', - payload, + isGroup ? 'send_group_chat' : 'send_chat', + { + 'messageId': clientMessageId, + 'receiverId': isGroup ? null : targetId, + 'groupId': isGroup ? targetId : null, + 'content': securePayload, + 'replyToMessageId': replyingTo?.id, + }, ); - if (isCurrentChatGroup) { - _ws.sendGroupChat( - messageId: clientMessageId, - groupId: targetId, - content: securePayload, - replyToMessageId: replyingTo?.id, - ); - } else { - _ws.sendChat( - messageId: clientMessageId, - receiverId: targetId, - content: securePayload, - replyToMessageId: replyingTo?.id, - ); - } - - if (_ws.isConnected) { - markMessageAsSynced(clientMessageId); + if (_ws.emit(frame)) { + await markMessageAsSynced(clientMessageId); } } catch (e) { - debugPrint("Immediate send failed, message queued: $e"); + debugPrint("Immediate send failed, message stays queued: $e"); } + return null; } - void sendTypingNotification(bool typing) { - if (currentChatUserId != null) { - _ws.sendTyping( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, - isTyping: typing, - ); + /// Edits one of our messages via `PUT /api/messages/{id}`. Returns an error + /// to show, or null on success. The other side sees the edit on next sync. + Future editMessage(Message msg, String newText) async { + final targetId = currentChatUserId; + final text = newText.trim(); + if (targetId == null || text.isEmpty) return null; + if (text == msg.content) return null; + if (!msg.canEdit(_myId)) { + return 'Messages can only be edited for 15 minutes after sending.'; + } + if (utf8.encode(text).length > messageByteLimit) { + return 'Message is too long.'; } - } - void closeChat(String closedChatId) { - if (currentChatUserId == closedChatId) { - chatOpenCount--; - if (chatOpenCount <= 0) { - currentChatUserId = null; - isPeerTyping = false; - isPeerOnline = false; - activeChat.clear(); - chatOpenCount = 0; + try { + final String content; + if (isCurrentChatGroup) { + content = MessageEnvelope.plain(text); + } else { + if (!await SignalService().establishSessionIfNeeded(targetId)) { + return "Couldn't reach this contact's encryption keys."; + } + content = await SignalService().encryptDirect(targetId, text); } + + final res = await _api.editMessage(msg.id, content); + final editedAt = + Message.fromJson(ApiService.dataMap(res.data)).editedAt ?? + DateTime.now(); + + final updated = msg.copyWith(content: text, editedAt: editedAt); + activeChat = _withResolvedQuotes( + activeChat.map((m) => m.id == msg.id ? updated : m).toList(), + ); notifyListeners(); + + final db = await DatabaseHelper.instance.database; + await db.update( + 'messages', + {'content': text, 'edited_at': editedAt.millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [msg.id], + ); + return null; + } catch (e) { + return ApiService.errorMessage(e, fallback: "Couldn't edit the message."); } } - List _extractDataList(dynamic data, List fallbackKeys) { - if (data == null) return []; - if (data is List) return data; - if (data is Map) { - if (data['data'] is List) return data['data']; - for (final key in fallbackKeys) { - if (data[key] is List) return data[key]; - } - } - return []; + void sendTypingNotification(bool typing) { + final target = currentChatUserId; + if (target == null) return; + _ws.sendTyping( + receiverId: isCurrentChatGroup ? null : target, + groupId: isCurrentChatGroup ? target : null, + isTyping: typing, + ); } - Future> getLocalMessagesForChat(String chatId) async { - try { - final db = await DatabaseHelper.instance.database; + // --- Realtime updates (called by ChatEventHandler) --- - final List> maps = await db.query( - 'messages', - where: 'chat_id = ? COLLATE NOCASE', - whereArgs: [chatId], - orderBy: 'created_at ASC', - ); + void receiveIncoming(Message msg) { + if (activeChat.any((m) => m.id == msg.id)) return; + activeChat = _withResolvedQuotes([...activeChat, msg]); + isPeerTyping = false; + notifyListeners(); + } - return maps.map((map) => Message.fromJson(map)).toList(); - } catch (e) { - debugPrint("Error fetching local messages for chat search/copy: $e"); - return []; - } + /// The peer read our messages. + void applyReadReceipt() { + bool changed = false; + activeChat = activeChat.map((m) { + if (_isMine(m) && !m.isRead) { + changed = true; + return m.copyWith(isRead: true); + } + return m; + }).toList(); + if (changed) notifyListeners(); } Future markMessageAsSynced(String messageId) async { final index = activeChat.indexWhere((m) => m.id == messageId); if (index != -1) { - final existingMsg = activeChat[index]; - final newList = List.from(activeChat); - - newList[index] = Message( - id: existingMsg.id, - senderId: existingMsg.senderId, - receiverId: existingMsg.receiverId, - content: existingMsg.content, - createdAt: existingMsg.createdAt, - isRead: existingMsg.isRead, - replyToMessageId: existingMsg.replyToMessageId, - quotedMessage: existingMsg.quotedMessage, - syncStatus: 'synced', - ); - + newList[index] = newList[index].copyWith(syncStatus: 'synced'); activeChat = newList; notifyListeners(); + } - try { - final db = await DatabaseHelper.instance.database; - await db.update( - 'messages', - {'sync_status': 'synced'}, - where: 'id = ?', - whereArgs: [messageId], - ); - await db.delete( - 'action_queue', - where: 'id = ?', - whereArgs: [messageId], - ); - } catch (e) { - debugPrint("Failed to update DB sync status: $e"); - } + try { + final db = await DatabaseHelper.instance.database; + await db.update( + 'messages', + {'sync_status': 'synced'}, + where: 'id = ?', + whereArgs: [messageId], + ); + await db.delete('action_queue', where: 'id = ?', whereArgs: [messageId]); + } catch (e) { + debugPrint("Failed to update DB sync status: $e"); } } - void addRealTimeMessage(Message incomingMsg) async { - if (currentChatUserId == null) return; - - bool belongsToCurrentChat = - (isCurrentChatGroup && incomingMsg.receiverId == currentChatUserId) || - (!isCurrentChatGroup && - (incomingMsg.senderId == currentChatUserId || - incomingMsg.receiverId == currentChatUserId)); - - if (!belongsToCurrentChat) return; - - Message newMsg = await _decryptMessageIfNeeded(incomingMsg); - - if (belongsToCurrentChat) { - final existingIndex = activeChat.indexWhere((m) => m.id == newMsg.id); - - if (existingIndex == -1) { - activeChat = [...activeChat, newMsg]; - notifyListeners(); - - try { - await DatabaseHelper.instance.insertMessage({ - 'id': newMsg.id, - 'chat_id': currentChatUserId, - 'sender_id': newMsg.senderId, - 'content': newMsg.content, - 'created_at': newMsg.createdAt.millisecondsSinceEpoch, - 'is_read': newMsg.isRead ? 1 : 0, - 'reply_to_id': newMsg.replyToMessageId, - 'sync_status': 'synced', - }); - } catch (e) { - debugPrint("Failed to save incoming WS message to DB: $e"); - } - - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, - ); - } else { - if (activeChat[existingIndex].syncStatus == 'pending') { - markMessageAsSynced(newMsg.id); - } - } + void closeChat(String closedChatId) { + if (currentChatUserId != closedChatId) return; + chatOpenCount--; + if (chatOpenCount <= 0) { + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + activeChat = []; + chatOpenCount = 0; } + notifyListeners(); } - Future _decryptMessageIfNeeded(Message msg) async { - final content = msg.content.trim(); - - if (content.startsWith('{') && content.contains('ciphertext')) { - try { - final db = await DatabaseHelper.instance.database; - - final exactMatch = await db.query( - 'messages', - where: 'id = ?', - whereArgs: [msg.id], - ); - - if (exactMatch.isNotEmpty) { - final exactContent = exactMatch.first['content'].toString(); - if (!exactContent.contains('ciphertext') && - !exactContent.contains('πŸ”’')) { - return msg.copyWith(content: exactContent); - } - } - - bool isSelfChat = msg.senderId == msg.receiverId; - bool isSentByMe = - isSelfChat || - (!isCurrentChatGroup && msg.senderId != currentChatUserId) || - msg.senderId == 'me'; - - if (isSentByMe) { - final targetChatId = currentChatUserId ?? msg.receiverId; - final fallbackRows = await db.query( - 'messages', - where: 'chat_id = ?', - whereArgs: [targetChatId], - ); - - int minDiff = -1; - String? closestPlaintext; - - for (var row in fallbackRows) { - final rSender = row['sender_id'].toString(); - if (rSender != 'me' && rSender != msg.senderId) continue; - - final rowContent = row['content'].toString(); - if (rowContent.contains('ciphertext') || - rowContent.contains('πŸ”’')) { - continue; - } - - final localTime = row['created_at'] as int; - final diff = (localTime - msg.createdAt.millisecondsSinceEpoch) - .abs(); - - if (diff < 5000 && (minDiff == -1 || diff < minDiff)) { - minDiff = diff; - closestPlaintext = rowContent; - } - } - - if (closestPlaintext != null) { - return msg.copyWith(content: closestPlaintext); - } - - return msg.copyWith(content: "πŸ”’ [Sent from another device]"); - } - } catch (e) { - debugPrint("Local sent-message lookup crashed: $e"); - } - - try { - final Map payload = jsonDecode(content); - - if (isCurrentChatGroup && payload['type'] == 0) { - return msg.copyWith(content: payload['ciphertext']); - } - - final decryptedText = await SignalService().decryptMessage( - msg.senderId, - payload['ciphertext'], - payload['type'], - ); - return msg.copyWith(content: decryptedText); - } catch (e) { - debugPrint("LibSignal Decryption Failed: $e"); - final errStr = e.toString(); - - if (errStr.contains('DuplicateMessageException')) { - return msg.copyWith(content: "πŸ”’ [Message already decrypted]"); - } else if (errStr.contains('NoSessionException') || - errStr.contains('Bad Mac')) { - return msg.copyWith(content: "πŸ”’ [Encrypted for past session]"); - } - - return msg.copyWith(content: "πŸ”’ [Encrypted Message]"); - } + /// Every cached message of a chat, oldest first, for in-chat search. + Future> getLocalMessagesForChat(String chatId) async { + try { + final db = await DatabaseHelper.instance.database; + final rows = await db.query( + 'messages', + where: 'chat_id = ? COLLATE NOCASE', + whereArgs: [chatId], + orderBy: 'created_at ASC', + ); + return rows + .map(Message.fromRow) + .where((m) => !MessageEnvelope.isUnreadable(m.content)) + .toList(); + } catch (e) { + debugPrint("Error reading local messages: $e"); + return []; } - return msg; } } diff --git a/mobile/lib/controllers/chat/chat_connection_controller.dart b/mobile/lib/controllers/chat/chat_connection_controller.dart index 3325682..e711bbc 100644 --- a/mobile/lib/controllers/chat/chat_connection_controller.dart +++ b/mobile/lib/controllers/chat/chat_connection_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:mobile/services/chat/chat_event_handler.dart'; @@ -7,6 +8,8 @@ import 'package:mobile/services/chat/chat_sync_service.dart'; import '../../services/ws_service.dart'; import '../../services/auth_service.dart'; +enum ConnectionStatus { connected, connecting, offline } + class ChatConnectionController extends ChangeNotifier with WidgetsBindingObserver { final WebSocketService _ws = WebSocketService(); @@ -15,7 +18,11 @@ class ChatConnectionController extends ChangeNotifier final ChatEventHandler eventHandler; bool isOffline = false; + ConnectionStatus status = ConnectionStatus.connecting; + bool _isWsConnecting = false; + bool _isPaused = false; + int _failedAttempts = 0; Timer? _reconnectTimer; StreamSubscription? _connectivitySubscription; StreamSubscription? _wsSubscription; @@ -27,62 +34,63 @@ class ChatConnectionController extends ChangeNotifier result, ) { final bool currentlyOffline = result.contains(ConnectivityResult.none); - - if (isOffline != currentlyOffline) { - isOffline = currentlyOffline; - notifyListeners(); - - if (!isOffline) { - connectWebSocket(); - eventHandler.inboxController.loadInbox(); - } else { - eventHandler.activeChatController.isPeerOnline = false; - eventHandler.activeChatController.isPeerTyping = false; - eventHandler.activeChatController.refreshUI(); - - _ws.disconnect(); - } + if (isOffline == currentlyOffline) return; + + isOffline = currentlyOffline; + if (isOffline) { + _markPeerOffline(); + _wsSubscription?.cancel(); + _ws.disconnect(); + _setStatus(ConnectionStatus.offline); + } else { + _failedAttempts = 0; + connectWebSocket(); + eventHandler.inboxController.loadInbox(); } }); } + void _setStatus(ConnectionStatus next) { + if (status != next) { + status = next; + notifyListeners(); + } + } + + void _markPeerOffline() { + final chat = eventHandler.activeChatController; + if (chat.isPeerOnline || chat.isPeerTyping) { + chat.isPeerOnline = false; + chat.isPeerTyping = false; + chat.refreshUI(); + } + } + Future connectWebSocket() async { - if (_ws.isConnected || _isWsConnecting) return; + if (_ws.isConnected || _isWsConnecting || isOffline || _isPaused) return; _isWsConnecting = true; + _reconnectTimer?.cancel(); + _setStatus(ConnectionStatus.connecting); try { - _reconnectTimer?.cancel(); - - final token = await _auth.getToken(); - if (token == null) { - _isWsConnecting = false; - return; - } + // Access tokens live 15 minutes, so refresh before every handshake. + final token = await _auth.getValidAccessToken(); + if (token == null) return; await _wsSubscription?.cancel(); _ws.disconnect(); - final connected = await _ws.connect(token); - if (!connected) { - _triggerReconnectLoop(); + if (!await _ws.connect(token)) { + _scheduleReconnect(); return; } - _syncService.processOfflineQueue( - eventHandler.currentUserId, - activeChatController: eventHandler.activeChatController, - ); - - final currentChatId = eventHandler.activeChatController.currentChatUserId; - if (currentChatId != null && - !eventHandler.activeChatController.isCurrentChatGroup) { - _ws.sendRequestStatus(targetId: currentChatId); - } + _failedAttempts = 0; + _setStatus(ConnectionStatus.connected); _wsSubscription = _ws.stream?.listen( (rawFrame) { try { - debugPrint("πŸ“₯ WS Received: $rawFrame"); final decoded = jsonDecode(rawFrame); if (decoded is Map) { eventHandler.handleIncomingEvent(decoded); @@ -92,68 +100,71 @@ class ChatConnectionController extends ChangeNotifier } }, onError: (err) { - debugPrint("WS Pipeline Error: $err"); - _triggerReconnectLoop(); + debugPrint("WS error: $err"); + _scheduleReconnect(); }, onDone: () { - debugPrint("WS Pipeline Closed by Server."); - _triggerReconnectLoop(); + debugPrint("WS closed"); + _scheduleReconnect(); }, + cancelOnError: true, + ); + + _syncService.processOfflineQueue( + eventHandler.currentUserId, + activeChatController: eventHandler.activeChatController, ); + + final chat = eventHandler.activeChatController; + if (chat.currentChatUserId != null && !chat.isCurrentChatGroup) { + _ws.sendRequestStatus(targetId: chat.currentChatUserId!); + } } catch (e) { - debugPrint("WS Setup Error: $e"); - _triggerReconnectLoop(); + debugPrint("WS setup error: $e"); + _scheduleReconnect(); } finally { _isWsConnecting = false; } - _ws.onConnectionLost = () { - debugPrint( - "ConnectionController: Caught immediate socket loss from watchdog.", - ); - _triggerReconnectLoop(); - }; } - void _triggerReconnectLoop() { + void _scheduleReconnect() { _ws.disconnect(); _isWsConnecting = false; + _markPeerOffline(); + if (isOffline || _isPaused) return; - if (eventHandler.activeChatController.isPeerOnline) { - eventHandler.activeChatController.isPeerOnline = false; - eventHandler.activeChatController.refreshUI(); - } + _setStatus(ConnectionStatus.connecting); + _failedAttempts++; + final seconds = min(30, 2 * pow(2, min(_failedAttempts - 1, 4)).toInt()); _reconnectTimer?.cancel(); - _reconnectTimer = Timer(const Duration(seconds: 4), () { - if (!isOffline) { - connectWebSocket(); - } - }); + _reconnectTimer = Timer(Duration(seconds: seconds), connectWebSocket); + } + + /// Reconnect immediately, e.g. from a "Retry" button. + void retryNow() { + _failedAttempts = 0; + connectWebSocket(); } void disconnectWebSocket() { _reconnectTimer?.cancel(); + _wsSubscription?.cancel(); _ws.disconnect(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - _syncService.processOfflineQueue( - eventHandler.currentUserId, - activeChatController: eventHandler.activeChatController, - ); - + _isPaused = false; connectWebSocket(); - - Future.delayed(const Duration(milliseconds: 500), () { - if (!isOffline) { - eventHandler.inboxController.loadInbox(); - } - }); + if (!isOffline) { + eventHandler.inboxController.loadInbox(); + eventHandler.activeChatController.syncActiveChatSilently(); + } } else if (state == AppLifecycleState.paused) { - _reconnectTimer?.cancel(); - _ws.disconnect(); + _isPaused = true; + disconnectWebSocket(); } } @@ -161,9 +172,7 @@ class ChatConnectionController extends ChangeNotifier void dispose() { WidgetsBinding.instance.removeObserver(this); _connectivitySubscription?.cancel(); - _wsSubscription?.cancel(); - _reconnectTimer?.cancel(); - _ws.disconnect(); + disconnectWebSocket(); super.dispose(); } } diff --git a/mobile/lib/controllers/chat/chat_search_controller.dart b/mobile/lib/controllers/chat/chat_search_controller.dart index bc3b11f..c98eb42 100644 --- a/mobile/lib/controllers/chat/chat_search_controller.dart +++ b/mobile/lib/controllers/chat/chat_search_controller.dart @@ -1,16 +1,35 @@ import 'package:flutter/material.dart'; +import '../../core/constants.dart'; +import '../../models/user.dart'; import '../../services/api_services.dart'; +/// User directory search (`GET /api/users/search`, rate limited server-side). class ChatSearchController extends ChangeNotifier { final ApiService _api = ApiService(); - List contactSearchResults = []; + List results = []; bool isSearchLoading = false; + bool isLoadingMore = false; + bool hasMore = false; + String? error; + + String _query = ''; + int _page = 1; + int _requestId = 0; + + static const int minQueryLength = 2; Future queryUsers(String term) async { - final String cleanTerm = term.trim(); - if (cleanTerm.isEmpty || cleanTerm.length < 3) { - contactSearchResults.clear(); + final query = term.trim(); + final requestId = ++_requestId; + _query = query; + _page = 1; + error = null; + + if (query.length < minQueryLength) { + results = []; + hasMore = false; + isSearchLoading = false; notifyListeners(); return; } @@ -19,34 +38,57 @@ class ChatSearchController extends ChangeNotifier { notifyListeners(); try { - final res = await _api.searchUsers(term); - contactSearchResults = _extractDataList(res.data, ['users']); + final page = await _fetch(query, 1); + if (requestId != _requestId) return; + results = page; + hasMore = page.length >= ServerLimits.searchPageSize; } catch (e) { - debugPrint("User query failure: $e"); - contactSearchResults.clear(); + if (requestId != _requestId) return; + results = []; + error = ApiService.errorMessage(e, fallback: "Search failed."); } finally { - isSearchLoading = false; - notifyListeners(); + if (requestId == _requestId) { + isSearchLoading = false; + notifyListeners(); + } } } - void clearSearch() { - if (contactSearchResults.isNotEmpty || isSearchLoading) { - contactSearchResults.clear(); - isSearchLoading = false; + Future loadMore() async { + if (!hasMore || isLoadingMore || isSearchLoading) return; + final requestId = _requestId; + isLoadingMore = true; + notifyListeners(); + + try { + final page = await _fetch(_query, _page + 1); + if (requestId != _requestId) return; + _page++; + final known = results.map((u) => u.id).toSet(); + results = [...results, ...page.where((u) => !known.contains(u.id))]; + hasMore = page.length >= ServerLimits.searchPageSize; + } catch (e) { + error = ApiService.errorMessage(e, fallback: "Search failed."); + } finally { + isLoadingMore = false; notifyListeners(); } } - List _extractDataList(dynamic data, List fallbackKeys) { - if (data == null) return []; - if (data is List) return data; - if (data is Map) { - if (data['data'] is List) return data['data']; - for (final key in fallbackKeys) { - if (data[key] is List) return data[key]; - } - } - return []; + Future> _fetch(String query, int page) async { + final res = await _api.searchUsers(query, page: page); + return ApiService.dataList(res.data) + .map((json) => UserModel.fromJson(Map.from(json))) + .toList(); + } + + void clearSearch() { + _requestId++; + _query = ''; + results = []; + hasMore = false; + error = null; + isSearchLoading = false; + notifyListeners(); } } diff --git a/mobile/lib/controllers/chat/group_details_controller.dart b/mobile/lib/controllers/chat/group_details_controller.dart index 48ab9cc..0fdb28e 100644 --- a/mobile/lib/controllers/chat/group_details_controller.dart +++ b/mobile/lib/controllers/chat/group_details_controller.dart @@ -1,39 +1,47 @@ import 'package:flutter/material.dart'; -import 'package:mobile/pages/chat/chat_details_page.dart'; +import '../../models/group.dart'; import '../../services/api_services.dart'; +/// Member lists, roles and admin actions for groups. class GroupDetailsController extends ChangeNotifier { final ApiService _api = ApiService(); - List currentGroupMembers = []; - Map groupMemberNames = {}; - Map userCache = {}; + String? _currentGroupId; + List currentGroupMembers = []; bool isLoadingDetails = false; + + /// user ID β†’ display name, across every group loaded this session. + final Map userCache = {}; final Set _fetchedGroups = {}; + bool hasFetchedGroup(String groupId) => _fetchedGroups.contains(groupId); + String nameFor(String userId, {String fallback = 'Member'}) { + return userCache[userId.trim().toLowerCase()] ?? fallback; + } + + bool isAdmin(String? userId) { + if (userId == null) return false; + return currentGroupMembers.any( + (m) => m.userId.toLowerCase() == userId.toLowerCase() && m.isAdmin, + ); + } + Future fetchGroupMembers(String groupId) async { - try { - isLoadingDetails = true; - notifyListeners(); + if (_currentGroupId != groupId) { + currentGroupMembers = []; + _currentGroupId = groupId; + } + isLoadingDetails = true; + notifyListeners(); - final response = await _api.getGroupMembers(groupId); - if (response.statusCode == 200) { - final List memberList = _extractDataList(response.data, [ - 'members', - 'data', - ]); - - currentGroupMembers = memberList - .map((json) => ChatMember.fromJson(json)) - .toList(); - - for (var member in memberList) { - final uid = member['user_id'].toString(); - userCache[uid] = member['display_name'] ?? 'Member'; - groupMemberNames[uid] = member['display_name'] ?? 'Unknown'; - } - } + try { + final res = await _api.getGroupMembers(groupId); + currentGroupMembers = ApiService.dataList(res.data) + .map((json) => GroupMember.fromJson(Map.from(json))) + .toList(); + _remember(currentGroupMembers); + _fetchedGroups.add(groupId); } catch (e) { debugPrint("Error fetching members: $e"); } finally { @@ -42,51 +50,84 @@ class GroupDetailsController extends ChangeNotifier { } } + /// Warms [userCache] so inbox previews can show sender names. Future preloadGroupMembers(String groupId) async { if (_fetchedGroups.contains(groupId)) return; - _fetchedGroups.add(groupId); try { final res = await _api.getGroupMembers(groupId); - final members = _extractDataList(res.data, ['members', 'data']); + _remember( + ApiService.dataList( + res.data, + ).map((json) => GroupMember.fromJson(Map.from(json))), + ); + notifyListeners(); + } catch (e) { + _fetchedGroups.remove(groupId); + debugPrint("Failed to preload members for $groupId: $e"); + } + } - bool updatedCache = false; - for (var m in members) { - final uid = m['user_id'].toString(); - final name = m['display_name'] ?? 'Member'; + void _remember(Iterable members) { + for (final m in members) { + userCache[m.userId.toLowerCase()] = m.displayName; + } + } - if (userCache[uid] != name) { - userCache[uid] = name; - updatedCache = true; - } - } + /// The admin actions below return an error message, or null on success. - if (updatedCache) notifyListeners(); + Future addMember(String groupId, String userId) async { + try { + await _api.addGroupMember(groupId, userId); + await fetchGroupMembers(groupId); + return null; } catch (e) { + return ApiService.errorMessage(e, fallback: "Couldn't add this member."); + } + } + + Future removeMember(String groupId, String userId) async { + try { + await _api.removeGroupMember(groupId, userId); + currentGroupMembers.removeWhere((m) => m.userId == userId); + notifyListeners(); + return null; + } catch (e) { + return ApiService.errorMessage( + e, + fallback: "Couldn't remove this member.", + ); + } + } + + Future renameGroup(String groupId, String newName) async { + try { + await _api.renameGroup(groupId, newName.trim()); + return null; + } catch (e) { + return ApiService.errorMessage(e, fallback: "Couldn't rename the group."); + } + } + + Future leaveGroup(String groupId) async { + try { + await _api.leaveGroup(groupId); _fetchedGroups.remove(groupId); - debugPrint("Failed to preload group members for $groupId: $e"); + if (_currentGroupId == groupId) currentGroupMembers = []; + notifyListeners(); + return null; + } catch (e) { + return ApiService.errorMessage(e, fallback: "Couldn't leave the group."); } } void clearCache() { - currentGroupMembers.clear(); - groupMemberNames.clear(); + currentGroupMembers = []; + _currentGroupId = null; userCache.clear(); _fetchedGroups.clear(); isLoadingDetails = false; notifyListeners(); } - - List _extractDataList(dynamic data, List fallbackKeys) { - if (data == null) return []; - if (data is List) return data; - if (data is Map) { - if (data['data'] is List) return data['data']; - for (final key in fallbackKeys) { - if (data[key] is List) return data[key]; - } - } - return []; - } } diff --git a/mobile/lib/controllers/chat/inbox_controller.dart b/mobile/lib/controllers/chat/inbox_controller.dart index eb6e0d4..90e167d 100644 --- a/mobile/lib/controllers/chat/inbox_controller.dart +++ b/mobile/lib/controllers/chat/inbox_controller.dart @@ -1,31 +1,37 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; +import '../../core/message_envelope.dart'; import '../../models/group.dart'; import '../../models/inbox_item.dart'; -import '../../models/conversation.dart'; -import '../../models/message.dart'; import '../../services/api_services.dart'; import '../../services/db_services.dart'; +import '../../services/ws_service.dart'; class InboxController extends ChangeNotifier { final ApiService _api = ApiService(); List inbox = []; + bool isLoading = false; + bool hasLoadedOnce = false; + + List get groups => inbox.where((item) => item.isGroup).toList(); + int get unreadChats => inbox.where((item) => item.unreadCount > 0).length; void clearInbox() { inbox = []; + hasLoadedOnce = false; + notifyListeners(); } Future loadInbox({ bool isOffline = false, String? currentChatId, }) async { - // 1. Load from Local Cache try { final db = await DatabaseHelper.instance.database; final localData = await db.query('inbox', orderBy: 'timestamp DESC'); - if (localData.isNotEmpty) { - inbox = localData.map((map) => InboxItem.fromMap(map)).toList(); + if (localData.isNotEmpty && inbox.isEmpty) { + inbox = localData.map(InboxItem.fromMap).toList(); notifyListeners(); } } catch (e) { @@ -34,91 +40,91 @@ class InboxController extends ChangeNotifier { if (isOffline) return; - // 2. Fetch from Network - try { - final response = await _api.getConversations(); - final rawData = _parseResponse(response.data, ['conversations']); - final db = await DatabaseHelper.instance.database; - final allChatIds = rawData.map((j) { - return j['is_group'] == true || j['type'] == 'group' - ? j['id'] - : (j['chat_user_id'] ?? j['user_id'] ?? j['id'] ?? j['partner_id']); - }).where((id) => id != null).toList(); - - Map localDecryptedMsgs = {}; - if (allChatIds.isNotEmpty) { - final placeholders = List.filled(allChatIds.length, '?').join(','); - final localMsgs = await db.rawQuery(''' - SELECT chat_id, content - FROM messages - WHERE chat_id IN ($placeholders) - AND content NOT LIKE '%ciphertext%' - AND content NOT LIKE '%πŸ”’%' - GROUP BY chat_id HAVING MAX(created_at) - ''', allChatIds); - - for (var row in localMsgs) { - localDecryptedMsgs[row['chat_id'].toString()] = row['content'].toString(); - } - } + isLoading = true; + notifyListeners(); - List combinedInbox = []; - for (var json in rawData) { - try { - final bool isGroup = - json['is_group'] == true || json['type'] == 'group'; + try { + final results = await Future.wait([ + _api.getConversations(), + _api.getGroups(), + ]); + final conversations = ApiService.dataList(results[0].data); + final myGroups = ApiService.dataList( + results[1].data, + ).map((json) => Group.fromJson(Map.from(json))).toList(); - InboxItem item; - if (isGroup) { - item = InboxItem.fromGroup(Group.fromJson(json)); - } else { - item = InboxItem.fromConversation(Conversation.fromJson(json)); - } + final latestLocal = await _latestReadableMessages(); - if (item.lastMessage.contains('ciphertext') || - item.lastMessage.contains('πŸ”’')) { - if (localDecryptedMsgs.containsKey(item.id)) { - item.lastMessage = localDecryptedMsgs[item.id]!; - } else { - item.lastMessage = "πŸ”’ Encrypted Message"; - } - } + final List combined = []; + for (final raw in conversations) { + final json = Map.from(raw); + final id = json['id']?.toString() ?? ''; + if (id.isEmpty) continue; - combinedInbox.add(item); - } catch (e) { - debugPrint("BAD JSON OBJECT: $json"); + final serverPreview = json['last_message']?.toString() ?? ''; + String preview = MessageEnvelope.preview(serverPreview, fallback: ''); + if (preview.isEmpty) { + preview = latestLocal[id] ?? 'πŸ”’ Encrypted message'; } + combined.add( + InboxItem.fromConversationJson(json, lastMessage: preview), + ); } - combinedInbox.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - - List chatsToCatchUp = []; - for (var newConv in combinedInbox) { - final oldConvIndex = inbox.indexWhere((c) => c.id == newConv.id); - if (oldConvIndex == -1 || - inbox[oldConvIndex].timestamp.isBefore(newConv.timestamp)) { - chatsToCatchUp.add(newConv); + // Groups without messages are missing from the conversations list. + final knownIds = combined.map((item) => item.id).toSet(); + for (final group in myGroups) { + if (!knownIds.contains(group.id)) { + combined.add(InboxItem.fromGroup(group)); } } - if (_hasInboxChanged(inbox, combinedInbox)) { - inbox = combinedInbox; - notifyListeners(); - _saveInboxToDb(inbox); - } + combined.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + final chatsToCatchUp = combined.where((fresh) { + final old = inbox.where((c) => c.id == fresh.id).firstOrNull; + return old == null || old.timestamp.isBefore(fresh.timestamp); + }).toList(); + + inbox = combined; + hasLoadedOnce = true; + await _replaceInboxInDb(inbox); - for (var missedChat in chatsToCatchUp) { - if (missedChat.id != currentChatId) { - unawaited( - _backgroundSyncChatHistoryToDb(missedChat.id, missedChat.isGroup), - ); + for (final chat in chatsToCatchUp) { + if (chat.id != currentChatId && chat.lastMessage.isNotEmpty) { + unawaited(_backgroundSyncChatHistoryToDb(chat.id, chat.isGroup)); } } } catch (e) { - debugPrint("Network Inbox Read Error: $e"); + debugPrint("Inbox fetch failed: $e"); + } finally { + isLoading = false; + notifyListeners(); } } + /// Latest cached plaintext per chat, used when the server preview is ciphertext. + Future> _latestReadableMessages() async { + final db = await DatabaseHelper.instance.database; + final rows = await db.rawQuery( + ''' + SELECT m.chat_id, m.content + FROM messages m + JOIN ( + SELECT chat_id, MAX(created_at) AS latest + FROM messages + WHERE content NOT LIKE '%"ciphertext"%' AND content NOT LIKE ? + GROUP BY chat_id + ) l ON l.chat_id = m.chat_id AND l.latest = m.created_at + ''', + ['${MessageEnvelope.lockedPrefix}%'], + ); + return { + for (final row in rows) + row['chat_id'].toString(): row['content'].toString(), + }; + } + void updateLocalInboxState( String chatId, String lastMessage, @@ -129,87 +135,118 @@ class InboxController extends ChangeNotifier { bool isRead = false, }) { final int index = inbox.indexWhere((item) => item.id == chatId); + if (index == -1) { + unawaited(loadInbox()); + return; + } - if (index != -1) { - final existingItem = inbox[index]; - existingItem.lastMessage = lastMessage; - existingItem.timestamp = timestamp; - existingItem.lastMessageSender = senderId; - existingItem.lastMessageSyncStatus = syncStatus; - existingItem.lastMessageIsRead = isRead; - - if (incrementUnread) { - existingItem.unreadCount += 1; - } + final item = inbox.removeAt(index); + item.lastMessage = lastMessage; + item.timestamp = timestamp; + item.lastMessageSender = senderId; + item.lastMessageSyncStatus = syncStatus; + item.lastMessageIsRead = isRead; + if (incrementUnread) item.unreadCount += 1; + inbox.insert(0, item); + notifyListeners(); - inbox.removeAt(index); - inbox.insert(0, existingItem); + _saveItem(item); + } - DatabaseHelper.instance.database.then((db) { - db.insert( - 'inbox', - existingItem.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - }); - } else { - unawaited(loadInbox()); - } + /// Our last message in [chatId] was read by the other side. + void markInboxItemAsRead(String chatId) { + final index = inbox.indexWhere((item) => item.id == chatId); + if (index == -1) return; + inbox[index].lastMessageIsRead = true; notifyListeners(); + _saveItem(inbox[index]); } - Future markInboxItemAsRead(String dbTargetChatId) async { - final int inboxIndex = inbox.indexWhere( - (item) => item.id == dbTargetChatId, - ); - if (inboxIndex != -1) { - inbox[inboxIndex].lastMessageIsRead = true; - notifyListeners(); + /// We opened [chatId], so its unread badge goes away. + void clearUnread(String chatId) { + final index = inbox.indexWhere((item) => item.id == chatId); + if (index == -1 || inbox[index].unreadCount == 0) return; + inbox[index].unreadCount = 0; + notifyListeners(); + _saveItem(inbox[index]); + } - try { - final db = await DatabaseHelper.instance.database; - await db.update( - 'inbox', - {'last_message_is_read': 1}, - where: 'id = ?', - whereArgs: [dbTargetChatId], - ); - } catch (e) { - debugPrint("Failed to update inbox read status: $e"); + /// Marks one conversation read on the server and locally. + Future markReadFor(InboxItem item) async { + final ws = WebSocketService(); + try { + if (item.isGroup) { + ws.sendReadReceipt(groupId: item.id); + } else { + await _api.markDirectRead(item.id); + ws.sendReadReceipt(receiverId: item.id); } + clearUnread(item.id); + } catch (e) { + debugPrint("Failed to mark ${item.id} read: $e"); } } + Future markAllRead() async { + final unread = inbox.where((item) => item.unreadCount > 0).toList(); + await Future.wait(unread.map(markReadFor)); + } + + void renameLocal(String chatId, String newTitle) { + final index = inbox.indexWhere((item) => item.id == chatId); + if (index == -1) return; + inbox[index] = inbox[index].copyWith(title: newTitle); + notifyListeners(); + _saveItem(inbox[index]); + } + + Future removeChat(String chatId) async { + inbox.removeWhere((item) => item.id == chatId); + notifyListeners(); + await DatabaseHelper.instance.deleteChat(chatId); + } + + void _saveItem(InboxItem item) { + DatabaseHelper.instance.database.then((db) { + db.insert( + 'inbox', + item.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }); + } + Future _backgroundSyncChatHistoryToDb( String chatId, bool isGroup, ) async { + // Only plaintext (group) content can be cached without touching the + // Signal ratchet; direct messages are decrypted when the chat is opened. + if (!isGroup) return; try { - final res = await _api.getChatHistory(chatId, isGroup: isGroup); - final targetList = _parseResponse(res.data, ['messages']); - final loadedMessages = targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - - if (loadedMessages.isEmpty) return; - + final res = await _api.getChatHistory(chatId, isGroup: true); final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - if (msg.content.contains('ciphertext') || msg.content.contains('πŸ”’')) { - continue; - } + final batch = db.batch(); + for (final raw in ApiService.dataList(res.data)) { + final json = Map.from(raw); + final envelope = MessageEnvelope.tryParse(json['content']?.toString()); + if (envelope != null && !envelope.isPlaintext) continue; batch.insert('messages', { - 'id': msg.id, + 'id': json['id'], 'chat_id': chatId, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, + 'sender_id': json['sender_id'], + 'content': envelope?.body ?? json['content'], + 'created_at': DateTime.parse( + json['created_at'], + ).millisecondsSinceEpoch, + 'is_read': 0, + 'reply_to_id': json['reply_to_message_id'], 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); + 'edited_at': json['edited_at'] != null + ? DateTime.parse(json['edited_at']).millisecondsSinceEpoch + : null, + }, conflictAlgorithm: ConflictAlgorithm.ignore); } await batch.commit(noResult: true); } catch (e) { @@ -217,11 +254,12 @@ class InboxController extends ChangeNotifier { } } - Future _saveInboxToDb(List items) async { + Future _replaceInboxInDb(List items) async { try { final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var item in items) { + final batch = db.batch(); + batch.delete('inbox'); + for (final item in items) { batch.insert( 'inbox', item.toMap(), @@ -229,35 +267,8 @@ class InboxController extends ChangeNotifier { ); } await batch.commit(noResult: true); - } catch (dbError) { - debugPrint("Failed to save fresh inbox to DB: $dbError"); - } - } - - 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 || - old.lastMessageSyncStatus != current.lastMessageSyncStatus) { - return true; - } - } - return false; - } - - 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']; + } catch (e) { + debugPrint("Failed to save inbox to DB: $e"); } - return []; } } diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index fa53144..1d96c5f 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -8,18 +8,23 @@ class Env { static String host = defaultHost; static String port = defaultPort; + static bool get _isSecure => + port == "443" || host.contains("commandlinecoding.in"); + static String get httpBaseUrl { - final protocol = (port == "443" || host.contains("commandlinecoding.in")) ? "https" : "http"; + final protocol = _isSecure ? "https" : "http"; final portSuffix = (port == "80" || port == "443") ? "" : ":$port"; return "$protocol://$host$portSuffix/api"; } static String get wsBaseUrl { - final protocol = (port == "443" || host.contains("commandlinecoding.in")) ? "wss" : "ws"; + final protocol = _isSecure ? "wss" : "ws"; final portSuffix = (port == "80" || port == "443") ? "" : ":$port"; return "$protocol://$host$portSuffix/api/ws"; } + static bool get isDefaultServer => host == defaultHost && port == defaultPort; + static Future init() async { const storage = FlutterSecureStorage(); host = await storage.read(key: "custom_host") ?? defaultHost; @@ -34,4 +39,26 @@ class Env { await storage.write(key: "custom_host", value: host); await storage.write(key: "custom_port", value: port); } -} \ No newline at end of file +} + +/// Limits enforced by the Elephant server (see docs/API_CONTRACTS.md). +class ServerLimits { + /// The server closes any WebSocket whose inbound frame exceeds 4096 bytes. + static const int maxWsFrameBytes = 4096; + + /// Plaintext budget per message. Signal ciphertext, base64 and the JSON + /// envelope grow a direct message ~1.5x on the wire; 2400 bytes encrypts to + /// ~3.6 KB (checked by test/frame_budget_test.dart). + static const int maxDirectMessageBytes = 2400; + static const int maxGroupMessageBytes = 3200; + + /// Messages can only be edited within this window of being sent. + static const Duration editWindow = Duration(minutes: 15); + + static const int historyPageSize = 50; + static const int searchPageSize = 20; + + static const int usernameMin = 3; + static const int usernameMax = 25; + static const int passwordMin = 8; +} diff --git a/mobile/lib/core/message_envelope.dart b/mobile/lib/core/message_envelope.dart new file mode 100644 index 0000000..cbe610a --- /dev/null +++ b/mobile/lib/core/message_envelope.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +/// Wire format of the `content` field the server relays opaquely: +/// `{"type": , "ciphertext": ""}`. +/// +/// `type == 0` carries plaintext (used for group messages, which are not +/// end-to-end encrypted yet). Any other type is a libsignal +/// `CiphertextMessage` type for direct messages. +class MessageEnvelope { + static const int plaintextType = 0; + + /// Prefix of local placeholders shown when content can't be decrypted. + static const String lockedPrefix = 'πŸ”’ '; + + final int type; + final String body; + + const MessageEnvelope(this.type, this.body); + + bool get isPlaintext => type == plaintextType; + + String encode() => jsonEncode({'type': type, 'ciphertext': body}); + + static String plain(String text) => + MessageEnvelope(plaintextType, text).encode(); + + static MessageEnvelope? tryParse(String? raw) { + if (raw == null) return null; + final trimmed = raw.trim(); + if (!trimmed.startsWith('{') || !trimmed.contains('ciphertext')) { + return null; + } + try { + final decoded = jsonDecode(trimmed); + if (decoded is! Map) return null; + final type = decoded['type']; + final body = decoded['ciphertext']; + if (type is! int || body is! String) return null; + return MessageEnvelope(type, body); + } catch (_) { + return null; + } + } + + /// True when [content] is still encrypted or is an undecryptable placeholder, + /// i.e. it must not be cached or shown as readable text. + static bool isUnreadable(String content) { + if (content.startsWith(lockedPrefix)) return true; + final envelope = tryParse(content); + return envelope != null && !envelope.isPlaintext; + } + + /// Best-effort readable text without touching Signal state: plaintext + /// envelopes are unwrapped, encrypted ones become [fallback]. + static String preview( + String content, { + String fallback = 'πŸ”’ Encrypted message', + }) { + final envelope = tryParse(content); + if (envelope == null) return content; + return envelope.isPlaintext ? envelope.body : fallback; + } + + static String locked(String reason) => '$lockedPrefix$reason'; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 625d9ce..13e0e63 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:mobile/themes/theme_provider.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'core/constants.dart'; import 'services/auth_service.dart'; @@ -22,46 +23,33 @@ void main() async { await AuthService().initTokens(); final themeProvider = ThemeProvider(); + await themeProvider.load(); - while (!themeProvider.isInitialized) { - await Future.delayed(const Duration(milliseconds: 10)); - } + final authState = AuthState(); + String currentUserId() => authState.currentUser?.id ?? ''; + + final inboxController = InboxController(); + final activeChatController = ActiveChatController() + ..currentUserIdProvider = currentUserId; runApp( MultiProvider( providers: [ ChangeNotifierProvider.value(value: themeProvider), - ChangeNotifierProvider(create: (_) => AuthState()), + ChangeNotifierProvider.value(value: authState), ChangeNotifierProvider(create: (_) => GroupController()), - - ChangeNotifierProvider(create: (_) => InboxController()), - ChangeNotifierProvider(create: (_) => ActiveChatController()), + ChangeNotifierProvider.value(value: inboxController), + ChangeNotifierProvider.value(value: activeChatController), ChangeNotifierProvider(create: (_) => ChatSearchController()), ChangeNotifierProvider(create: (_) => GroupDetailsController()), - - ChangeNotifierProxyProvider3< - AuthState, - InboxController, - ActiveChatController, - ChatConnectionController - >( - create: (context) => ChatConnectionController( + ChangeNotifierProvider( + create: (_) => ChatConnectionController( eventHandler: ChatEventHandler( - inboxController: context.read(), - activeChatController: context.read(), - currentUserId: context.read().currentUser?.id ?? '', + inboxController: inboxController, + activeChatController: activeChatController, + currentUserIdProvider: currentUserId, ), ), - update: (context, auth, inbox, activeChat, previousConnection) { - return previousConnection ?? - ChatConnectionController( - eventHandler: ChatEventHandler( - inboxController: inbox, - activeChatController: activeChat, - currentUserId: auth.currentUser?.id ?? '', - ), - ); - }, ), ], child: const MyApp(), @@ -78,6 +66,7 @@ class MyApp extends StatelessWidget { title: 'Elephant', debugShowCheckedModeBanner: false, theme: context.watch().themeData, + themeAnimationDuration: const Duration(milliseconds: 350), home: const SessionGateway(), ); } @@ -90,71 +79,70 @@ class SessionGateway extends StatefulWidget { State createState() => _SessionGatewayState(); } -class _SessionGatewayState extends State - with WidgetsBindingObserver { +class _SessionGatewayState extends State { bool _hasCheckedAutoLogin = false; String? _lastInitializedToken; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _performInitialAutoLoginCheck(); } - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - void _initChatSession() { context.read().connectWebSocket(); context.read().loadInbox(); } void _performInitialAutoLoginCheck() async { - final auth = context.read(); - final token = await auth.checkAutoLogin(); + final token = await context.read().checkAutoLogin(); if (token != null && mounted) { _lastInitializedToken = token; _initChatSession(); } - if (mounted) { - setState(() { - _hasCheckedAutoLogin = true; - }); - } + if (mounted) setState(() => _hasCheckedAutoLogin = true); } @override Widget build(BuildContext context) { - if (!_hasCheckedAutoLogin) { - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - body: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } + if (!_hasCheckedAutoLogin) return const _Splash(); final authState = context.watch(); + final token = authState.token; - if (authState.token != null && authState.token != _lastInitializedToken) { - _lastInitializedToken = authState.token; - WidgetsBinding.instance.addPostFrameCallback((_) { - _initChatSession(); - }); + if (token != null && _lastInitializedToken == null) { + _lastInitializedToken = token; + WidgetsBinding.instance.addPostFrameCallback((_) => _initChatSession()); + } else if (token == null) { + _lastInitializedToken = null; } - if (authState.token != null) { - return const HomePage(); - } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: token != null + ? const HomePage(key: ValueKey('home')) + : const LoginPage(key: ValueKey('login')), + ); + } +} + +class _Splash extends StatelessWidget { + const _Splash(); - return const LoginPage(); + @override + Widget build(BuildContext context) { + return Scaffold( + body: AmbientBackground( + child: Center( + child: Image.asset( + 'assets/launcher/elephant.png', + width: 96, + height: 96, + ), + ), + ), + ); } } diff --git a/mobile/lib/models/conversation.dart b/mobile/lib/models/conversation.dart deleted file mode 100644 index c311dc4..0000000 --- a/mobile/lib/models/conversation.dart +++ /dev/null @@ -1,46 +0,0 @@ -class Conversation { - final String chatUserId; - final String username; - final String displayName; - final String lastMessage; - final DateTime lastMessageTime; - final String senderId; - final bool isRead; - final int unreadCount; - final String? lastMessageSenderId; - - Conversation({ - required this.chatUserId, - required this.username, - required this.displayName, - required this.lastMessage, - required this.lastMessageTime, - required this.senderId, - required this.isRead, - required this.unreadCount, this.lastMessageSenderId, - }); - - factory Conversation.fromJson(Map json) { - final String extractedId = - json['chat_user_id'] ?? - json['user_id'] ?? - json['id'] ?? - json['partner_id'] ?? - ''; - - return Conversation( - chatUserId: extractedId, - username: - json['name'] ?? json['username'] ?? json['user_name'] ?? 'Unknown', - displayName: json['display_name'] ?? '', - lastMessage: json['last_message'] ?? '', - lastMessageTime: DateTime.parse( - json['last_message_time'] ?? DateTime.now().toIso8601String(), - ).toLocal(), - senderId: json['sender_id'] ?? '', - isRead: json['is_read'] ?? false, - unreadCount: json['unread_count'] ?? 0, - lastMessageSenderId: json['last_message_sender_id'] ?? json['lastMessageSender'], - ); - } -} diff --git a/mobile/lib/models/group.dart b/mobile/lib/models/group.dart index 3b40d8c..bf08148 100644 --- a/mobile/lib/models/group.dart +++ b/mobile/lib/models/group.dart @@ -1,51 +1,58 @@ +/// A group as returned by `GET /api/groups` and `GET /api/groups/{id}`. class Group { final String id; 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'; + return Group( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + createdBy: json['created_by']?.toString() ?? '', + createdAt: + DateTime.tryParse(json['created_at']?.toString() ?? '')?.toLocal() ?? + DateTime.now(), + ); + } +} - DateTime msgTime = DateTime.now(); - if (json['last_message_time'] != null) { - msgTime = DateTime.parse(json['last_message_time']).toLocal(); - } else if (json['created_at'] != null) { - msgTime = DateTime.parse(json['created_at']); - } +/// A row of `GET /api/groups/{id}/members`. +class GroupMember { + final String userId; + final String username; + final String displayName; + final String role; + final DateTime? joinedAt; - final dynamic lastMsgData = json['last_message']; - if (lastMsgData != null) { - if (lastMsgData is Map) { - content = lastMsgData['content'] ?? content; - } else if (lastMsgData is String) { - content = lastMsgData; - } - } + GroupMember({ + required this.userId, + required this.username, + required this.displayName, + required this.role, + this.joinedAt, + }); - return Group( - id: json['id'] ?? '', - name: json['name'] ?? '', - createdBy: json['created_by'] ?? '', - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - lastMessageSender: json['sender_id'], - lastMessage: content, - lastMessageAt: msgTime, + bool get isAdmin => role == 'admin'; + + factory GroupMember.fromJson(Map json) { + final username = json['username']?.toString() ?? ''; + final displayName = json['display_name']?.toString() ?? ''; + return GroupMember( + userId: json['user_id'].toString(), + username: username, + displayName: displayName.isNotEmpty ? displayName : username, + role: json['role']?.toString() ?? 'member', + joinedAt: DateTime.tryParse( + json['joined_at']?.toString() ?? '', + )?.toLocal(), ); } } diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart index cc547f4..116c09e 100644 --- a/mobile/lib/models/inbox_item.dart +++ b/mobile/lib/models/inbox_item.dart @@ -1,4 +1,3 @@ -import 'package:mobile/models/conversation.dart'; import 'package:mobile/models/group.dart'; class InboxItem { @@ -51,37 +50,50 @@ class InboxItem { isRead: isRead ?? this.isRead, unreadCount: unreadCount ?? this.unreadCount, lastMessageSender: lastMessageSender ?? this.lastMessageSender, - lastMessageSyncStatus: lastMessageSyncStatus ?? this.lastMessageSyncStatus, + lastMessageSyncStatus: + lastMessageSyncStatus ?? this.lastMessageSyncStatus, lastMessageIsRead: lastMessageIsRead ?? this.lastMessageIsRead, ); } - factory InboxItem.fromConversation(Conversation conv) { + /// Builds an item from one entry of `GET /api/messages/conversations`. + /// [lastMessage] is the already-decoded preview text. + factory InboxItem.fromConversationJson( + Map json, { + required String lastMessage, + }) { + final bool isGroup = json['type'] == 'group'; + final String name = json['name']?.toString() ?? ''; + final String displayName = json['display_name']?.toString() ?? ''; + final bool isRead = json['is_read'] == true; + return InboxItem( - id: conv.chatUserId, - username: conv.username, - title: conv.displayName, - lastMessage: conv.lastMessage, - timestamp: conv.lastMessageTime, - isGroup: false, - isRead: conv.isRead, - unreadCount: conv.unreadCount, - lastMessageSender: conv.lastMessageSenderId, + id: json['id']?.toString() ?? '', + title: isGroup ? name : (displayName.isNotEmpty ? displayName : name), + username: isGroup ? null : name, + lastMessage: lastMessage, + timestamp: + DateTime.tryParse( + json['last_message_time']?.toString() ?? '', + )?.toLocal() ?? + DateTime.now(), + isGroup: isGroup, + isRead: isRead, + unreadCount: (json['unread_count'] as num?)?.toInt() ?? 0, + lastMessageSender: json['sender_id']?.toString(), lastMessageSyncStatus: 'synced', - lastMessageIsRead: conv.isRead, + lastMessageIsRead: isRead, ); } + /// A group that has no messages yet (absent from the conversations list). factory InboxItem.fromGroup(Group group) { return InboxItem( id: group.id, title: group.name, - timestamp: group.lastMessageAt, + lastMessage: '', + timestamp: group.createdAt, isGroup: true, - isRead: true, - unreadCount: 0, - lastMessageSender: group.lastMessageSender, - lastMessage: group.lastMessage, lastMessageSyncStatus: 'synced', lastMessageIsRead: true, ); @@ -114,10 +126,11 @@ class InboxItem { isRead: (map['is_read'] as int) == 1, unreadCount: (map['unread_count'] as int?) ?? 0, lastMessageSender: map['last_message_sender'] as String?, - lastMessageSyncStatus: map['last_message_sync_status'] as String? ?? 'synced', - lastMessageIsRead: map['last_message_is_read'] != null - ? (map['last_message_is_read'] as int) == 1 + lastMessageSyncStatus: + map['last_message_sync_status'] as String? ?? 'synced', + lastMessageIsRead: map['last_message_is_read'] != null + ? (map['last_message_is_read'] as int) == 1 : false, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index 9be807d..852007f 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -1,3 +1,5 @@ +import '../core/constants.dart'; + class QuotedMessage { final String id; final String senderId; @@ -15,12 +17,23 @@ class QuotedMessage { return QuotedMessage( id: json['id']?.toString() ?? '', senderId: json['sender_id']?.toString() ?? '', - senderDisplayName: json['sender_display_name']?.toString() ?? - json['sender_name']?.toString() ?? '', + senderDisplayName: + json['sender_display_name']?.toString() ?? + json['sender_name']?.toString() ?? + '', content: json['content']?.toString() ?? '', ); } + QuotedMessage copyWith({String? content, String? senderDisplayName}) { + return QuotedMessage( + id: id, + senderId: senderId, + senderDisplayName: senderDisplayName ?? this.senderDisplayName, + content: content ?? this.content, + ); + } + Map toJson() => { 'id': id, 'sender_id': senderId, @@ -31,6 +44,8 @@ class QuotedMessage { class Message { final String id; + + /// The author's user ID, or `'me'` for messages written on this device. final String senderId; final String receiverId; final String content; @@ -39,6 +54,7 @@ class Message { final String? replyToMessageId; final QuotedMessage? quotedMessage; final String syncStatus; + final DateTime? editedAt; Message({ required this.id, @@ -50,57 +66,92 @@ class Message { this.replyToMessageId, this.quotedMessage, this.syncStatus = 'synced', + this.editedAt, }); - factory Message.fromJson(Map json) { - DateTime parsedDate = DateTime.now(); - - var dateData = json['created_at'] ?? json['timestamp']; - - if (dateData != null) { - if (dateData is int) { - parsedDate = DateTime.fromMillisecondsSinceEpoch(dateData); - } else { - String dateString = dateData.toString(); - if (dateString.startsWith('0001-01-01')) { - parsedDate = DateTime.now(); - } else { - try { - parsedDate = DateTime.parse(dateString).toLocal(); - } catch (e) { - parsedDate = DateTime.now(); - } - } - } - } + bool get isEdited => editedAt != null; + bool get isPending => syncStatus == 'pending'; + + bool isFrom(String? currentUserId) { + final sender = senderId.trim().toLowerCase(); + return sender == 'me' || + (currentUserId != null && sender == currentUserId.trim().toLowerCase()); + } + + bool canEdit(String? currentUserId) { + return isFrom(currentUserId) && + !isPending && + DateTime.now().difference(createdAt) < ServerLimits.editWindow; + } + + static DateTime? _parseDate(dynamic value) { + if (value == null) return null; + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + final text = value.toString(); + if (text.isEmpty || text.startsWith('0001-01-01')) return null; + return DateTime.tryParse(text)?.toLocal(); + } + factory Message.fromJson(Map json) { return Message( - id: json['client_message_id']?.toString() ?? - json['message_id']?.toString() ?? - json['id']?.toString() ?? '', + id: + json['client_message_id']?.toString() ?? + json['message_id']?.toString() ?? + json['id']?.toString() ?? + '', senderId: json['sender_id']?.toString() ?? '', receiverId: json['receiver_id']?.toString() ?? '', content: json['content']?.toString() ?? '', - createdAt: parsedDate, + createdAt: + _parseDate(json['created_at'] ?? json['timestamp']) ?? DateTime.now(), isRead: json['is_read'] == 1 || json['is_read'] == true, - replyToMessageId: json['reply_to_message_id']?.toString(), - quotedMessage: json['quoted_message'] != null - ? QuotedMessage.fromJson(json['quoted_message']) + replyToMessageId: _nonEmpty( + json['reply_to_message_id'] ?? json['reply_to_id'], + ), + quotedMessage: json['quoted_message'] is Map + ? QuotedMessage.fromJson( + Map.from(json['quoted_message']), + ) : null, syncStatus: json['sync_status']?.toString() ?? 'synced', + editedAt: _parseDate(json['edited_at']), + ); + } + + /// Builds a message from a row of the local `messages` table. + factory Message.fromRow(Map row) { + return Message( + id: row['id'] as String, + senderId: row['sender_id'] as String, + receiverId: row['chat_id'] as String? ?? '', + content: row['content'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int), + isRead: (row['is_read'] as int? ?? 0) == 1, + replyToMessageId: row['reply_to_id'] as String?, + syncStatus: row['sync_status'] as String? ?? 'synced', + editedAt: row['edited_at'] != null + ? DateTime.fromMillisecondsSinceEpoch(row['edited_at'] as int) + : null, ); } - Map toMap() { + static String? _nonEmpty(dynamic value) { + final text = value?.toString(); + return (text == null || text.isEmpty) ? null : text; + } + + /// Row for the local `messages` table. + Map toRow(String chatId) { return { 'id': id, + 'chat_id': chatId, 'sender_id': senderId, - 'receiver_id': receiverId, 'content': content, 'created_at': createdAt.millisecondsSinceEpoch, 'is_read': isRead ? 1 : 0, 'reply_to_id': replyToMessageId, 'sync_status': syncStatus, + 'edited_at': editedAt?.millisecondsSinceEpoch, }; } @@ -109,6 +160,8 @@ class Message { String? id, String? syncStatus, String? content, + DateTime? editedAt, + QuotedMessage? quotedMessage, }) { return Message( id: id ?? this.id, @@ -118,8 +171,9 @@ class Message { createdAt: createdAt, isRead: isRead ?? this.isRead, replyToMessageId: replyToMessageId, - quotedMessage: quotedMessage, + quotedMessage: quotedMessage ?? this.quotedMessage, syncStatus: syncStatus ?? this.syncStatus, + editedAt: editedAt ?? this.editedAt, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart index b3d61fa..9eb75d5 100644 --- a/mobile/lib/models/user.dart +++ b/mobile/lib/models/user.dart @@ -1,25 +1,31 @@ +/// A user as returned by `/api/users/*` and the auth endpoints. class UserModel { final String id; final String username; final String displayName; final String? email; - final String? avatarUrl; + final DateTime? createdAt; UserModel({ required this.id, required this.username, required this.displayName, this.email, - this.avatarUrl, + this.createdAt, }); factory UserModel.fromJson(Map json) { + final username = json['username']?.toString() ?? ''; + final displayName = json['display_name']?.toString() ?? ''; + final email = json['email']?.toString(); return UserModel( id: json['id']?.toString() ?? '', - username: json['username'] ?? '', - displayName: json['display_name'] ?? json['displayName'] ?? 'User', - email: json['email'], - avatarUrl: json['avatar_url'] ?? json['avatarUrl'], + username: username, + displayName: displayName.isNotEmpty ? displayName : username, + email: (email == null || email.isEmpty) ? null : email, + createdAt: DateTime.tryParse( + json['created_at']?.toString() ?? '', + )?.toLocal(), ); } } diff --git a/mobile/lib/models/ws.dart b/mobile/lib/models/ws.dart deleted file mode 100644 index 9c485d6..0000000 --- a/mobile/lib/models/ws.dart +++ /dev/null @@ -1,41 +0,0 @@ -class WSMessage { - final String type; - final String senderId; - final String receiverId; - final String groupId; - final String content; - final String messageId; - final DateTime? timestamp; - - WSMessage({ - required this.type, - this.senderId = '', - this.receiverId = '', - this.groupId = '', - this.content = '', - this.messageId = '', - this.timestamp, - }); - - factory WSMessage.fromJson(Map json) { - return WSMessage( - type: json['type'] ?? '', - senderId: json['sender_id'] ?? '', - receiverId: json['receiver_id'] ?? '', - groupId: json['group_id'] ?? '', - content: json['content'] ?? '', - messageId: json['message_id'] ?? '', - timestamp: json['timestamp'] != null ? DateTime.parse(json['timestamp']) : null, - ); - } - - Map toJson() => { - 'type': type, - if (senderId.isNotEmpty) 'sender_id': senderId, - if (receiverId.isNotEmpty) 'receiver_id': receiverId, - if (groupId.isNotEmpty) 'group_id': groupId, - if (content.isNotEmpty) 'content': content, - if (messageId.isNotEmpty) 'message_id': messageId, - if (timestamp != null) 'timestamp': timestamp!.toIso8601String(), - }; -} \ No newline at end of file diff --git a/mobile/lib/pages/auth/login_page.dart b/mobile/lib/pages/auth/login_page.dart index 50d3beb..0abad33 100644 --- a/mobile/lib/pages/auth/login_page.dart +++ b/mobile/lib/pages/auth/login_page.dart @@ -1,8 +1,10 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:mobile/pages/settings/appearance.dart'; +import 'package:mobile/pages/settings/server_settings.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; import '../../core/constants.dart'; import '../../controllers/auth_state.dart'; @@ -14,1027 +16,525 @@ class LoginPage extends StatefulWidget { State createState() => _LoginPageState(); } -class _LoginPageState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; +class _LoginPageState extends State { final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); final _displayNameController = TextEditingController(); final _passwordController = TextEditingController(); - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - } + bool _isRegister = false; + bool _obscurePassword = true; @override void dispose() { - _tabController.dispose(); _usernameController.dispose(); _displayNameController.dispose(); _passwordController.dispose(); super.dispose(); } - void _openServerConfigDialog() { - final hostController = TextEditingController(text: Env.host); - final portController = TextEditingController(text: Env.port); - final colorScheme = Theme.of(context).colorScheme; + void _setMode(bool register) { + if (_isRegister == register) return; + setState(() => _isRegister = register); + _formKey.currentState?.reset(); + context.read().clearError(); + } - showDialog( - context: context, - builder: (dialogContext) => BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: AlertDialog( - scrollable: true, - backgroundColor: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.8), - title: Row( - children: [ - Icon(Icons.dns, color: colorScheme.primary), - const SizedBox(width: 8), - const Text( - "Server Settings", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + + final auth = context.read(); + if (_isRegister) { + await auth.handleRegister( + _usernameController.text, + _displayNameController.text, + _passwordController.text, + ); + } else { + await auth.handleLogin( + _usernameController.text, + _passwordController.text, + ); + } + } + + Future _openServerSettings() async { + await showServerSettingsSheet(context); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + + return Scaffold( + resizeToAvoidBottomInset: true, + body: AmbientBackground( + animate: true, + intensity: 1.2, + child: SafeArea( + child: Stack( children: [ - Text( - "Enter your decentralized peer node coordinates:", - style: TextStyle( - fontSize: 13, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - Text( - "Host", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - TextField( - controller: hostController, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 14, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: colorScheme.primary, - width: 1.5, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, + Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Insets.xl, + 72, + Insets.xl, + Insets.xl, ), - hintText: Env.defaultHost, - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: + [ + _Brand(isRegister: _isRegister), + const SizedBox(height: Insets.xxl), + GlassSurface( + strong: true, + borderRadius: BorderRadius.circular(Radii.xl), + padding: const EdgeInsets.all(Insets.xl), + shadows: [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.08, + ), + blurRadius: 40, + offset: const Offset(0, 20), + ), + ], + child: _buildForm(auth), + ), + const SizedBox(height: Insets.xl), + _buildOAuth(auth), + const SizedBox(height: Insets.xl), + _ServerChip(onTap: _openServerSettings), + ] + .animate(interval: 70.ms) + .fadeIn(duration: 450.ms) + .slideY(begin: 0.08, curve: Curves.easeOutCubic), + ), ), ), ), - const SizedBox(height: 14), - Text( - "Port", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - TextField( - controller: portController, - keyboardType: TextInputType.number, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 14, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: colorScheme.primary, - width: 1.5, + Positioned( + top: Insets.sm, + right: Insets.lg, + child: GlassSurface( + borderRadius: BorderRadius.circular(99), + child: IconButton( + tooltip: 'Appearance', + icon: Icon( + Icons.palette_outlined, + color: context.colors.primary, + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AppearanceSettings(), + ), ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - hintText: Env.defaultPort, - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, ), ), ), ], ), - 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", - ), + Widget _buildForm(AuthState auth) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ModeSwitch(isRegister: _isRegister, onChanged: _setMode), + const SizedBox(height: Insets.xl), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: auth.errorMessage == null + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.only(bottom: Insets.lg), + child: _ErrorBanner(message: auth.errorMessage!), ), - ); - }, + ), + TextFormField( + controller: _usernameController, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + inputFormatters: [ + FilteringTextInputFormatter.allow( + _isRegister ? RegExp(r'[a-zA-Z0-9]') : RegExp(r'[a-zA-Z0-9.]'), + ), + ], + decoration: InputDecoration( + labelText: 'Username', + hintText: _isRegister ? 'letters and numbers' : 'e.g. alex.4821', + prefixIcon: const Icon(Icons.alternate_email_rounded), + helperText: _isRegister + ? 'A 4-digit tag is added for you, e.g. alex.4821' + : null, + helperMaxLines: 2, + ), + validator: (v) => + AuthState.validateUsername(v, isRegister: _isRegister), + ), + AnimatedSize( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + child: _isRegister + ? Padding( + padding: const EdgeInsets.only(top: Insets.lg), + child: TextFormField( + controller: _displayNameController, + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.words, + maxLength: 100, + decoration: const InputDecoration( + labelText: 'Display name', + hintText: 'How others see you', + prefixIcon: Icon(Icons.badge_outlined), + counterText: '', + ), + validator: AuthState.validateDisplayName, + ), + ) + : const SizedBox(width: double.infinity), + ), + const SizedBox(height: Insets.lg), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline_rounded), + helperText: _isRegister + ? 'At least ${ServerLimits.passwordMin} characters' + : null, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + ), + validator: (v) => + AuthState.validatePassword(v, isRegister: _isRegister), + ), + const SizedBox(height: Insets.xl), + GradientButton( + label: _isRegister ? 'Create account' : 'Sign in', + isLoading: auth.isLoading, + onPressed: _submit, + ), + ], + ), + ); + } + + Widget _buildOAuth(AuthState auth) { + return Column( + children: [ + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.md), child: Text( - "Reset Default", - style: TextStyle(color: colorScheme.onSurfaceVariant), + 'or continue with', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primary, + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: Insets.lg), + Row( + children: [ + Expanded( + child: _OAuthButton( + label: 'Google', + glyph: 'G', + onTap: auth.isLoading + ? null + : () => auth.handleOAuthLogin('google'), ), - onPressed: () async { - final newHost = hostController.text.trim(); - final newPort = portController.text.trim(); + ), + const SizedBox(width: Insets.md), + Expanded( + child: _OAuthButton( + label: 'GitHub', + icon: Icons.code_rounded, + onTap: auth.isLoading + ? null + : () => auth.handleOAuthLogin('github'), + ), + ), + ], + ), + ], + ); + } +} - final navigator = Navigator.of(dialogContext); - final scaffoldMessenger = ScaffoldMessenger.of(context); +class _Brand extends StatelessWidget { + final bool isRegister; - await Env.updateConfig(newHost, newPort); + const _Brand({required this.isRegister}); - navigator.pop(); - setState(() {}); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Target base set to: ${Env.httpBaseUrl}"), - ), - ); - }, - child: const Text("Save", style: TextStyle(color: Colors.white)), + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 84, + height: 84, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.glass.glassStrongFill, + border: Border.all(color: context.glass.glassBorder), + boxShadow: [ + BoxShadow( + color: context.colors.primary.withValues(alpha: 0.35), + blurRadius: 40, + offset: const Offset(0, 12), + ), + ], + ), + child: Image.asset('assets/launcher/elephant.png'), + ), + const SizedBox(height: Insets.lg), + Text('Elephant', style: context.text.headlineMedium), + const SizedBox(height: Insets.xs), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: Text( + isRegister + ? 'Create your account. Your keys stay on this device.' + : 'Private conversations, end-to-end encrypted.', + key: ValueKey(isRegister), + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), - ], + ), ), - ), + ], ); } +} - void _submit() { - if (_formKey.currentState?.validate() ?? false) { - final auth = context.read(); - if (_tabController.index == 0) { - auth.handleLogin( - _usernameController.text.trim(), - _passwordController.text.trim(), - ); - } else { - auth.handleRegister( - _usernameController.text.trim(), - _displayNameController.text.trim(), - _passwordController.text.trim(), - ); - } - } +class _ModeSwitch extends StatelessWidget { + final bool isRegister; + final ValueChanged onChanged; + + const _ModeSwitch({required this.isRegister, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(99), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + alignment: isRegister + ? Alignment.centerRight + : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: AccentGradientBox( + borderRadius: BorderRadius.circular(99), + child: const SizedBox.expand(), + ), + ), + ), + Row( + children: [ + _segment(context, 'Sign in', !isRegister, () => onChanged(false)), + _segment(context, 'Register', isRegister, () => onChanged(true)), + ], + ), + ], + ), + ); } - Widget _buildSocialButton({ - required String label, - required IconData icon, - required VoidCallback onPressed, - }) { - final colorScheme = Theme.of(context).colorScheme; + Widget _segment( + BuildContext context, + String label, + bool selected, + VoidCallback onTap, + ) { return Expanded( - child: OutlinedButton.icon( - onPressed: onPressed, - icon: Icon(icon, color: colorScheme.onSurface, size: 18), - label: Text( - label, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: selected + ? context.glass.onAccent + : context.colors.onSurfaceVariant, + ), + child: Text(label), ), ), - style: OutlinedButton.styleFrom( - side: BorderSide(color: colorScheme.outlineVariant), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), ), ); } +} + +class _ErrorBanner extends StatelessWidget { + final String message; + + const _ErrorBanner({required this.message}); @override Widget build(BuildContext context) { - final authState = context.watch(); - final colorScheme = Theme.of(context).colorScheme; - final theme = Theme.of(context); - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - body: Stack( + final error = context.colors.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: error.withValues(alpha: 0.3)), + ), + child: Row( children: [ - Positioned.fill( - child: Stack( + Icon(Icons.error_outline_rounded, color: error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + color: error, + fontWeight: FontWeight.w600, + fontSize: 13.5, + ), + ), + ), + ], + ), + ); + } +} + +class _OAuthButton extends StatelessWidget { + final String label; + final String? glyph; + final IconData? icon; + final VoidCallback? onTap; + + const _OAuthButton({required this.label, this.glyph, this.icon, this.onTap}); + + @override + Widget build(BuildContext context) { + return GlassSurface( + borderRadius: BorderRadius.circular(Radii.md), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Positioned( - top: -150, - left: -150, - child: - Container( - width: 500, - height: 500, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - colorScheme.primary.withValues(alpha: 0.45), - colorScheme.primary.withValues(alpha: 0.0), - ], - ), - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .move( - duration: 6.seconds, - curve: Curves.easeInOutSine, - end: const Offset(300, 250), - ) - .scale( - duration: 4.seconds, - curve: Curves.easeInOutSine, - end: const Offset(1.4, 1.4), - ), - ), - Positioned( - bottom: -200, - right: -100, - child: - Container( - width: 600, - height: 600, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - colorScheme.tertiary.withValues(alpha: 0.35), - colorScheme.tertiary.withValues(alpha: 0.0), - ], - ), - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .move( - duration: 8.seconds, - curve: Curves.easeInOutSine, - end: const Offset(-400, -300), - ) - .scale( - duration: 6.seconds, - curve: Curves.easeInOutSine, - end: const Offset(1.2, 0.8), - ), - ), - Positioned( - top: MediaQuery.of(context).size.height * 0.2, - left: MediaQuery.of(context).size.width * 0.1, - child: - Container( - width: 400, - height: 400, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - colorScheme.secondary.withValues(alpha: 0.30), - colorScheme.secondary.withValues(alpha: 0.0), - ], - ), - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .moveX( - duration: 7.seconds, - curve: Curves.easeInOutSine, - end: MediaQuery.of(context).size.width * 0.5, - ) - .moveY( - duration: 9.seconds, - curve: Curves.easeInOutSine, - end: 300, - ) - .scale( - duration: 5.seconds, - curve: Curves.easeInOutSine, - end: const Offset(1.6, 1.6), - ), + if (glyph != null) + Text( + glyph!, + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 18, + color: context.colors.onSurface, + ), + ) + else + Icon(icon, size: 20, color: context.colors.onSurface), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600), ), ], ), ), - Positioned.fill( - child: RepaintBoundary( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 40, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - theme.colorScheme.surface.withValues(alpha: 0.25), - theme.colorScheme.surface.withValues(alpha: 0.05), - ], - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.20, - ), - width: 1.5, - ), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.shadow.withValues( - alpha: 0.15, - ), - blurRadius: 40, - spreadRadius: -5, - ), - ], - ), - constraints: const BoxConstraints(maxWidth: 420), - padding: const EdgeInsets.all(32), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.chat_bubble_rounded, - color: Color(0xFF0052CC), - size: 28, - ), - const SizedBox(width: 8), - Text( - 'Elephant', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.primary, - letterSpacing: 0.5, - ), - ), - ], - ), - const SizedBox(height: 24), - Column( - key: ValueKey(_tabController.index), - children: [ - Text( - _tabController.index == 0 - ? 'Welcome Back' - : 'Create Account', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - const SizedBox(height: 6), - Text( - _tabController.index == 0 - ? 'Access your secure workspace' - : 'Join the peer mesh platform', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 28), - Container( - height: 52, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(26), - border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.3), - ), - ), - child: Stack( - children: [ - AnimatedAlign( - duration: const Duration( - milliseconds: 350, - ), - curve: Curves.fastOutSlowIn, - alignment: _tabController.index == 0 - ? Alignment.centerLeft - : Alignment.centerRight, - child: FractionallySizedBox( - widthFactor: 0.5, - child: Container( - margin: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 22, - ), - color: theme.colorScheme.primary - .withValues(alpha: 0.2), - border: Border.all( - color: theme.colorScheme.primary - .withValues(alpha: 0.5), - ), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary - .withValues(alpha: 0.4), - blurRadius: 12, - ), - ], - ), - ), - ), - ), - Row( - children: [ - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState( - () => _tabController.index = 0, - ), - child: Center( - child: Text( - 'Sign In', - style: TextStyle( - fontWeight: FontWeight.bold, - color: - _tabController.index == 0 - ? Theme.of( - context, - ).colorScheme.primary - : theme - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ), - ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState( - () => _tabController.index = 1, - ), - child: Center( - child: Text( - 'Register', - style: TextStyle( - fontWeight: FontWeight.bold, - color: - _tabController.index == 1 - ? Theme.of( - context, - ).colorScheme.primary - : theme - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 24), - if (authState.errorMessage != null) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.error, - ), - ), - child: Text( - authState.errorMessage!, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 20), - ], - Text( - "Username", - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: colorScheme.primary, - ), - ), - const SizedBox(height: 6), - TextFormField( - textInputAction: TextInputAction.next, - controller: _usernameController, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 14, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: colorScheme.primary, - width: 1.5, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - hintText: "username", - hintStyle: TextStyle(color: theme.hintColor), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - validator: (val) => - (val == null || val.trim().isEmpty) - ? 'Required field' - : null, - ), - const SizedBox(height: 16), - AnimatedSize( - duration: const Duration(milliseconds: 500), - curve: Curves.fastLinearToSlowEaseIn, - child: _tabController.index == 1 - ? Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Text( - "Display Name", - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - const SizedBox(height: 6), - TextFormField( - textInputAction: - TextInputAction.next, - controller: - _displayNameController, - style: TextStyle( - color: theme - .colorScheme - .onSurfaceVariant, - fontSize: 14, - ), - decoration: InputDecoration( - focusedBorder: - OutlineInputBorder( - borderRadius: - BorderRadius.circular( - 8, - ), - borderSide: BorderSide( - color: colorScheme - .primary, - width: 1.5, - ), - ), - contentPadding: - const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - hintText: "John Doe", - hintStyle: TextStyle( - color: Theme.of( - context, - ).hintColor, - ), - filled: true, - fillColor: theme - .colorScheme - .surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular( - 8, - ), - borderSide: BorderSide.none, - ), - ), - validator: (val) => - (val == null || - val.trim().isEmpty) - ? 'Required field' - : null, - ), - const SizedBox(height: 16), - ], - ) - .animate() - .fade(duration: 400.ms, delay: 150.ms) - .slideX(begin: -0.05) - : const SizedBox.shrink(), - ), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Password", - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - if (_tabController.index == 0) - TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - ), - child: const Text( - "Forgot Password?", - style: TextStyle( - color: Color(0xFF0052CC), - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - TextFormField( - controller: _passwordController, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - obscureText: true, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 14, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: colorScheme.primary, - width: 1.5, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - hintText: "β€’β€’β€’β€’β€’β€’β€’β€’", - hintStyle: TextStyle(color: theme.hintColor), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - validator: (val) => - (val == null || val.trim().length < 6) - ? 'Must be at least 6 characters' - : null, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: authState.isLoading ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimary, - padding: const EdgeInsets.symmetric( - vertical: 14, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 0, - ), - child: authState.isLoading - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Theme.of( - context, - ).colorScheme.onPrimary, - strokeWidth: 2.5, - ), - ) - : Text( - _tabController.index == 0 - ? 'Sign In' - : 'Sign Up', - key: ValueKey( - _tabController.index, - ), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: Divider( - color: Theme.of( - context, - ).colorScheme.outlineVariant, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - ), - child: Text( - "or sign in with", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ), - Expanded( - child: Divider( - color: Theme.of( - context, - ).colorScheme.outlineVariant, - ), - ), - ], - ), - const SizedBox(height: 20), + ), + ), + ); + } +} - Row( - children: [ - _buildSocialButton( - label: "Google", - icon: Icons.g_mobiledata, - onPressed: authState.isLoading - ? () {} - : () => context - .read() - .handleOAuthLogin("google"), - ), - const SizedBox(width: 12), - _buildSocialButton( - label: "GitHub", - icon: Icons.code, - onPressed: authState.isLoading - ? () {} - : () => context - .read() - .handleOAuthLogin("github"), - ), - ], - ), - const SizedBox(height: 28), - Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.tertiary, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.check_circle, - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, - size: 14, - ), - const SizedBox(width: 6), - Text( - "End-to-end encrypted session", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 20), - Center( - child: TextButton.icon( - onPressed: _openServerConfigDialog, - icon: Icon( - Icons.dns_outlined, - size: 14, - color: Theme.of( - context, - ).colorScheme.primary, - ), - label: Text( - "Connected to: ${Env.host}:${Env.port} βš™οΈ", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ].animate(interval: 100.ms).fade(duration: 400.ms).slideY(begin: 0.1, curve: Curves.easeOutQuad), - ), - ), - ), +class _ServerChip extends StatelessWidget { + final VoidCallback onTap; + + const _ServerChip({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Center( + child: GlassSurface( + borderRadius: BorderRadius.circular(99), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.dns_outlined, + size: 16, + color: context.colors.primary, + ), + const SizedBox(width: 8), + Text( + Env.isDefaultServer + ? 'Elephant Cloud' + : '${Env.host}:${Env.port}', + style: context.text.bodySmall?.copyWith( + fontWeight: FontWeight.w700, ), - ).animate().fade(duration: 600.ms).slideY(begin: 0.1, curve: Curves.easeOutExpo), - ), + ), + const SizedBox(width: 6), + Icon( + Icons.tune_rounded, + size: 15, + color: context.colors.onSurfaceVariant, + ), + ], ), ), ), - Positioned( - top: MediaQuery.of(context).padding.top + 16, - right: 24, - child: RepaintBoundary( - child: - ClipRRect( - borderRadius: BorderRadius.circular(30), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface.withValues( - alpha: 0.15, - ), - shape: BoxShape.circle, - border: Border.all( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.20, - ), - width: 1.5, - ), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.shadow.withValues( - alpha: 0.1, - ), - blurRadius: 20, - ), - ], - ), - child: IconButton( - padding: const EdgeInsets.all(12), - icon: Icon( - Icons.color_lens_outlined, - color: theme.colorScheme.primary, - size: 22, - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AppearanceSettings(), - ), - ); - }, - tooltip: 'Appearance Settings', - ), - ), - ), - ) - .animate() - .fade(delay: 400.ms, duration: 500.ms) - .scale(curve: Curves.easeOutBack), - ), - ), - ], + ), ), ); } diff --git a/mobile/lib/pages/chat/chat_details_page.dart b/mobile/lib/pages/chat/chat_details_page.dart index 2f0392e..75378d9 100644 --- a/mobile/lib/pages/chat/chat_details_page.dart +++ b/mobile/lib/pages/chat/chat_details_page.dart @@ -1,55 +1,38 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth_state.dart'; import 'package:mobile/controllers/chat/chat_search_controller.dart'; import 'package:mobile/controllers/chat/group_details_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; +import 'package:mobile/models/group.dart'; import 'package:mobile/pages/chat/chat_page.dart'; -import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:mobile/services/api_services.dart'; import 'package:mobile/services/signal_service.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.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'], - ); - } -} +/// What [ChatDetailsPage] pops with. A rename pops the new name as a String. +enum ChatDetailsResult { search, left } class ChatDetailsPage extends StatefulWidget { final bool isGroup; final String chatName; - final String chatImageUrl; + final String? username; final String chatId; const ChatDetailsPage({ super.key, required this.isGroup, required this.chatName, - required this.chatImageUrl, required this.chatId, + this.username, }); @override @@ -57,703 +40,816 @@ class ChatDetailsPage extends StatefulWidget { } class _ChatDetailsPageState extends State { + late String _name = widget.chatName; + bool? _isVerified; + bool _busy = false; + @override void initState() { super.initState(); - if (widget.isGroup) { - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.isGroup) { context.read().fetchGroupMembers(widget.chatId); - }); + } else { + _loadVerification(); + } + }); + } + + Future _loadVerification() async { + try { + final res = await ApiService().getVerification(widget.chatId); + if (mounted) { + setState( + () => + _isVerified = ApiService.dataMap(res.data)['is_verified'] == true, + ); + } + } catch (_) { + if (mounted) setState(() => _isVerified = false); } } - @override - Widget build(BuildContext context) { - final groupDetailsState = 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), + void _close([Object? result]) { + Navigator.pop(context, result ?? (_name != widget.chatName ? _name : null)); + } - if (widget.isGroup) - _buildGroupMembersSection(groupDetailsState) - else - _buildOneOnOneDetails(), + // --- Direct chat actions --- - const SizedBox(height: 40), - ]), - ), - ], + Future _openSafetyNumber() async { + final myId = context.read().currentUser?.id; + if (myId == null) return; + + setState(() => _busy = true); + final info = await SignalService().getVerification(myId, widget.chatId); + if (!mounted) return; + setState(() => _busy = false); + + if (info == null) { + showSnack( + context, + '$_name hasn\'t set up encryption yet.', + isError: true, + ); + return; + } + + final verified = await showGlassSheet( + context, + title: 'Verify safety number', + builder: (_) => _SafetyNumberSheet( + name: _name, + safetyNumber: info.safetyNumber, + initiallyVerified: info.isVerified, + contactId: widget.chatId, ), ); + if (verified != null && mounted) setState(() => _isVerified = verified); } - 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)), - ), - ), - ), + Future _resetSession() async { + final confirmed = await confirmAction( + context, + title: 'Reset secure session?', + message: + 'Use this if messages from $_name fail to decrypt, for example after they reinstalled the app. ' + 'A new session is created and their current safety number is trusted.', + confirmLabel: 'Reset', + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + final ok = await SignalService().forceResetSession(widget.chatId); + if (!mounted) return; + setState(() => _busy = false); + showSnack( + context, + ok + ? 'Secure session reset. New messages will use it.' + : 'Couldn\'t reach $_name\'s keys. Try again later.', + isError: !ok, ); + _loadVerification(); } - 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'); - }, - ), - ], - ), + // --- Group actions --- + + Future _rename() async { + final newName = await promptText( + context, + title: 'Rename group', + initialValue: _name, + hint: 'Group name', + maxLength: 100, + ); + if (newName == null || newName.isEmpty || newName == _name || !mounted) { + return; + } + + final error = await context.read().renameGroup( + widget.chatId, + newName, + ); + if (!mounted) return; + if (error != null) { + showSnack(context, error, isError: true); + return; + } + context.read().renameLocal(widget.chatId, newName); + setState(() => _name = newName); + } + + Future _leave() async { + final confirmed = await confirmAction( + context, + title: 'Leave $_name?', + message: + 'You\'ll stop receiving messages from this group. An admin can add you back later.', + confirmLabel: 'Leave', + destructive: true, ); + if (!confirmed || !mounted) return; + + final groups = context.read(); + final inbox = context.read(); + final error = await groups.leaveGroup(widget.chatId); + if (!mounted) return; + if (error != null) { + showSnack(context, error, isError: true); + return; + } + await inbox.removeChat(widget.chatId); + if (mounted) _close(ChatDetailsResult.left); } - 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)], + Future _memberActions(GroupMember member, bool iAmAdmin) async { + final myId = context.read().currentUser?.id.toLowerCase(); + final isSelf = member.userId.toLowerCase() == myId; + if (isSelf) return; + + final action = await showActionSheet( + context, + header: ListTile( + leading: ElephantAvatar( + name: member.displayName, + seed: member.userId, + size: 40, ), - onTap: () {}, + title: Text(member.displayName), + subtitle: Text('@${member.username}'), ), + actions: [ + const SheetAction( + icon: Icons.chat_bubble_outline_rounded, + label: 'Send message', + value: 'message', + ), + if (iAmAdmin) + SheetAction( + icon: Icons.person_remove_outlined, + label: 'Remove from group', + value: 'remove', + destructive: true, + ), + ], ); - } + if (!mounted || action == null) return; - 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) {}), + if (action == 'message') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatPage( + chatUserId: member.userId, + displayName: member.displayName, + username: member.username, ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.music_note), - title: const Text('Custom notifications'), - onTap: () {}, + ), + ); + } else if (action == 'remove') { + final confirmed = await confirmAction( + context, + title: 'Remove ${member.displayName}?', + message: 'They will no longer receive messages from $_name.', + confirmLabel: 'Remove', + destructive: true, + ); + if (!confirmed || !mounted) return; + final error = await context.read().removeMember( + widget.chatId, + member.userId, + ); + if (mounted) { + showSnack( + context, + error ?? '${member.displayName} was removed', + isError: error != null, + ); + } + } + } + + // --- Build --- + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _close(); + }, + child: Scaffold( + extendBodyBehindAppBar: true, + appBar: GlassAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: _close, ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.image), - title: const Text('Media visibility'), - onTap: () {}, + title: Text(widget.isGroup ? 'Group info' : 'Contact info'), + actions: [ + if (_busy) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + body: AmbientBackground( + intensity: 0.7, + child: ListView( + padding: EdgeInsets.only( + top: + MediaQuery.paddingOf(context).top + + kToolbarHeight + + Insets.xl, + bottom: Insets.xxl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + _buildHeader(), + const SizedBox(height: Insets.xl), + _buildQuickActions(), + if (widget.isGroup) + ..._buildGroupSections() + else + ..._buildDirectSections(), + ], ), - ], + ), ), ); } - 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. Tap to verify.', - ), - onTap: () async { - final currentUserId = context.read().currentUser?.id; - if (currentUserId == null) return; - - final safetyNumber = await SignalService().getSafetyNumber( - currentUserId, - widget.chatId - ); - - if (!context.mounted) return; - - if (safetyNumber == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Send a message first to establish a secure session.')), - ); - return; - } - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "Verify Security Code", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - color: Colors.white, - child: QrImageView( - data: safetyNumber, - version: QrVersions.auto, - size: 200.0, - ), - ), - const SizedBox(height: 20), - Text( - safetyNumber, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - letterSpacing: 2, - fontWeight: FontWeight.w500 - ), - ), - const SizedBox(height: 20), - const Text( - "To verify that messages and calls are end-to-end encrypted, scan this code on your contact's phone, or compare the number above.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 40), - ], - ), - ), - ); - }, + Widget _buildHeader() { + final members = context.watch().currentGroupMembers; + return Column( + children: [ + Hero( + tag: 'avatar-${widget.chatId}', + child: ElephantAvatar( + name: _name, + seed: widget.chatId, + isGroup: widget.isGroup, + size: 104, ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.refresh, color: Colors.orange), - title: const Text('Reset Secure Session', style: TextStyle(color: Colors.orange)), - subtitle: const Text('Use this if messages are failing to decrypt.'), - onTap: () async { - await SignalService().forceResetSession(widget.chatId); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Secure session reset. Send a new message to reconnect.')), - ); - } - }, + ), + const SizedBox(height: Insets.lg), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.xl), + child: Text( + _name, + textAlign: TextAlign.center, + style: context.text.headlineMedium?.copyWith(fontSize: 26), ), - 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.', + ), + const SizedBox(height: Insets.xs), + if (widget.isGroup) + Text( + members.isEmpty ? 'Group' : 'Group Β· ${members.length} members', + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), - onTap: () {}, + ) + else if (widget.username != null) + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: widget.username!)); + showSnack(context, 'Username copied'); + }, + child: Text( + '@${widget.username}', + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + if (!widget.isGroup && _isVerified == true) ...[ + const SizedBox(height: Insets.sm), + TagPill( + 'Verified', + color: context.glass.success, + icon: Icons.verified_rounded, ), ], - ), + ], ); } - Widget _buildGroupMembersSection(GroupDetailsController groupState) { - final members = groupState.currentGroupMembers; - final isLoading = groupState.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, + Widget _buildQuickActions() { + final iAmAdmin = + widget.isGroup && + context.watch().isAdmin( + context.read().currentUser?.id, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + child: Row( children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - '${members.length} participants', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - ), + _QuickAction( + icon: Icons.chat_bubble_outline_rounded, + label: 'Message', + onTap: () => _close(), ), - - 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), - ); - }, + const SizedBox(width: Insets.md), + _QuickAction( + icon: Icons.search_rounded, + label: 'Search', + onTap: () => _close(ChatDetailsResult.search), + ), + if (iAmAdmin) ...[ + const SizedBox(width: Insets.md), + _QuickAction( + icon: Icons.person_add_alt_rounded, + label: 'Add', + onTap: _openAddMembers, ), + ], + ], + ), + ); + } - 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, + List _buildDirectSections() { + return [ + const SectionLabel('Encryption'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.verified_user_outlined, + iconColor: context.glass.success, + title: 'Verify safety number', + subtitle: _isVerified == true + ? 'You verified $_name\'s keys.' + : 'Compare numbers with $_name to make sure no one is listening in.', + trailing: _isVerified == null + ? null + : TagPill( + _isVerified! ? 'Verified' : 'Not verified', + color: _isVerified! + ? context.glass.success + : context.colors.onSurfaceVariant, ), - title: Text(member.displayName), - subtitle: isThisUserAdmin - ? Text( - 'Admin', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - ), - ) - : null, - onTap: () => - _showMemberDetailsDialog(member, isCurrentUserAdmin), - ); - }, - ), + onTap: _openSafetyNumber, + ), + ElephantTile( + icon: Icons.restart_alt_rounded, + iconColor: context.glass.warning, + title: 'Reset secure session', + subtitle: 'Fixes messages that won\'t decrypt.', + onTap: _resetSession, + ), ], ), - ); + const SectionLabel('About encryption'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page + 4), + child: Text( + 'Messages with $_name use the Signal Protocol. Your private keys never leave this device, ' + 'so the server only ever sees encrypted text.', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.5, + ), + ), + ), + ]; } - void _showMemberDetailsDialog(ChatMember member, bool isCurrentUserAdmin) { - showModalBottomSheet( - context: context, - builder: (context) { - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text('Message @${member.username}'), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: member.userId, - displayName: member.displayName, - isGroup: false, - ), - ), - ); - }, + List _buildGroupSections() { + final groups = context.watch(); + final myId = context.read().currentUser?.id.toLowerCase(); + final iAmAdmin = groups.isAdmin(myId); + final members = groups.currentGroupMembers; + + return [ + if (iAmAdmin) ...[ + const SectionLabel('Settings'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.edit_outlined, + title: 'Rename group', + subtitle: _name, + onTap: _rename, + ), + ], + ), + ], + SectionLabel( + 'Members', + trailing: groups.isLoadingDetails && members.isNotEmpty + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + ), + if (members.isEmpty && groups.isLoadingDetails) + const Padding( + padding: EdgeInsets.all(Insets.xl), + child: Center(child: CircularProgressIndicator()), + ) + else + GlassSection( + children: [ + if (iAmAdmin) + ElephantTile( + icon: Icons.person_add_alt_rounded, + title: 'Add members', + onTap: _openAddMembers, + ), + for (final member in members) + InkWell( + onTap: () => _memberActions(member, iAmAdmin), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Insets.lg, + vertical: 10, ), - if (isCurrentUserAdmin && - member.userId != - context.read().currentUser?.id) - ListTile( - title: const Text( - 'Remove from group', - style: TextStyle(color: Colors.red), + child: Row( + children: [ + ElephantAvatar( + name: member.displayName, + seed: member.userId, + size: 38, ), - 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), - ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member.userId.toLowerCase() == myId + ? '${member.displayName} (you)' + : member.displayName, + style: context.text.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, ), - ], - ), - ); - - 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.'), + Text( + '@${member.username}', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), - ); - context - .read() - .fetchGroupMembers(widget.chatId); - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to remove member.'), - ), - ); - } - } - }, - ), - ], + ), + ], + ), + ), + if (member.isAdmin) const TagPill('Admin'), + ], + ), + ), ), - ), + ], + ), + const SectionLabel('Privacy'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page + 4), + child: Text( + 'Group messages are encrypted in transit (TLS) but are not end-to-end encrypted yet, ' + 'so the server can read them. Use a direct chat for sensitive conversations.', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.5, ), - ); - }, - ); + ), + ), + const SizedBox(height: Insets.xl), + GlassSection( + children: [ + ElephantTile( + icon: Icons.logout_rounded, + title: 'Leave group', + destructive: true, + showChevron: false, + onTap: _leave, + ), + ], + ), + ]; } -} - -// --- 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); + void _openAddMembers() { + showGlassSheet( + context, + title: 'Add members', + builder: (_) => _AddMembersSheet(groupId: widget.chatId), + ).whenComplete(() { + if (mounted) context.read().clearSearch(); + }); } } -class _ActionIcon extends StatelessWidget { +class _QuickAction extends StatelessWidget { final IconData icon; final String label; - final GestureTapCallback? onTap; + final VoidCallback onTap; - const _ActionIcon({required this.icon, required this.label, this.onTap}); + const _QuickAction({ + required this.icon, + required this.label, + required 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), - ], + return Expanded( + child: GlassSurface( + borderRadius: BorderRadius.circular(Radii.lg), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon(icon, color: context.colors.primary), + const SizedBox(height: 6), + Text(label, style: context.text.labelLarge), + ], + ), + ), + ), ), ), ); } } -class _AddParticipantSheet extends StatefulWidget { - final String chatId; - - const _AddParticipantSheet({required this.chatId}); +class _SafetyNumberSheet extends StatefulWidget { + final String name; + final String safetyNumber; + final bool initiallyVerified; + final String contactId; + + const _SafetyNumberSheet({ + required this.name, + required this.safetyNumber, + required this.initiallyVerified, + required this.contactId, + }); @override - State<_AddParticipantSheet> createState() => _AddParticipantSheetState(); + State<_SafetyNumberSheet> createState() => _SafetyNumberSheetState(); } -class _AddParticipantSheetState extends State<_AddParticipantSheet> { - final _searchController = TextEditingController(); - Timer? _debounceTimer; +class _SafetyNumberSheetState extends State<_SafetyNumberSheet> { + late bool _verified = widget.initiallyVerified; + bool _saving = false; - @override - void dispose() { - _debounceTimer?.cancel(); - _searchController.dispose(); - super.dispose(); + List get _chunks { + final digits = widget.safetyNumber.replaceAll(RegExp(r'\s'), ''); + return [ + for (int i = 0; i < digits.length; i += 5) + digits.substring(i, (i + 5).clamp(0, digits.length)), + ]; } - void _onSearchChanged(String value, ChatSearchController searchState) { - if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); - - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - if (value.length >= 3) { - searchState.queryUsers(value); + Future _toggle(bool value) async { + setState(() => _saving = true); + try { + await SignalService().setVerified(widget.contactId, value); + if (mounted) setState(() => _verified = value); + } catch (e) { + if (mounted) { + showSnack( + context, + ApiService.errorMessage( + e, + fallback: 'Couldn\'t update verification.', + ), + isError: true, + ); } - }); - setState(() {}); - } - - Future _addMember(String userId, String displayName) async { - FocusScope.of(context).unfocus(); - - final groupCtrl = context.read(); - final groupDetailsCtrl = 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!')), - ); - groupDetailsCtrl.fetchGroupMembers(widget.chatId); - Navigator.pop(context); - } else if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to add $displayName.'))); + } finally { + if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { - final groupDetailsState = context.watch(); - final searchState = context.watch(); - final inboxState = 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 = groupDetailsState.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, + final fg = context.colors.onSurface; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(Insets.xl, 0, Insets.xl, Insets.xl), + child: Column( + children: [ + Text( + 'If the numbers on your screen and ${widget.name}\'s screen match, your chat is private.', + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), - 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, searchState), - decoration: InputDecoration( - hintText: "Search name or username", - prefixIcon: const Icon(Icons.search), - suffixIcon: isSearching - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - searchState.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(), + const SizedBox(height: Insets.xl), + GlassSurface( + borderRadius: BorderRadius.circular(Radii.lg), + padding: const EdgeInsets.all(Insets.lg), + child: QrImageView( + data: widget.safetyNumber.replaceAll(RegExp(r'\s'), ''), + size: 180, + eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.circle, color: fg), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.circle, + color: fg, + ), + ), + ), + const SizedBox(height: Insets.xl), + Wrap( + alignment: WrapAlignment.center, + spacing: 14, + runSpacing: 8, + children: [ + for (final chunk in _chunks) + Text( + chunk, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: 1.5, + color: fg, ), - - Expanded( - child: isSearching - ? _buildSearchResults( - searchState, - currentMemberIds, - hasValidQueryLength, - ) - : _buildRecentContacts(inboxState, currentMemberIds), ), - ], + ], + ), + const SizedBox(height: Insets.lg), + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: widget.safetyNumber)); + showSnack(context, 'Safety number copied'); + }, + icon: const Icon(Icons.copy_rounded, size: 18), + label: const Text('Copy'), + ), + const SizedBox(height: Insets.sm), + GlassSurface( + borderRadius: BorderRadius.circular(Radii.md), + child: SwitchListTile( + value: _verified, + onChanged: _saving ? null : _toggle, + title: const Text('Mark as verified'), + subtitle: const Text( + 'Resets automatically if their keys change.', + ), ), ), - ), + const SizedBox(height: Insets.lg), + GradientButton( + label: 'Done', + onPressed: () => Navigator.pop(context, _verified), + ), + ], ), ); } +} - Widget _buildSearchResults( - ChatSearchController searchState, - Set currentMemberIds, - bool hasValidQueryLength, - ) { - if (!hasValidQueryLength) { - return const Center( - child: Text("Type at least 3 characters to search..."), - ); - } - if (searchState.isSearchLoading) { - return const Center(child: CircularProgressIndicator()); - } +class _AddMembersSheet extends StatefulWidget { + final String groupId; - final results = searchState.contactSearchResults - .where((user) => !currentMemberIds.contains(user['id'])) - .toList(); + const _AddMembersSheet({required this.groupId}); - if (results.isEmpty) { - return const Center(child: Text("No new users found.")); - } + @override + State<_AddMembersSheet> createState() => _AddMembersSheetState(); +} - 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), - ), - ); - }, - ); - } +class _AddMembersSheetState extends State<_AddMembersSheet> { + final _search = TextEditingController(); + Timer? _debounce; + final Set _adding = {}; - Widget _buildRecentContacts( - InboxController inboxState, - Set currentMemberIds, - ) { - final recents = inboxState.inbox - .where( - (thread) => !thread.isGroup && !currentMemberIds.contains(thread.id), - ) - .toList(); + @override + void dispose() { + _debounce?.cancel(); + _search.dispose(); + super.dispose(); + } - if (recents.isEmpty) { - return const Center(child: Text("No recent contacts to add.")); - } + void _onChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) context.read().queryUsers(value); + }); + } - 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); - }, + Future _add(String userId, String name) async { + setState(() => _adding.add(userId)); + final error = await context.read().addMember( + widget.groupId, + userId, ); + if (!mounted) return; + setState(() => _adding.remove(userId)); + showSnack(context, error ?? '$name was added', isError: error != null); } - 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), + @override + Widget build(BuildContext context) { + final search = context.watch(); + final memberIds = context + .watch() + .currentGroupMembers + .map((m) => m.userId.toLowerCase()) + .toSet(); + final inbox = context.watch(); + + final typed = + _search.text.trim().length >= ChatSearchController.minQueryLength; + final candidates = typed + ? search.results.map((u) => (u.id, u.displayName, u.username)).toList() + : inbox.inbox + .where((item) => !item.isGroup) + .map((item) => (item.id, item.title, item.username ?? '')) + .toList(); + + return SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Insets.xl, + 0, + Insets.xl, + Insets.sm, + ), + child: GlassSearchField( + controller: _search, + hint: 'Search by name or username', + onChanged: (value) { + setState(() {}); + _onChanged(value); + }, + ), + ), + if (search.isSearchLoading) + const LinearProgressIndicator(minHeight: 2), + if (!typed) const SectionLabel('Recent chats'), + Expanded( + child: candidates.isEmpty + ? Center( + child: Text( + search.error ?? + (typed + ? 'No users found' + : 'Search for people to add'), + style: TextStyle(color: context.colors.onSurfaceVariant), + ), + ) + : ListView.builder( + itemCount: candidates.length, + itemBuilder: (context, index) { + final (id, name, username) = candidates[index]; + final isMember = memberIds.contains(id.toLowerCase()); + return ListTile( + leading: ElephantAvatar(name: name, seed: id, size: 40), + title: Text(name), + subtitle: Text('@$username'), + trailing: isMember + ? const TagPill('Member') + : _adding.contains(id) + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : IconButton.filledTonal( + icon: const Icon(Icons.add_rounded), + onPressed: () => _add(id, name), + ), + ); + }, + ), + ), + ], ), ); } diff --git a/mobile/lib/pages/chat/chat_page.dart b/mobile/lib/pages/chat/chat_page.dart index c9e679a..554bd1b 100644 --- a/mobile/lib/pages/chat/chat_page.dart +++ b/mobile/lib/pages/chat/chat_page.dart @@ -1,1072 +1,709 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth_state.dart'; import 'package:mobile/controllers/chat/active_chat_controller.dart'; import 'package:mobile/controllers/chat/group_details_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; +import 'package:mobile/core/message_envelope.dart'; +import 'package:mobile/models/message.dart'; import 'package:mobile/pages/chat/chat_details_page.dart'; import 'package:mobile/services/ws_service.dart'; -import 'package:provider/provider.dart'; +import 'package:mobile/themes/app_themes.dart'; import 'package:mobile/widgets/chat_page_widgets.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; +import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ChatPage extends StatefulWidget { final String chatUserId; final String displayName; + final String? username; final bool isGroup; - final bool isNew; const ChatPage({ super.key, required this.chatUserId, required this.displayName, + this.username, this.isGroup = false, - this.isNew = false, }); @override State createState() => _ChatPageState(); } -class _ChatPageState extends State with WidgetsBindingObserver { - bool _isSearchMode = false; - final TextEditingController _chatSearchController = TextEditingController(); - List _searchResults = []; - - final ItemScrollController _itemScrollController = ItemScrollController(); - final ItemPositionsListener _itemPositionsListener = - ItemPositionsListener.create(); - - bool _showScrollToBottom = false; - bool _isNearBottom = true; - final Set _selectedIndices = {}; - dynamic _replyingToMessage; - late ActiveChatController _chatController; - late AuthState _authState; +class _ChatPageState extends State { + final ItemScrollController _scroll = ItemScrollController(); + final ItemPositionsListener _positions = ItemPositionsListener.create(); + final TextEditingController _composer = TextEditingController(); + final FocusNode _composerFocus = FocusNode(); - Timer? _typingDebounce; - bool _isCurrentlyTyping = false; + late final ActiveChatController _chat; + late final AuthState _auth; + late String _title = widget.displayName; + Message? _replyingTo; + Message? _editing; + String? _highlightedId; Timer? _highlightTimer; + bool _showScrollDown = false; + double _composerHeight = 72; - String? _highlightedMessageId; - - int _previousMessageCount = 0; + bool _searchMode = false; + final TextEditingController _searchController = TextEditingController(); + List _searchResults = []; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); + _chat = context.read(); + _auth = context.read(); + _positions.itemPositions.addListener(_onScroll); - _itemPositionsListener.itemPositions.addListener(_scrollListener); - _chatController = context.read(); - _chatController.addListener(_onChatStateChanged); - _authState = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _chatController.openChat(widget.chatUserId, isGroup: widget.isGroup); - if (widget.isGroup) { - final groupState = context.read(); - if (!groupState.hasFetchedGroup(widget.chatUserId)) { - groupState.fetchGroupMembers(widget.chatUserId); - } - } - - if (widget.isGroup && widget.isNew) { - _sendMessage('Hey Everyone!!'); - } + if (!mounted) return; + _chat.openChat(widget.chatUserId, isGroup: widget.isGroup); + context.read().clearUnread(widget.chatUserId); + if (widget.isGroup) { + context.read().fetchGroupMembers( + widget.chatUserId, + ); } }); } - void _onChatStateChanged() { - final currentCount = _chatController.activeChat.length; - if (currentCount > _previousMessageCount) { - _previousMessageCount = currentCount; - if (!_showScrollToBottom && _itemScrollController.isAttached) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _itemScrollController.jumpTo(index: 0); - }); - } - } else { - _previousMessageCount = currentCount; - } - } - @override void dispose() { - WidgetsBinding.instance.removeObserver(this); - _chatController.removeListener(_onChatStateChanged); - - _itemPositionsListener.itemPositions.removeListener(_scrollListener); - + _positions.itemPositions.removeListener(_onScroll); final closedChatId = widget.chatUserId; - - Future.microtask(() { - _chatController.closeChat(closedChatId); - }); - + Future.microtask(() => _chat.closeChat(closedChatId)); _highlightTimer?.cancel(); - _typingDebounce?.cancel(); - + _composer.dispose(); + _composerFocus.dispose(); + _searchController.dispose(); super.dispose(); } - @override - void didChangeMetrics() { - super.didChangeMetrics(); - if (_isNearBottom) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _scrollToBottom(animated: true); - } - }); - } - } + // --- Scrolling --- - void _handleTypingChange(bool isTyping) { - if (isTyping != _isCurrentlyTyping) { - _isCurrentlyTyping = isTyping; - context.read().sendTypingNotification(isTyping); - } + void _onScroll() { + final positions = _positions.itemPositions.value; + if (positions.isEmpty) return; - _typingDebounce?.cancel(); + final minIndex = positions + .map((p) => p.index) + .reduce((a, b) => a < b ? a : b); + final maxIndex = positions + .map((p) => p.index) + .reduce((a, b) => a > b ? a : b); - if (isTyping) { - _typingDebounce = Timer(const Duration(seconds: 2), () { - if (mounted && _isCurrentlyTyping) { - _isCurrentlyTyping = false; - context.read().sendTypingNotification(false); - } - }); + final scrolledUp = minIndex > 2; + if (scrolledUp != _showScrollDown) { + setState(() => _showScrollDown = scrolledUp); } - } - - void _scrollToAndHighlight(String messageId) { - setState(() { - _isSearchMode = false; - _chatSearchController.clear(); - _searchResults.clear(); - }); - - final chatState = context.read(); - final bool isTyping = chatState.isPeerTyping; - - final activeChat = chatState.activeChat; - final targetIndex = activeChat.indexWhere((msg) => msg.id == messageId); - - if (targetIndex != -1 && _itemScrollController.isAttached) { - final int realVisualIndex = - (activeChat.length - 1 - targetIndex) + (isTyping ? 3 : 2); - - _itemScrollController.scrollTo( - index: realVisualIndex, - duration: const Duration(milliseconds: 600), - curve: Curves.easeInOutCubic, - alignment: 0.3, - ); - - setState(() { - _highlightedMessageId = messageId; - }); + final itemCount = _chat.activeChat.length + 2; + if (maxIndex >= itemCount - 3) _chat.loadMoreMessages(); + } - _highlightTimer?.cancel(); - _highlightTimer = Timer(const Duration(milliseconds: 1500), () { - if (mounted) { - setState(() { - _highlightedMessageId = null; - }); - } - }); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Message is too far back to scroll to.')), + void _scrollToBottom() { + if (_scroll.isAttached) { + _scroll.scrollTo( + index: 0, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, ); } } - void _scrollListener() { - final positions = _itemPositionsListener.itemPositions.value; - if (positions.isEmpty) return; + void _jumpToMessage(String messageId) { + final messages = _chat.activeChat; + final index = messages.indexWhere((m) => m.id == messageId); + if (index == -1 || !_scroll.isAttached) { + showSnack(context, 'That message is further back than what\'s loaded.'); + return; + } - final bottomItem = positions.firstWhere( - (p) => p.index == 0, - orElse: () => const ItemPosition( - index: -1, - itemLeadingEdge: 0, - itemTrailingEdge: 0, - ), + _scroll.scrollTo( + index: messages.length - index, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOutCubic, + alignment: 0.35, ); - - final isScrolledUp = bottomItem.index > 2 || (bottomItem.index == -1); - - if (isScrolledUp != _showScrollToBottom) { - setState(() { - _showScrollToBottom = isScrolledUp; - }); - } + setState(() => _highlightedId = messageId); + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(milliseconds: 1600), () { + if (mounted) setState(() => _highlightedId = null); + }); } - void _scrollToBottom({bool animated = true}) { - if (!_itemScrollController.isAttached) return; + // --- Names --- - if (animated) { - _itemScrollController.scrollTo( - index: 0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } else { - _itemScrollController.jumpTo(index: 0); - } + bool _isMine(Message m) => m.isFrom(_auth.currentUser?.id); + + String _nameOf(String senderId) { + final id = senderId.trim().toLowerCase(); + if (id == 'me' || id == _auth.currentUser?.id.toLowerCase()) return 'You'; + if (!widget.isGroup) return _title; + return context.read().nameFor(id); } - void _sendMessage(String text) { - if (text.trim().isEmpty) return; + // --- Sending, replying, editing --- + + Future _send(String text) async { + final inbox = context.read(); - String? replyName; - if (_replyingToMessage != null) { - final String pId = _replyingToMessage.senderId.trim().toLowerCase(); - final bool isMe = - pId == 'me' || - (pId.isNotEmpty && pId != widget.chatUserId.trim().toLowerCase()); - replyName = isMe ? "You" : widget.displayName; + if (_editing != null) { + final error = await _chat.editMessage(_editing!, text); + if (!mounted) return; + if (error != null) { + showSnack(context, error, isError: true); + return; + } + setState(() => _editing = null); + _composer.clear(); + return; } - context.read().sendTextMessage( - text, - replyingTo: _replyingToMessage, - replyingToName: replyName, - senderId: _authState.currentUser!.displayName, - ); + final reply = _replyingTo; + final error = await _chat.sendTextMessage(text, replyingTo: reply); + if (!mounted) return; + if (error != null) { + showSnack(context, error, isError: true); + return; + } - final bool isConnected = WebSocketService().isConnected; - context.read().updateLocalInboxState( + _composer.clear(); + setState(() => _replyingTo = null); + inbox.updateLocalInboxState( widget.chatUserId, - text.trim(), + text, DateTime.now(), false, senderId: 'me', - syncStatus: isConnected ? 'synced' : 'pending', - isRead: false, + syncStatus: WebSocketService().isConnected ? 'synced' : 'pending', ); + _scrollToBottom(); + } + void _startReply(Message message) { + HapticFeedback.selectionClick(); setState(() { - _replyingToMessage = null; - }); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); + _editing = null; + _replyingTo = message; }); + _composerFocus.requestFocus(); } - void _toggleSelection(int index) { + void _startEdit(Message message) { setState(() { - if (_selectedIndices.contains(index)) { - _selectedIndices.remove(index); - } else { - _selectedIndices.add(index); - } + _replyingTo = null; + _editing = message; }); + _composer.text = message.content; + _composer.selection = TextSelection.collapsed( + offset: _composer.text.length, + ); + _composerFocus.requestFocus(); } - void _clearSelection() { + void _cancelBanner() { + if (_editing != null) _composer.clear(); setState(() { - _selectedIndices.clear(); + _editing = null; + _replyingTo = null; }); } - void _copySelectedMessages() async { - final sortedIndices = _selectedIndices.toList()..sort(); - - final messages = await context - .read() - .getLocalMessagesForChat(widget.chatUserId); - - final selectedTexts = sortedIndices - .map((index) { - final msg = messages[index]; - final time = DateFormat.jm().format(msg.createdAt); - final senderName = msg.senderId == _authState.currentUser!.id - ? "Me" - : widget.displayName; + Future _showMessageActions(Message message) async { + HapticFeedback.mediumImpact(); + final isLocked = message.content.startsWith(MessageEnvelope.lockedPrefix); + final canEdit = !isLocked && message.canEdit(_auth.currentUser?.id); - return '[$time] $senderName: ${msg.content}'; - }) - .join('\n\n'); + final action = await showActionSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(Insets.xl, 0, Insets.xl, Insets.sm), + child: Text( + '${_nameOf(message.senderId)} Β· ${DateFormat.MMMd().add_jm().format(message.createdAt)}' + '${message.isEdited ? ' Β· edited' : ''}', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + actions: [ + const SheetAction( + icon: Icons.reply_rounded, + label: 'Reply', + value: 'reply', + ), + if (!isLocked) + const SheetAction( + icon: Icons.copy_rounded, + label: 'Copy text', + value: 'copy', + ), + if (canEdit) + const SheetAction( + icon: Icons.edit_outlined, + label: 'Edit', + value: 'edit', + ), + ], + ); + if (!mounted || action == null) return; + + switch (action) { + case 'reply': + _startReply(message); + break; + case 'copy': + await Clipboard.setData(ClipboardData(text: message.content)); + if (mounted) showSnack(context, 'Copied'); + break; + case 'edit': + _startEdit(message); + break; + } + } - Clipboard.setData(ClipboardData(text: selectedTexts)); + // --- Details & search --- - ScaffoldMessenger.of( + Future _openDetails() async { + FocusScope.of(context).unfocus(); + final result = await Navigator.push( context, - ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); + MaterialPageRoute( + builder: (_) => ChatDetailsPage( + chatId: widget.chatUserId, + chatName: _title, + username: widget.username, + isGroup: widget.isGroup, + ), + ), + ); + if (!mounted) return; - _clearSelection(); - } + // Opening a member's chat from group info replaces the active chat. + if (_chat.currentChatUserId != widget.chatUserId && + result != ChatDetailsResult.left) { + _chat.openChat(widget.chatUserId, isGroup: widget.isGroup); + } - UserStatus _getPresenceStatusText(ActiveChatController state) { - return state.isPeerOnline ? UserStatus.online : UserStatus.offline; + if (result == ChatDetailsResult.search) { + setState(() => _searchMode = true); + } else if (result == ChatDetailsResult.left) { + Navigator.pop(context); + } else if (result is String) { + setState(() => _title = result); + } } - bool _isSameDay(DateTime date1, DateTime date2) { - return date1.year == date2.year && - date1.month == date2.month && - date1.day == date2.day; + Future _runSearch(String query) async { + final q = query.trim().toLowerCase(); + if (q.isEmpty) { + setState(() => _searchResults = []); + return; + } + final messages = await _chat.getLocalMessagesForChat(widget.chatUserId); + if (!mounted) return; + setState(() { + _searchResults = messages + .where((m) => m.content.toLowerCase().contains(q)) + .toList() + .reversed + .toList(); + }); } - 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}"; + void _closeSearch() { + setState(() { + _searchMode = false; + _searchController.clear(); + _searchResults = []; + }); } - 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) async { - if (query.trim().isEmpty) { - setState(() => _searchResults = []); - return; - } + // --- Build --- - final chatState = context.read(); - final messages = await chatState.getLocalMessagesForChat( - widget.chatUserId, - ); - final lowercaseQuery = query.toLowerCase(); - - setState(() { - _searchResults = messages.where((msg) { - return msg.content.toLowerCase().contains(lowercaseQuery); - }).toList(); - }); - }, - ), - ); + String _subtitle(ActiveChatController chat, GroupDetailsController group) { + if (chat.isPeerTyping) { + return widget.isGroup ? 'someone is typing…' : 'typing…'; + } + if (widget.isGroup) { + final count = group.currentGroupMembers.length; + return count > 0 ? '$count members Β· tap for info' : 'Tap for group info'; + } + if (chat.isPeerOnline) return 'online'; + return widget.username != null + ? '@${widget.username}' + : 'Tap for contact info'; } @override Widget build(BuildContext context) { - final chatState = context.watch(); - final groupDetailsState = context.watch(); - final isSelectionMode = _selectedIndices.isNotEmpty; - final theme = Theme.of(context); + final chat = context.watch(); + final group = context.watch(); + final messages = chat.currentChatUserId == widget.chatUserId + ? chat.activeChat + : const []; + final topInset = MediaQuery.paddingOf(context).top + kToolbarHeight + 6; return PopScope( - canPop: !isSelectionMode, - onPopInvokedWithResult: (didPop, result) { - if (!didPop) { - setState(() { - _selectedIndices.clear(); - }); - } + canPop: !_searchMode, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _searchMode) _closeSearch(); }, child: Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBody: true, extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - 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(), - ), - 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 (result == 'start_search') { - setState(() { - _isSearchMode = true; - }); - } - }, - ), - ), - ), - body: Stack( - children: [ - Consumer( - builder: (context, chatController, child) { - final activeChat = chatState.activeChat; - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.05), - end: Offset.zero, - ).animate(animation), - child: child, + appBar: _searchMode + ? GlassAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: _closeSearch, + ), + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.only(right: Insets.lg), + child: GlassSearchField( + controller: _searchController, + hint: 'Search this chat', + autofocus: true, + onChanged: _runSearch, ), - ); - }, - child: chatState.isChatHistoryLoading && activeChat.isEmpty - ? Center( - key: const ValueKey('loading'), - child: CircularProgressIndicator( - color: theme.colorScheme.primary, - ), - ) - : activeChat.isEmpty - ? Center( - key: const ValueKey('empty'), - child: Text( - "No messages yet.\nSay hi!", - textAlign: TextAlign.center, - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ) - : ScrollablePositionedList.builder( - key: const ValueKey('list'), - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - reverse: true, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - padding: const EdgeInsets.only( - top: 140, - left: 16, - right: 16, - ), - itemCount: - activeChat.length + - (chatState.isPeerTyping ? 2 : 1) + - 2, - itemBuilder: (context, index) { - if (index == 0) { - return const SizedBox(height: 140); - } - if (index == 1) { - return AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - child: SizedBox( - height: _replyingToMessage != null ? 80 : 0, - ), - ); - } - - if (chatState.isPeerTyping && index == 2) { - 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( - 0, - ), - ), - ), - child: Text( - "Typing...", - style: TextStyle( - color: theme.colorScheme.primary, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ), - ), - ); - } - - final int msgIndex = - index - (chatState.isPeerTyping ? 3 : 2); - final int realIndex = - activeChat.length - 1 - msgIndex; - if (realIndex == -1) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 24.0, - horizontal: 16.0, - ), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - "πŸ”’ Messages and calls are end-to-end encrypted. No one outside of this chat can read or listen to them.", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } - final msg = activeChat[realIndex]; - - final bool isHighlighted = - msg.id == _highlightedMessageId; - 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 bool isMe = - cleanSenderId == 'me' || - (_authState.currentUser?.id != null && - cleanSenderId == - _authState.currentUser!.id - .toLowerCase()); - - final String senderId = msg.senderId - .trim() - .toLowerCase(); - final String? displayName = widget.isGroup - ? (groupDetailsState - .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()), - 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: 350, - ), - decoration: BoxDecoration( - color: isSelected || isHighlighted - ? theme.colorScheme.primary - .withValues(alpha: 0.25) - : 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, - syncStatus: msg.syncStatus, - quotedMessage: msg.quotedMessage, - isGroup: widget.isGroup, - senderName: showSenderName - ? displayName - : null, - onQuoteTap: - msg.quotedMessage != null - ? () => _scrollToAndHighlight( - msg.quotedMessage!.id, - ) - : null, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ), - ); - }, - ), - - 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 - .trim() - .toLowerCase()] ?? - '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: () { - 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), ), + ) + as PreferredSizeWidget + : ChatHeader( + name: _title, + seed: widget.chatUserId, + isGroup: widget.isGroup, + isOnline: widget.isGroup ? null : chat.isPeerOnline, + subtitle: _subtitle(chat, group), + highlightSubtitle: + chat.isPeerTyping || (!widget.isGroup && chat.isPeerOnline), + onTitleTap: _openDetails, + actions: [ + IconButton( + tooltip: 'Search', + icon: const Icon(Icons.search_rounded), + onPressed: () => setState(() => _searchMode = true), ), - ), + const SizedBox(width: Insets.xs), + ], ), - 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, - ), - 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), - ), - ], - ), - child: Row( - children: [ - 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, - ), - ), - ], - ), - ), - IconButton( - icon: Icon( - Icons.close, - size: 20, - color: theme.iconTheme.color, - ), - onPressed: () => setState( - () => _replyingToMessage = null, - ), - ), - ], - ), - ), - ), - ) - : const SizedBox(width: double.infinity, height: 0), + body: AmbientBackground( + intensity: 0.4, + child: Stack( + children: [ + Positioned.fill( + child: _buildMessageList(chat, messages, topInset), + ), + if (_searchMode) + Positioned.fill(top: topInset, child: _buildSearchResults()), + if (!_searchMode) + Positioned( + right: Insets.lg, + bottom: _composerHeight + Insets.md, + child: AnimatedScale( + scale: _showScrollDown ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: GlassSurface( + strong: true, + borderRadius: BorderRadius.circular(99), + child: IconButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded), + onPressed: _scrollToBottom, ), ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: _handleTypingChange, + ), + ), + if (!_searchMode) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _MeasureSize( + onChange: (size) { + if (size.height != _composerHeight) { + setState(() => _composerHeight = size.height); + } + }, + child: ChatComposer( + controller: _composer, + focusNode: _composerFocus, + onSend: _send, + byteLimit: chat.messageByteLimit, + isEditing: _editing != null, + onTypingChanged: chat.sendTypingNotification, + banner: _editing != null + ? ComposerBanner( + icon: Icons.edit_outlined, + title: 'Editing message', + body: _editing!.content, + onClose: _cancelBanner, + ) + : _replyingTo != null + ? ComposerBanner( + icon: Icons.reply_rounded, + title: + 'Replying to ${_nameOf(_replyingTo!.senderId)}', + body: _replyingTo!.content, + onClose: _cancelBanner, + ) + : null, ), - ], + ), ), - ), - ], + ], + ), ), ), ); } -} -class _AnimatedMessageItem extends StatefulWidget { - final Widget child; + Widget _buildMessageList( + ActiveChatController chat, + List messages, + double topInset, + ) { + if (messages.isEmpty) { + return Padding( + padding: EdgeInsets.only(top: topInset, bottom: _composerHeight), + child: chat.isChatHistoryLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Column( + children: [ + EncryptionNotice(isGroup: widget.isGroup), + const SizedBox(height: 60), + EmptyState( + icon: widget.isGroup + ? Icons.groups_rounded + : Icons.waving_hand_rounded, + title: widget.isGroup + ? 'Start the conversation' + : 'Say hello', + message: widget.isGroup + ? 'Be the first to post in ${widget.displayName}.' + : 'Send ${widget.displayName} your first message.', + ), + ], + ), + ), + ); + } - const _AnimatedMessageItem({super.key, required this.child}); + // Reversed list: index 0 is the typing slot at the bottom, then newest β†’ + // oldest, then the header (loading more / encryption notice) at the top. + return ScrollablePositionedList.builder( + itemScrollController: _scroll, + itemPositionsListener: _positions, + reverse: true, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.only( + top: topInset + Insets.sm, + bottom: _composerHeight + Insets.sm, + left: Insets.md, + right: Insets.md, + ), + itemCount: messages.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + child: chat.isPeerTyping + ? const TypingIndicator() + : const SizedBox(height: 0, width: double.infinity), + ); + } + if (index == messages.length + 1) { + if (chat.isLoadingMore) { + return const Padding( + padding: EdgeInsets.all(Insets.lg), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + return chat.hasMoreMessages + ? const SizedBox(height: 40) + : EncryptionNotice(isGroup: widget.isGroup); + } - @override - State<_AnimatedMessageItem> createState() => _AnimatedMessageItemState(); -} + final i = messages.length - index; + final msg = messages[i]; + final prev = i > 0 ? messages[i - 1] : null; + final next = i < messages.length - 1 ? messages[i + 1] : null; + final isMe = _isMine(msg); + + bool sameRun(Message? other) { + if (other == null) return false; + return _isMine(other) == isMe && + (isMe || + other.senderId.toLowerCase() == msg.senderId.toLowerCase()) && + DaySeparator.label(other.createdAt) == + DaySeparator.label(msg.createdAt) && + other.createdAt.difference(msg.createdAt).inMinutes.abs() < 4; + } -class _AnimatedMessageItemState extends State<_AnimatedMessageItem> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _fadeAnimation; - late Animation _slideAnimation; + final newDay = + prev == null || + DaySeparator.label(prev.createdAt) != + DaySeparator.label(msg.createdAt); + final quote = msg.quotedMessage; - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), + return Column( + key: ValueKey(msg.id), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (newDay) DaySeparator(date: msg.createdAt), + _SwipeToReply( + isMe: isMe, + onReply: () => _startReply(msg), + child: GestureDetector( + onLongPress: () => _showMessageActions(msg), + child: ChatBubble( + message: msg, + isMe: isMe, + isGroup: widget.isGroup, + isFirstInRun: newDay || !sameRun(prev), + isLastInRun: !sameRun(next), + senderName: widget.isGroup && !isMe + ? _nameOf(msg.senderId) + : null, + senderSeed: msg.senderId.toLowerCase(), + quotedAuthor: quote != null && quote.senderId.isNotEmpty + ? _nameOf(quote.senderId) + : null, + highlighted: msg.id == _highlightedId, + onQuoteTap: quote != null + ? () => _jumpToMessage(quote.id) + : null, + ), + ), + ), + ], + ); + }, ); + } - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + Widget _buildSearchResults() { + if (_searchResults.isEmpty) { + return EmptyState( + icon: Icons.manage_search_rounded, + title: _searchController.text.isEmpty + ? 'Search this chat' + : 'No messages found', + message: _searchController.text.isEmpty + ? 'Searches the messages stored on this device.' + : 'Try a different word.', + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: Insets.sm), + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final msg = _searchResults[index]; + return ListTile( + title: Text( + msg.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${_nameOf(msg.senderId)} Β· ${DateFormat.yMMMd().add_jm().format(msg.createdAt)}', + ), + onTap: () { + FocusScope.of(context).unfocus(); + _closeSearch(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _jumpToMessage(msg.id), + ); + }, + ); + }, + ); + } +} - _slideAnimation = Tween( - begin: const Offset(0, 0.15), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); +/// Reports its child's size after layout (used to pad the list above the +/// composer, whose height changes with reply/edit banners). +class _MeasureSize extends SingleChildRenderObjectWidget { + final ValueChanged onChange; - _controller.forward(); - } + const _MeasureSize({required this.onChange, required super.child}); @override - void dispose() { - _controller.dispose(); - super.dispose(); + RenderObject createRenderObject(BuildContext context) => + _MeasureSizeRender(onChange); + + @override + void updateRenderObject( + BuildContext context, + _MeasureSizeRender renderObject, + ) { + renderObject.onChange = onChange; } +} + +class _MeasureSizeRender extends RenderProxyBox { + ValueChanged onChange; + Size? _last; + + _MeasureSizeRender(this.onChange); @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition(position: _slideAnimation, child: widget.child), - ); + void performLayout() { + super.performLayout(); + if (_last != size) { + _last = size; + WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size)); + } } } -class SwipeToReply extends StatefulWidget { +/// Drag a bubble sideways to reply. +class _SwipeToReply extends StatefulWidget { final Widget child; final VoidCallback onReply; final bool isMe; - const SwipeToReply({ - super.key, + const _SwipeToReply({ required this.child, required this.onReply, required this.isMe, }); @override - State createState() => _SwipeToReplyState(); + State<_SwipeToReply> createState() => _SwipeToReplyState(); } -class _SwipeToReplyState extends State +class _SwipeToReplyState extends State<_SwipeToReply> 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; - }); - }); - } + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + )..addListener(() => setState(() => _offset = _animation.value)); + late Animation _animation = const AlwaysStoppedAnimation(0); + double _offset = 0; + bool _armed = false; + static const double _threshold = 56; @override void dispose() { @@ -1074,79 +711,49 @@ class _SwipeToReplyState extends State super.dispose(); } - void _onDragUpdate(DragUpdateDetails details) { + void _update(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; - } + _offset += details.primaryDelta ?? 0; + _offset = widget.isMe + ? _offset.clamp(-_threshold * 1.3, 0) + : _offset.clamp(0, _threshold * 1.3); + final armed = _offset.abs() >= _threshold; + if (armed && !_armed) HapticFeedback.lightImpact(); + _armed = armed; }); } - void _onDragEnd(DragEndDetails details) { - if (_dragOffset.abs() >= _replyThreshold) { - widget.onReply(); - } - _vibrated = false; - + void _end(DragEndDetails _) { + if (_armed) widget.onReply(); + _armed = false; _animation = Tween( - begin: _dragOffset, - end: 0.0, + begin: _offset, + end: 0, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - _controller.forward(from: 0.0); + _controller.forward(from: 0); } @override Widget build(BuildContext context) { - final double iconOpacity = (_dragOffset.abs() / _replyThreshold).clamp( - 0.0, - 1.0, - ); - + final progress = (_offset.abs() / _threshold).clamp(0.0, 1.0); return GestureDetector( - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, - behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: _update, + onHorizontalDragEnd: _end, child: Stack( alignment: Alignment.center, children: [ Positioned( - left: widget.isMe ? null : 20, - right: widget.isMe ? 20 : null, + left: widget.isMe ? null : 8, + right: widget.isMe ? 8 : null, child: Opacity( - opacity: iconOpacity, + opacity: progress, child: Transform.scale( - scale: 0.5 + (0.5 * iconOpacity), - child: Icon( - Icons.reply, - color: Theme.of(context).colorScheme.primary, - size: 28, - ), + scale: 0.6 + 0.4 * progress, + child: Icon(Icons.reply_rounded, color: context.colors.primary), ), ), ), - Transform.translate( - offset: Offset(_dragOffset, 0), - child: widget.child, - ), + Transform.translate(offset: Offset(_offset, 0), child: widget.child), ], ), ); diff --git a/mobile/lib/pages/home/home_page.dart b/mobile/lib/pages/home/home_page.dart index 4f3f3dd..9ed8725 100644 --- a/mobile/lib/pages/home/home_page.dart +++ b/mobile/lib/pages/home/home_page.dart @@ -1,17 +1,21 @@ -import 'dart:async'; -import 'dart:ui'; - import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile/controllers/auth_state.dart'; import 'package:mobile/controllers/chat/chat_connection_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; +import 'package:mobile/models/inbox_item.dart'; +import 'package:mobile/pages/chat/chat_details_page.dart'; +import 'package:mobile/pages/chat/chat_page.dart'; +import 'package:mobile/pages/new%20chat/new_chat_page.dart'; +import 'package:mobile/pages/new%20chat/select_contact_page.dart'; import 'package:mobile/pages/settings/settings_page.dart'; -import 'package:mobile/services/auth_service.dart'; +import 'package:mobile/themes/app_themes.dart'; import 'package:mobile/widgets/home_page_widgets.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; -import '../new chat/new_chat_page.dart'; - class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -20,630 +24,657 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - late bool _isSearchOpen; - int _page = 1; - final _searchController = TextEditingController(); - final Set _selectedChatIds = {}; - - late final ScrollController _scrollController; - bool _isFabVisible = true; - - bool get _isSelectionMode => _selectedChatIds.isNotEmpty; - - Timer? _searchDebounce; + int _tab = 0; @override void initState() { super.initState(); - _isSearchOpen = false; - - _searchController.addListener(() { - if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 300), () { - if (mounted) { - setState(() {}); - } - }); - }); - - _scrollController = ScrollController(); - _scrollController.addListener(() { - final inboxController = context.read(); - if (inboxController.inbox.isEmpty) { - if (!_isFabVisible) { - setState(() => _isFabVisible = true); - } - return; - } - if (_scrollController.position.userScrollDirection == - ScrollDirection.reverse) { - if (_isFabVisible) setState(() => _isFabVisible = false); - } else if (_scrollController.position.userScrollDirection == - ScrollDirection.forward) { - if (!_isFabVisible) setState(() => _isFabVisible = true); - } - }); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - final connectionController = context.read(); - final inboxContoller = context.read(); - - final token = await AuthService().getToken(); - - if (!mounted) return; - - if (token != null) { - await connectionController.connectWebSocket(); - inboxContoller.loadInbox(); - } - }); - } - - @override - void dispose() { - _searchDebounce?.cancel(); - _searchController.dispose(); - _scrollController.dispose(); - super.dispose(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _maybeShowNewUsername(), + ); } - Widget _buildNavItem({ - required int index, - required IconData icon, - required IconData activeIcon, - required String label, - required double width, - }) { - final theme = Theme.of(context); - final isSelected = _page == index; + /// Registration assigns a discriminator; make sure the user sees it once. + void _maybeShowNewUsername() { + final auth = context.read(); + final username = auth.newlyRegisteredUsername; + if (username == null || !mounted) return; + auth.newlyRegisteredUsername = null; - return GestureDetector( - onTap: () { - setState(() { - _page = index; - }); - if (_isSearchOpen) { - _isSearchOpen = false; - _searchController.clear(); - } - }, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: width, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedScale( - scale: isSelected ? 1.15 : 1.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutBack, - child: Icon( - isSelected ? activeIcon : icon, - color: isSelected - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - size: 24, - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.decelerate, - child: Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.w600, - color: isSelected - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - ), + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + icon: Icon( + Icons.celebration_rounded, + color: dialogContext.colors.primary, + size: 36, + ), + title: const Text('Welcome to Elephant'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'This is your username. You\'ll need it to sign in, and friends use it to find you.', + textAlign: TextAlign.center, + ), + const SizedBox(height: Insets.lg), + GlassSurface( + borderRadius: BorderRadius.circular(Radii.md), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('@$username', style: dialogContext.text.titleMedium), + const SizedBox(width: 8), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.copy_rounded, size: 18), + onPressed: () { + Clipboard.setData(ClipboardData(text: username)); + showSnack(context, 'Username copied'); + }, ), - ), + ], ), - ], - ), + ), + ], ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Got it'), + ), + ], ), ); } - Widget buildGlassNavigationBar({Key? key}) { - final theme = Theme.of(context); - return SafeArea( - key: key, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 20), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - height: 68, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: theme.colorScheme.surface.withValues(alpha: 0.8), - width: 1.5, - ), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues(alpha: 0.04), - blurRadius: 24, - spreadRadius: 2, - offset: const Offset(0, 8), - ), - BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.03), - blurRadius: 16, - spreadRadius: -4, - offset: const Offset(0, -2), + @override + Widget build(BuildContext context) { + final inbox = context.watch(); + + return Scaffold( + extendBody: true, + body: AmbientBackground( + intensity: 0.55, + child: IndexedStack( + index: _tab, + children: const [ + _ChatsTab(), + _GroupsTab(), + SettingsPage(embedded: true), + ], + ), + ), + floatingActionButton: _tab == 2 + ? null + : Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: _ComposeButton( + icon: _tab == 0 ? Icons.edit_rounded : Icons.group_add_rounded, + tooltip: _tab == 0 ? 'New message' : 'New group', + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _tab == 0 + ? const NewChatPage() + : const SelectContactPage(), ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - final tabWidth = constraints.maxWidth / 3; - - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: _page * tabWidth, - top: 0, - bottom: 0, - width: tabWidth, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface.withValues( - alpha: 0.8, - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary.withValues( - alpha: 0.4, - ), - blurRadius: 20, - // offset: const Offset(0, 0), - ), - ], - ), - ), - ), - ), - Row( - children: [ - _buildNavItem( - index: 0, - icon: Icons.amp_stories_outlined, - activeIcon: Icons.amp_stories, - label: 'Story', - width: tabWidth, - ), - _buildNavItem( - index: 1, - icon: Icons.chat_bubble_outline, - activeIcon: Icons.chat_bubble, - label: 'Chat', - width: tabWidth, - ), - _buildNavItem( - index: 2, - icon: Icons.call_outlined, - activeIcon: Icons.call, - label: 'Call', - width: tabWidth, - ), - ], - ), - ], - ); - }, + ), ), ), - ), - ), + bottomNavigationBar: _GlassNavBar( + index: _tab, + unreadChats: inbox.unreadChats, + onChanged: (i) { + if (i != _tab) HapticFeedback.selectionClick(); + setState(() => _tab = i); + }, ), ); } +} + +void openConversation(BuildContext context, InboxItem item) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatPage( + chatUserId: item.id, + displayName: item.title.isEmpty ? (item.username ?? '') : item.title, + username: item.username, + isGroup: item.isGroup, + ), + ), + ); +} + +Future _showConversationActions( + BuildContext context, + InboxItem item, +) async { + final action = await showActionSheet( + context, + header: ListTile( + title: Text(item.title, style: context.text.titleMedium), + subtitle: Text(item.isGroup ? 'Group' : '@${item.username ?? ''}'), + ), + actions: [ + const SheetAction( + icon: Icons.chat_bubble_outline_rounded, + label: 'Open chat', + value: 'open', + ), + if (item.unreadCount > 0) + const SheetAction( + icon: Icons.mark_chat_read_outlined, + label: 'Mark as read', + value: 'read', + ), + SheetAction( + icon: item.isGroup + ? Icons.info_outline_rounded + : Icons.verified_user_outlined, + label: item.isGroup ? 'Group info' : 'Contact & encryption', + value: 'info', + ), + ], + ); + if (!context.mounted || action == null) return; + + switch (action) { + case 'open': + openConversation(context, item); + break; + case 'read': + await context.read().markReadFor(item); + break; + case 'info': + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatDetailsPage( + chatId: item.id, + chatName: item.title, + username: item.username, + isGroup: item.isGroup, + ), + ), + ); + break; + } +} - Widget buildHomeTab( - InboxController inboxState, - ChatConnectionController connectionState, - ) { - final theme = Theme.of(context); - final isOffline = connectionState.isOffline; +class _ChatsTab extends StatefulWidget { + const _ChatsTab(); + + @override + State<_ChatsTab> createState() => _ChatsTabState(); +} + +class _ChatsTabState extends State<_ChatsTab> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final inbox = context.watch(); + final connection = context.watch(); - final searchQuery = _searchController.text.toLowerCase(); - final filteredInbox = inboxState.inbox.where((thread) { - final title = thread.title.toLowerCase(); - final username = thread.username?.toLowerCase() ?? ''; - return title.contains(searchQuery) || username.contains(searchQuery); - }).toList(); + final query = _query.toLowerCase(); + final items = query.isEmpty + ? inbox.inbox + : inbox.inbox.where((item) { + return item.title.toLowerCase().contains(query) || + (item.username?.toLowerCase().contains(query) ?? false) || + item.lastMessage.toLowerCase().contains(query); + }).toList(); return RefreshIndicator( - color: theme.colorScheme.primary, - onRefresh: _isSelectionMode ? () async {} : () => inboxState.loadInbox(), + edgeOffset: 120, + onRefresh: () => inbox.loadInbox(), child: CustomScrollView( - controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ - SliverAppBar( - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - pinned: true, - centerTitle: false, - elevation: 0, - backgroundColor: Colors.transparent, - flexibleSpace: ClipRRect( - clipBehavior: Clip.antiAlias, - child: BackdropFilter( - enabled: true, - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container(color: theme.colorScheme.surface), - ), - ), - leading: _isSelectionMode - ? null - : InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => SettingsPage()), - ); - }, - child: Hero( - tag: 'User Profile', - child: Padding( - padding: const EdgeInsets.only( - left: 16.0, - top: 10.0, - bottom: 10.0, - ), - child: CircleAvatar( - radius: 18, - backgroundColor: theme.colorScheme.primary.withValues( - alpha: 0.15, - ), - child: Icon( - Icons.person, - color: theme.colorScheme.primary, - size: 18, - ), - ), - ), - ), - ), - leadingWidth: 52, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: _isSelectionMode - ? Text( - '${_selectedChatIds.length} Selected', - style: TextStyle(color: theme.colorScheme.onSurface), - ) - : AnimatedSwitcher( - switchInCurve: Curves.decelerate, - switchOutCurve: Curves.decelerate, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - final tween = Tween( - begin: const Offset(0.5, 0.0), - end: Offset.zero, - ); - final offsetAnimation = tween.animate(animation); - return SlideTransition( - position: offsetAnimation, - child: child, - ); - }, - child: _isSearchOpen - ? SizedBox( - height: 40, - child: TextField( - controller: _searchController, - key: const ValueKey('search'), - autofocus: true, - style: TextStyle( - color: theme.colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: 'Search conversations...', - hintStyle: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - ), - filled: true, - fillColor: theme.colorScheme.onSurface - .withValues(alpha: 0.08), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 0, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - ), - ), - ) - : Text( - 'Elephant', - style: TextStyle( - letterSpacing: 0.5, - fontSize: 20, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - key: const ValueKey('title'), - ), - ), - ), + _HomeHeader( + title: 'Chats', actions: [ - _isSelectionMode - ? const SizedBox.shrink() - : IconButton( - onPressed: () { - setState(() { - _isSearchOpen = !_isSearchOpen; - if (!_isSearchOpen) { - FocusScope.of(context).unfocus(); - _searchController.clear(); - } - }); - }, - icon: Icon( - _isSearchOpen ? Icons.close : Icons.search, - color: theme.colorScheme.onSurface, + PopupMenuButton( + icon: const Icon(Icons.more_horiz_rounded), + onSelected: (value) async { + if (value == 'read') { + await inbox.markAllRead(); + if (context.mounted) { + showSnack(context, 'All chats marked as read'); + } + } else if (value == 'group') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SelectContactPage(), ), - ), - if (!_isSearchOpen) - !_isSelectionMode - ? PopupMenuButton( - iconColor: theme.colorScheme.onSurface, - color: theme.scaffoldBackgroundColor, - onSelected: (String value) {}, - borderRadius: BorderRadius.circular(20), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'starred', - child: Text('Starred messages'), - ), - const PopupMenuItem( - value: 'read all', - child: Text('Read all'), - ), - ], - ) - : PopupMenuButton( - iconColor: theme.colorScheme.onSurface, - onSelected: (String value) { - switch (value) { - case 'Select all': - setState(() { - for (final thread in inboxState.inbox) { - _selectedChatIds.add(thread.id); - } - }); - break; - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'Select all', - child: Text("Select all"), - ), - const PopupMenuItem( - value: 'Favorite', - child: Text("Favorite"), - ), - const PopupMenuItem( - value: 'Read', - child: Text("Read"), - ), - const PopupMenuItem( - value: 'Delete', - child: Text("Delete"), - ), - ], - ), - const SizedBox(width: 4), + ); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'read', + enabled: inbox.unreadChats > 0, + child: const Text('Mark all as read'), + ), + const PopupMenuItem(value: 'group', child: Text('New group')), + ], + ), ], ), - if (isOffline) - SliverToBoxAdapter( - child: Container( - color: Colors.redAccent, - width: double.infinity, - padding: const EdgeInsets.all(4), - child: const Text( - "You are offline. Showing cached chats.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white, fontSize: 12), - ), + SliverToBoxAdapter( + child: _ConnectionBanner( + status: connection.status, + onRetry: connection.retryNow, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page, + Insets.xs, + Insets.page, + Insets.sm, + ), + child: GlassSearchField( + controller: _searchController, + hint: 'Search chats', + onChanged: (value) => setState(() => _query = value.trim()), ), ), - filteredInbox.isEmpty - ? SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - searchQuery.isNotEmpty - ? "No conversation found" - : "No conversations yet", - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, + ), + if (items.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: !inbox.hasLoadedOnce && inbox.isLoading + ? const Center(child: CircularProgressIndicator()) + : query.isNotEmpty + ? EmptyState( + icon: Icons.search_off_rounded, + title: 'No matches', + message: 'No chat matches "$_query".', + ) + : EmptyState( + icon: Icons.forum_rounded, + title: 'No conversations yet', + message: + 'Find someone by their username and say hello. Direct messages are end-to-end encrypted.', + actionLabel: 'Start a chat', + onAction: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NewChatPage()), ), ), - ), - ) - : SliverList.builder( - itemCount: filteredInbox.length, - itemBuilder: (context, index) { - final thread = filteredInbox[index]; - - final bool isSelected = _selectedChatIds.contains( - thread.id, - ); - - return CustomChatCard( - isSelectionMode: _isSelectionMode, - conversation: thread, - isSelected: isSelected, - onLongPress: () { - if (!_isSearchOpen) { - setState(() { - _selectedChatIds.add(thread.id); - }); - } - }, - onTapInSelection: () { - setState(() { - if (_selectedChatIds.contains(thread.id)) { - _selectedChatIds.remove(thread.id); - } else { - _selectedChatIds.add(thread.id); - } - }); - }, - ); + ) + else + SliverList.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ConversationTile( + key: ValueKey(item.id), + conversation: item, + onTap: () => openConversation(context, item), + onLongPress: () { + HapticFeedback.mediumImpact(); + _showConversationActions(context, item); }, - ), - const SliverPadding(padding: EdgeInsets.only(bottom: 120)), + ); + }, + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 140)), ], ), ); } +} + +class _GroupsTab extends StatelessWidget { + const _GroupsTab(); @override Widget build(BuildContext context) { - final inboxState = context.watch(); - final connectionState = context.watch(); - final theme = Theme.of(context); - final bool showFab = inboxState.inbox.isEmpty || _isFabVisible; - return PopScope( - canPop: _selectedChatIds.isEmpty, - onPopInvokedWithResult: (didPop, result) { - if (didPop) return; - setState(() { - _selectedChatIds.clear(); - }); - }, - child: Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBodyBehindAppBar: true, - extendBody: true, - body: IndexedStack( - index: _page, - children: [ - Center( - child: Text( - "No stories available", - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 16, + final inbox = context.watch(); + final groups = inbox.groups; + + return RefreshIndicator( + edgeOffset: 120, + onRefresh: () => inbox.loadInbox(), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + const _HomeHeader(title: 'Groups'), + if (groups.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.groups_rounded, + title: 'No groups yet', + message: + 'Create a group to talk with several people at once. Group messages are not end-to-end encrypted yet.', + actionLabel: 'Create a group', + onAction: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SelectContactPage()), ), ), + ) + else ...[ + SliverToBoxAdapter( + child: SectionLabel( + '${groups.length} ${groups.length == 1 ? 'group' : 'groups'}', + ), + ), + SliverList.builder( + itemCount: groups.length, + itemBuilder: (context, index) { + final item = groups[index]; + return ConversationTile( + key: ValueKey(item.id), + conversation: item, + onTap: () => openConversation(context, item), + onLongPress: () => _showConversationActions(context, item), + ); + }, ), - buildHomeTab(inboxState, connectionState), - Center( - child: Text( - "No recent calls", - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 16, + ], + const SliverPadding(padding: EdgeInsets.only(bottom: 140)), + ], + ), + ); + } +} + +/// Large title that collapses into a frosted bar on scroll. +class _HomeHeader extends StatelessWidget { + final String title; + final List? actions; + + const _HomeHeader({required this.title, this.actions}); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + pinned: true, + expandedHeight: 104, + backgroundColor: Colors.transparent, + actions: [ + ...?actions, + const SizedBox(width: Insets.sm), + ], + flexibleSpace: Stack( + fit: StackFit.expand, + children: [ + const GlassHeaderBackground(), + FlexibleSpaceBar( + titlePadding: const EdgeInsetsDirectional.only( + start: Insets.page, + bottom: 14, + ), + expandedTitleScale: 1.55, + title: Text( + title, + style: context.text.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } +} + +class _ConnectionBanner extends StatelessWidget { + final ConnectionStatus status; + final VoidCallback onRetry; + + const _ConnectionBanner({required this.status, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final visible = status != ConnectionStatus.connected; + final isOffline = status == ConnectionStatus.offline; + final color = isOffline ? context.colors.error : context.glass.warning; + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: !visible + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page, + Insets.xs, + Insets.page, + Insets.sm, + ), + child: GlassSurface( + borderRadius: BorderRadius.circular(Radii.md), + tint: color.withValues(alpha: 0.12), + padding: const EdgeInsets.fromLTRB(14, 6, 6, 6), + child: Row( + children: [ + if (isOffline) + Icon(Icons.cloud_off_rounded, size: 18, color: color) + else + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isOffline + ? 'You\'re offline. Messages will send when you reconnect.' + : 'Connecting…', + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + if (!isOffline) + TextButton(onPressed: onRetry, child: const Text('Retry')) + else + const SizedBox(height: 40), + ], ), ), ), + ); + } +} + +class _ComposeButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const _ComposeButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: context.glass.accentGradient, + borderRadius: BorderRadius.circular(Radii.lg), + boxShadow: [ + BoxShadow( + color: context.colors.primary.withValues(alpha: 0.4), + blurRadius: 22, + offset: const Offset(0, 10), + ), ], ), - floatingActionButton: _page == 1 - ? AnimatedSlide( - duration: const Duration(milliseconds: 300), - offset: (showFab && !_isSelectionMode) - ? Offset.zero - : const Offset(0, 2), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: (showFab && !_isSelectionMode) ? 1.0 : 0.0, - child: Padding( - padding: const EdgeInsets.only(bottom: 10), - 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(), - ), - ); - }, - ), - ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.lg), + onTap: onTap, + child: SizedBox( + width: 60, + height: 60, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Icon( + icon, + key: ValueKey(icon), + color: context.glass.onAccent, + size: 26, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _GlassNavBar extends StatelessWidget { + final int index; + final int unreadChats; + final ValueChanged onChanged; + + const _GlassNavBar({ + required this.index, + required this.unreadChats, + required this.onChanged, + }); + + static const _items = [ + (Icons.chat_bubble_outline_rounded, Icons.chat_bubble_rounded, 'Chats'), + (Icons.groups_outlined, Icons.groups_rounded, 'Groups'), + (Icons.person_outline_rounded, Icons.person_rounded, 'You'), + ]; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page, + 0, + Insets.page, + Insets.md, + ), + child: GlassSurface( + strong: true, + borderRadius: BorderRadius.circular(Radii.xl), + shadows: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 30, + offset: const Offset(0, 12), + ), + ], + child: SizedBox( + height: 66, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth / _items.length; + return Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + left: index * width + 10, + top: 8, + bottom: 8, + width: width - 20, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.lg), + color: context.colors.primary.withValues(alpha: 0.13), ), ), ), - ), + Row( + children: [ + for (int i = 0; i < _items.length; i++) + _navItem(context, i, width), + ], + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + Widget _navItem(BuildContext context, int i, double width) { + final selected = i == index; + final (icon, activeIcon, label) = _items[i]; + final color = selected + ? context.colors.primary + : context.colors.onSurfaceVariant; + final showBadge = i == 0 && unreadChats > 0; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(i), + child: SizedBox( + width: width, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Badge( + isLabelVisible: showBadge, + label: Text('$unreadChats'), + backgroundColor: context.colors.primary, + textColor: context.glass.onAccent, + child: AnimatedScale( + scale: selected ? 1.08 : 1, + duration: const Duration(milliseconds: 220), + child: Icon( + selected ? activeIcon : icon, + color: color, + size: 24, ), - ) - : null, - bottomNavigationBar: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - transitionBuilder: (child, animation) { - final tween = Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ); - final offsetAnimation = tween.animate(animation); - return SlideTransition(position: offsetAnimation, child: child); - }, - child: _isSelectionMode - ? const SizedBox.shrink(key: ValueKey('nav_hidden')) - : buildGlassNavigationBar(key: const ValueKey('nav_visible')), + ), + ), + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 11.5, + color: color, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + ], ), ), ); diff --git a/mobile/lib/pages/new chat/new_chat_page.dart b/mobile/lib/pages/new chat/new_chat_page.dart index 738e838..027b3cb 100644 --- a/mobile/lib/pages/new chat/new_chat_page.dart +++ b/mobile/lib/pages/new chat/new_chat_page.dart @@ -1,11 +1,14 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:mobile/controllers/chat/active_chat_controller.dart'; import 'package:mobile/controllers/chat/chat_search_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; +import 'package:mobile/models/user.dart'; import 'package:mobile/pages/new%20chat/select_contact_page.dart'; import 'package:mobile/pages/settings/settings_page.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; import '../chat/chat_page.dart'; @@ -18,104 +21,79 @@ class NewChatPage extends StatefulWidget { class _NewChatPageState extends State { final _searchController = TextEditingController(); - Timer? _debounceTimer; + Timer? _debounce; + late final ChatSearchController _search = context.read(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _search.clearSearch()); + } @override void dispose() { - _debounceTimer?.cancel(); + _debounce?.cancel(); _searchController.dispose(); + Future.microtask(_search.clearSearch); super.dispose(); } - void _onSearchChanged(String value, ChatSearchController searchChat) { - if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + void _onChanged(String value) { + setState(() {}); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () => _search.queryUsers(value)); + } - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - searchChat.queryUsers(value); - }); + void _open(String id, String name, String? username) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ChatPage(chatUserId: id, displayName: name, username: username), + ), + ); } - void _triggerUsernameSearchDialog() { - final inputController = TextEditingController(); - showDialog( - context: context, - builder: (context) => BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - title: Text( - "Find by Username", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - content: TextField( - controller: inputController, - autofocus: true, - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), - decoration: InputDecoration( - hintText: "Enter minimum 3 characters...", - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), + @override + Widget build(BuildContext context) { + final search = context.watch(); + final inbox = context.watch(); + final query = _searchController.text.trim(); + final isSearching = query.isNotEmpty; + + return Scaffold( + extendBodyBehindAppBar: true, + appBar: GlassAppBar( + title: const Text('New message'), + actions: [ + IconButton( + tooltip: 'My QR code', + icon: const Icon(Icons.qr_code_rounded), + onPressed: () => showUserCard(context), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - "Cancel", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: Insets.xs), + ], + ), + body: AmbientBackground( + intensity: 0.5, + child: Column( + children: [ + SizedBox(height: MediaQuery.paddingOf(context).top + kToolbarHeight + Insets.md), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + child: GlassSearchField( + controller: _searchController, + hint: 'Search name or username', + autofocus: true, + onChanged: _onChanged, ), ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - ), - onPressed: () { - final text = inputController.text.trim(); - if (text.length >= 3) { - context.read().queryUsers(text); - setState(() { - _searchController.text = text; - }); - Navigator.pop(context); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Search query must be at least 3 characters.", - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - ), - ); - } - }, - child: Text( - "Search", - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - ), + if (search.isSearchLoading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: Insets.page, vertical: Insets.sm), + child: LinearProgressIndicator(minHeight: 2), ), + Expanded( + child: isSearching ? _buildResults(search, query) : _buildIdle(inbox), ), ], ), @@ -123,306 +101,121 @@ class _NewChatPageState extends State { ); } - Widget _buildActionRow({ - required IconData icon, - required String label, - required VoidCallback onTap, - }) { - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), - leading: CircleAvatar( - radius: 20, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Icon( - icon, - color: Theme.of(context).colorScheme.onSurfaceVariant, - size: 20, - ), - ), - title: Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, + Widget _buildIdle(InboxController inbox) { + final recents = inbox.inbox.where((t) => !t.isGroup).toList(); + return ListView( + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom + Insets.xl), + children: [ + const SizedBox(height: Insets.lg), + GlassSection( + children: [ + ElephantTile( + icon: Icons.group_add_rounded, + title: 'New group', + subtitle: 'Chat with several people at once', + onTap: () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const SelectContactPage()), + ), + ), + ], ), - ), - onTap: onTap, + if (recents.isNotEmpty) ...[ + const SectionLabel('Recent'), + for (final thread in recents) + _PersonTile( + id: thread.id, + name: thread.title, + username: thread.username, + onTap: () => _open(thread.id, thread.title, thread.username), + ), + ] else + Padding( + padding: const EdgeInsets.all(Insets.xxl), + child: Text( + 'Search for someone by their username, like alex.4821, to start an encrypted chat.', + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), + ), + ), + ], ); } - @override - Widget build(BuildContext context) { - final searchState = context.watch(); - final inboxState = context.watch(); - final activeChatState = context.read(); - final String searchInput = _searchController.text.trim(); - final bool isSearching = searchInput.isNotEmpty; - final bool hasValidQueryLength = searchInput.length >= 3; + Widget _buildResults(ChatSearchController search, String query) { + if (query.length < ChatSearchController.minQueryLength) { + return _hint('Keep typing…'); + } + if (search.error != null) return _hint(search.error!); + if (!search.isSearchLoading && search.results.isEmpty) { + return const EmptyState( + icon: Icons.person_search_rounded, + title: 'No one found', + message: 'Check the spelling, or ask them for their full username.', + ); + } - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - scrolledUnderElevation: 0, - iconTheme: IconThemeData( - color: Theme.of(context).colorScheme.onSurface, - ), - title: Text( - 'New message', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - actions: [ - PopupMenuButton( - onSelected: (value) { - switch (value) { - case 'qr': - showUserCard(context); - break; - default: - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'qr', - child: Text("Share username by QR"), - ), - ], - ), - ], + final List users = search.results; + return NotificationListener( + onNotification: (n) { + if (n.metrics.extentAfter < 200) search.loadMore(); + return false; + }, + child: ListView.builder( + padding: EdgeInsets.only(top: Insets.sm, bottom: MediaQuery.paddingOf(context).bottom + Insets.xl), + itemCount: users.length + (search.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == users.length) { + return const Padding( + padding: EdgeInsets.all(Insets.lg), + child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ); + } + final user = users[index]; + return _PersonTile( + id: user.id, + name: user.displayName, + username: user.username, + onTap: () => _open(user.id, user.displayName, user.username), + ); + }, ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: TextField( - controller: _searchController, - onChanged: (val) => _onSearchChanged(val, searchState), - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), - decoration: InputDecoration( - hintText: "Name, username or number", - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 15, - ), - prefixIcon: Icon( - Icons.search, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - suffixIcon: isSearching - ? IconButton( - icon: Icon( - Icons.clear, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - onPressed: () { - _searchController.clear(); - searchState.queryUsers(""); - }, - ) - : null, - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), - ), - ), - ), - Expanded( - child: isSearching - ? (!hasValidQueryLength - ? Center( - child: Text( - "Type at least 3 characters to search...", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ) - : searchState.isSearchLoading - ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ) - : searchState.contactSearchResults.isEmpty - ? Center( - child: Text( - "No users found", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 15, - ), - ), - ) - : ListView.builder( - itemCount: searchState.contactSearchResults.length, - itemBuilder: (context, index) { - final user = - searchState.contactSearchResults[index]; - final String displayName = - user['display_name'] ?? 'User'; - final String username = user['username'] ?? ''; + ); + } - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - leading: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - child: Icon( - Icons.person, - color: Theme.of(context).colorScheme.primary, - ), - ), - title: Text( - displayName, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text( - "@$username", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - onTap: () { - final String uid = user['id']; - activeChatState.openChat(uid); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: uid, - displayName: displayName, - ), - ), - ); - }, - ); - }, - )) - : ListView( - children: [ - _buildActionRow( - icon: Icons.group, - label: "New group", - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SelectContactPage(), - ), - ); - }, - ), - _buildActionRow( - icon: Icons.alternate_email, - label: "Find by username", - onTap: _triggerUsernameSearchDialog, - ), - _buildActionRow( - icon: Icons.tag, - label: "Find by phone number", - onTap: () {}, - ), - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 20, - bottom: 8, - ), - child: Text( - "Recent Contacts", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ), - if (inboxState.inbox.isNotEmpty) - ...inboxState.inbox - .where((thread) => !thread.isGroup) - .map( - (thread) => ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - leading: CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - title: Text( - thread.title, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - subtitle: Text( - "@${thread.username}", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - onTap: () { - activeChatState.openChat(thread.id); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: thread.id, - displayName: thread.title, - ), - ), - ); - }, - ), - ), - ], - ), - ), - ], + Widget _hint(String text) { + return Padding( + padding: const EdgeInsets.all(Insets.xxl), + child: Text( + text, + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), ); } } + +class _PersonTile extends StatelessWidget { + final String id; + final String name; + final String? username; + final VoidCallback onTap; + + const _PersonTile({ + required this.id, + required this.name, + required this.username, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Insets.page, vertical: 2), + leading: ElephantAvatar(name: name, seed: id, size: 46), + title: Text(name), + subtitle: username != null ? Text('@$username') : null, + onTap: onTap, + ); + } +} diff --git a/mobile/lib/pages/new chat/select_contact_page.dart b/mobile/lib/pages/new chat/select_contact_page.dart index b370a43..408ac77 100644 --- a/mobile/lib/pages/new chat/select_contact_page.dart +++ b/mobile/lib/pages/new chat/select_contact_page.dart @@ -1,12 +1,17 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:mobile/controllers/chat/chat_search_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/chat/chat_page.dart'; import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; +/// Step 1 of creating a group: pick members, then name the group. class SelectContactPage extends StatefulWidget { const SelectContactPage({super.key}); @@ -14,553 +19,299 @@ class SelectContactPage extends StatefulWidget { State createState() => _SelectContactPageState(); } -class _SelectContactPageState extends State { - final Map> _selectedContacts = {}; - - Timer? _debounceTimer; - bool _isSearchOpen = false; +typedef _Contact = ({String name, String username}); +class _SelectContactPageState extends State { + final Map _selected = {}; final _searchController = TextEditingController(); - final _groupNameController = TextEditingController(); - - void _onSearchChanged(String value) { - if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); - - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - if (mounted) { - context.read().queryUsers(value); - } - }); - } + Timer? _debounce; + late final ChatSearchController _search = context.read(); - void _toggleSelection(String id, String displayName, String username) { - setState(() { - if (_selectedContacts.containsKey(id)) { - _selectedContacts.remove(id); - } else { - _selectedContacts[id] = { - 'displayName': displayName, - 'username': username, - }; - } - }); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _search.clearSearch()); } @override void dispose() { - _debounceTimer?.cancel(); + _debounce?.cancel(); _searchController.dispose(); - _groupNameController.dispose(); + Future.microtask(_search.clearSearch); super.dispose(); } - Widget _buildContactTile({ - required String id, - required String displayName, - required String username, - }) { - final bool isSelected = _selectedContacts.containsKey(id); + void _onChanged(String value) { + setState(() {}); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () => _search.queryUsers(value)); + } - return ListTile( - selected: isSelected, - selectedTileColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.12), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: Stack( - children: [ - 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: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - size: 18, - ), - ), - ), - ], - ), - title: Text( - displayName, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text( - username.startsWith('@') ? username : "@$username", - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - onTap: () => _toggleSelection(id, displayName, username), + void _toggle(String id, String name, String username) { + setState(() { + if (_selected.remove(id) == null) { + _selected[id] = (name: name, username: username); + } + }); + } + + void _next() { + showGlassSheet( + context, + title: 'Name your group', + builder: (_) => _CreateGroupSheet(members: Map.of(_selected)), ); } @override Widget build(BuildContext context) { - final searchState = context.watch(); - final inboxState = context.watch(); - final bool isSearching = _searchController.text.trim().isNotEmpty; + final search = context.watch(); + final inbox = context.watch(); + final query = _searchController.text.trim(); + final typed = query.length >= ChatSearchController.minQueryLength; + + final List<(String, String, String)> people = typed + ? search.results.map((u) => (u.id, u.displayName, u.username)).toList() + : inbox.inbox + .where((t) => !t.isGroup) + .map((t) => (t.id, t.title, t.username ?? '')) + .toList(); return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - scrolledUnderElevation: 0, - title: AnimatedSwitcher( - switchInCurve: Curves.decelerate, - switchOutCurve: Curves.decelerate, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - final tween = Tween( - begin: const Offset(0.5, 0.0), - end: Offset.zero, - ); - return SlideTransition( - position: tween.animate(animation), - child: child, - ); - }, - child: _isSearchOpen - ? SizedBox( - height: 40, - child: TextField( - controller: _searchController, - onChanged: _onSearchChanged, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - autofocus: true, - decoration: InputDecoration( - hintText: "Name, username or number", - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 15, - ), - suffixIcon: isSearching - ? IconButton( - icon: Icon( - Icons.clear, - color: Theme.of(context).colorScheme.onSurface, + extendBodyBehindAppBar: true, + appBar: GlassAppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('New group'), + Text( + _selected.isEmpty ? 'Add members' : '${_selected.length} selected', + style: context.text.bodySmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + ], + ), + ), + floatingActionButton: AnimatedScale( + scale: _selected.isEmpty ? 0 : 1, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: FloatingActionButton.extended( + onPressed: _selected.isEmpty ? null : _next, + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('Next'), + ), + ), + body: AmbientBackground( + intensity: 0.5, + child: Column( + children: [ + SizedBox(height: MediaQuery.paddingOf(context).top + kToolbarHeight + Insets.md), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: _selected.isEmpty + ? const SizedBox(width: double.infinity) + : SizedBox( + height: 92, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + children: [ + for (final entry in _selected.entries) + Padding( + padding: const EdgeInsets.only(right: Insets.md), + child: GestureDetector( + onTap: () => _toggle(entry.key, entry.value.name, entry.value.username), + child: SizedBox( + width: 62, + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ElephantAvatar(name: entry.value.name, seed: entry.key, size: 52), + Positioned( + right: -4, + top: -4, + child: CircleAvatar( + radius: 10, + backgroundColor: context.colors.onSurface, + child: Icon(Icons.close_rounded, size: 13, color: context.colors.surface), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + entry.value.name.split(' ').first, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), ), - onPressed: () { - _searchController.clear(); - context.read().queryUsers( - "", - ); - }, - ) - : null, - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, + ), + ], ), ), - ), - ) - : Text( - 'Select Contacts', - style: TextStyle( - letterSpacing: 0.5, - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - key: const ValueKey('title'), - ), - ), - actions: [ - IconButton( - onPressed: () { - setState(() { - _isSearchOpen = !_isSearchOpen; - if (!_isSearchOpen) { - _searchController.clear(); - context.read().queryUsers(""); - } - }); - }, - icon: Icon( - _isSearchOpen ? Icons.close : Icons.search, - color: Theme.of(context).colorScheme.onSurface, ), - ), - ], - ), - body: Column( - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - child: _selectedContacts.isNotEmpty - ? Container( - width: double.infinity, - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.12), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Text( - '${_selectedContacts.length} selected', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - fontSize: 14, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + child: GlassSearchField( + controller: _searchController, + hint: 'Search name or username', + onChanged: _onChanged, + ), + ), + if (search.isSearchLoading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: Insets.page, vertical: Insets.sm), + child: LinearProgressIndicator(minHeight: 2), + ), + if (!typed && people.isNotEmpty) const SectionLabel('Recent chats'), + Expanded( + child: people.isEmpty + ? Padding( + padding: const EdgeInsets.all(Insets.xxl), + child: Text( + search.error ?? + (typed + ? 'No one found.' + : 'Search for people by name or username to add them.'), + textAlign: TextAlign.center, + style: TextStyle(color: context.colors.onSurfaceVariant), ), - ), - ) - : const SizedBox.shrink(), - ), - Expanded( - child: isSearching - ? (searchState.isSearchLoading - ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, + ) + : ListView.builder( + padding: EdgeInsets.only(bottom: 100 + MediaQuery.paddingOf(context).bottom), + itemCount: people.length, + itemBuilder: (context, index) { + final (id, name, username) = people[index]; + final selected = _selected.containsKey(id); + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Insets.page, vertical: 2), + leading: ElephantAvatar(name: name, seed: id, size: 46, showSelected: selected), + title: Text(name), + subtitle: Text('@$username'), + trailing: Checkbox( + value: selected, + shape: const CircleBorder(), + onChanged: (_) => _toggle(id, name, username), ), - ) - : searchState.contactSearchResults.isEmpty - ? Center( - child: Text( - "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( - itemCount: searchState.contactSearchResults.length, - itemBuilder: (context, index) { - final user = - searchState.contactSearchResults[index]; - final String id = - (user['id'] ?? user['user_id'] ?? '') - .toString(); - final String displayName = - user['display_name'] ?? 'User'; - final String username = user['username'] ?? ''; + onTap: () => _toggle(id, name, username), + ); + }, + ), + ), + ], + ), + ), + ); + } +} - if (id.isEmpty) return const SizedBox.shrink(); +class _CreateGroupSheet extends StatefulWidget { + final Map members; - return _buildContactTile( - id: id, - displayName: displayName, - username: username, - ); - }, - )) - : ListView( - children: [ - if (_selectedContacts.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 20, - bottom: 8, - ), - child: Text( - "Selected Contacts", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ), - ..._selectedContacts.entries.map( - (entry) => _buildContactTile( - id: entry.key, - displayName: entry.value['displayName']!, - username: entry.value['username']!, - ), - ), - ], - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 20, - bottom: 8, - ), - child: Text( - "Recent Contacts", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ), - if (inboxState.inbox.isNotEmpty) - ...inboxState.inbox - .where( - (thread) => - !thread.isGroup && - !_selectedContacts.containsKey(thread.id), - ) - .map( - (thread) => _buildContactTile( - id: thread.id, - displayName: thread.title, - username: thread.username ?? "", - ), - ), - ], - ), - ), - ], - ), - floatingActionButton: _selectedContacts.isNotEmpty - ? FloatingActionButton( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - elevation: 4, - child: Icon( - Icons.arrow_forward, - color: Theme.of(context).colorScheme.onPrimary, - ), - onPressed: () { - context.read().setContacts(_selectedContacts); + const _CreateGroupSheet({required this.members}); - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - builder: (BuildContext bottomSheetContext) { - return Consumer( - 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), - ), - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.surface - .withValues(alpha: 0.5), - width: 1, - ), - ), - ), - 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; + @override + State<_CreateGroupSheet> createState() => _CreateGroupSheetState(); +} - final memberIds = - _selectedContacts.keys - .toList(); +class _CreateGroupSheetState extends State<_CreateGroupSheet> { + final _name = TextEditingController(); - final rootNavigator = - Navigator.of(context); + @override + void dispose() { + _name.dispose(); + super.dispose(); + } - final newGroup = - await modalContext - .read< - GroupController - >() - .createGroup( - groupName: - groupName, - memberIds: - memberIds, - ); + Future _create() async { + final name = _name.text.trim(); + if (name.isEmpty) return; - if (newGroup != null) { - if (!context.mounted) - return; + final groups = context.read(); + final inbox = context.read(); + final navigator = Navigator.of(context); - context - .read() - .loadInbox(); + final group = await groups.createGroup(groupName: name, memberIds: widget.members.keys.toList()); + if (!mounted) return; - Navigator.of( - bottomSheetContext, - ).pop(); + if (group == null) { + showSnack(context, groups.lastError ?? 'Couldn\'t create the group.', isError: true); + return; + } - _groupNameController - .clear(); + unawaited(inbox.loadInbox()); + navigator.pop(true); + navigator.pushReplacement( + MaterialPageRoute( + builder: (_) => ChatPage(chatUserId: group.id, displayName: group.name, isGroup: true), + ), + ); + if (groups.failedMemberIds.isNotEmpty) { + showSnack( + navigator.context, + '${groups.failedMemberIds.length} member(s) couldn\'t be added. Try again from group info.', + isError: true, + ); + } + } - 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, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - }, - ); - }, - ); - }, - ) - : null, + @override + Widget build(BuildContext context) { + final isLoading = context.watch().isLoading; + return Padding( + padding: const EdgeInsets.fromLTRB(Insets.xl, Insets.sm, Insets.xl, Insets.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + ValueListenableBuilder( + valueListenable: _name, + builder: (_, value, _) => ElephantAvatar( + name: value.text, + seed: value.text.isEmpty ? 'new-group' : value.text, + isGroup: true, + size: 56, + ), + ), + const SizedBox(width: Insets.lg), + Expanded( + child: TextField( + controller: _name, + autofocus: true, + maxLength: 100, + textCapitalization: TextCapitalization.sentences, + onSubmitted: (_) => _create(), + decoration: const InputDecoration(hintText: 'Group name', counterText: ''), + ), + ), + ], + ), + const SizedBox(height: Insets.lg), + Text( + '${widget.members.length + 1} members including you. Group messages are not end-to-end encrypted yet.', + style: context.text.bodySmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + const SizedBox(height: Insets.xl), + ValueListenableBuilder( + valueListenable: _name, + builder: (_, value, _) => GradientButton( + label: 'Create group', + icon: Icons.check_rounded, + isLoading: isLoading, + onPressed: value.text.trim().isEmpty ? null : _create, + ), + ), + ], + ), ); } } diff --git a/mobile/lib/pages/settings/accounts.dart b/mobile/lib/pages/settings/accounts.dart index 1e4ed9f..12678ea 100644 --- a/mobile/lib/pages/settings/accounts.dart +++ b/mobile/lib/pages/settings/accounts.dart @@ -1,211 +1,156 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth_state.dart'; +import 'package:mobile/controllers/chat/active_chat_controller.dart'; import 'package:mobile/controllers/chat/chat_connection_controller.dart'; import 'package:mobile/controllers/chat/group_details_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; -import 'package:mobile/main.dart'; +import 'package:mobile/core/constants.dart'; import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; +/// Ends the session and returns to the sign-in screen. Encryption keys stay on +/// the device so the same account can sign back in without losing history. +Future signOut(BuildContext context) async { + final navigator = Navigator.of(context); + context.read().disconnectWebSocket(); + context.read().clearCache(); + context.read().clearInbox(); + context.read().clearGroupData(); + final activeChat = context.read(); + if (activeChat.currentChatUserId != null) { + activeChat.closeChat(activeChat.currentChatUserId!); + } + await context.read().logout(); + navigator.popUntil((route) => route.isFirst); +} + class AccountsSettings extends StatelessWidget { const AccountsSettings({super.key}); + void _copy(BuildContext context, String value, String label) { + Clipboard.setData(ClipboardData(text: value)); + showSnack(context, '$label copied'); + } + @override Widget build(BuildContext context) { final user = context.watch().currentUser; - final theme = Theme.of(context); + return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - appBar: AppBar( - 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: [ - SizedBox( - width: double.infinity, - height: 150, - child: Padding( - padding: const EdgeInsets.only( - left: 16.0, - top: 10.0, - bottom: 10.0, - ), + extendBodyBehindAppBar: true, + appBar: const GlassAppBar(title: Text('Account')), + body: AmbientBackground( + intensity: 0.7, + child: ListView( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + kToolbarHeight + Insets.xl, + bottom: Insets.xxl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + Center( child: Hero( - tag: 'User Profile', - child: CircleAvatar( - backgroundColor: theme.colorScheme.primaryContainer, - child: Icon( - Icons.person, - color: theme.colorScheme.onPrimaryContainer, - size: 40, - ), + tag: 'me-avatar', + child: ElephantAvatar( + name: user?.displayName ?? '?', + seed: user?.id ?? '', + size: 104, ), ), ), - ), - Center( - child: Hero( - tag: 'User Data', - child: Material( - type: MaterialType.transparency, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - user != null ? user.displayName : 'Profile N/A', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - ), - GestureDetector( - onTap: () async { - await Clipboard.setData( - ClipboardData(text: user!.username), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Copied to clipboard!"), - duration: Duration(seconds: 2), - ), - ); - } - }, - child: Text( - user != null - ? '@${user.username}' - : 'Could not load profile', - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), + const SizedBox(height: Insets.lg), + Text( + user?.displayName ?? '', + textAlign: TextAlign.center, + style: context.text.headlineMedium?.copyWith(fontSize: 26), ), - ), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.only(left: 16, bottom: 8), - child: Text( - "Details", - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - fontSize: 13, + if (user?.createdAt != null) + Text( + 'Member since ${DateFormat.yMMMM().format(user!.createdAt!)}', + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), + const SectionLabel('Profile'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.alternate_email_rounded, + title: user != null ? '@${user.username}' : 'β€”', + subtitle: + 'Your username. Use it to sign in; others use it to find you.', + trailing: const Icon(Icons.copy_rounded, size: 18), + onTap: user == null + ? null + : () => _copy(context, user.username, 'Username'), + ), + ElephantTile( + icon: Icons.badge_outlined, + title: user?.displayName ?? 'β€”', + subtitle: 'Display name', + showChevron: false, + ), + if (user?.email != null) + ElephantTile( + icon: Icons.mail_outline_rounded, + title: user!.email!, + subtitle: 'Email from Google or GitHub sign-in', + showChevron: false, + ), + ElephantTile( + icon: Icons.fingerprint_rounded, + title: 'User ID', + subtitle: user?.id ?? 'β€”', + trailing: const Icon(Icons.copy_rounded, size: 18), + onTap: user == null + ? null + : () => _copy(context, user.id, 'User ID'), + ), + ], ), - ), - - // Email Tile - ListTile( - 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: TextStyle(color: theme.colorScheme.onSurface), - ), - trailing: Icon( - Icons.edit, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - onTap: () { - // TODO: Navigate to edit email page - }, - ), - - // Phone Number Tile - ListTile( - leading: Icon( - Icons.phone_outlined, - color: theme.colorScheme.onSurfaceVariant, - ), - title: const Text("Phone Number"), - subtitle: Text( - "Not provided", - style: TextStyle(color: theme.colorScheme.onSurface), - ), - trailing: Icon( - Icons.edit, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - onTap: () {}, - ), - - // Bio Tile - ListTile( - leading: Icon( - Icons.info_outline, - color: theme.colorScheme.onSurfaceVariant, - ), - title: const Text("About"), - subtitle: Text( - "Hey there! I am using Elephant.", - style: TextStyle(color: theme.colorScheme.onSurface), - ), - trailing: Icon( - Icons.edit, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - onTap: () {}, - ), - - const SizedBox(height: 30), - FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(theme.colorScheme.error), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), + const SectionLabel('Server'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.dns_outlined, + title: Env.isDefaultServer ? 'Elephant Cloud' : 'Self-hosted', + subtitle: + '${Env.host}:${Env.port}\nSign out to switch servers.', + showChevron: false, + ), + ], ), - onPressed: () async { - await context.read().logout(); - - if (context.mounted) { - context.read().disconnectWebSocket(); - - context.read().clearCache(); - - context.read().clearInbox(); - - context.read().clearGroupData(); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => const SessionGateway(), - ), - (Route route) => false, - ); - } - }, - child: const Text( - 'Log out', - style: TextStyle(fontWeight: FontWeight.bold), + const SizedBox(height: Insets.xl), + GlassSection( + children: [ + ElephantTile( + icon: Icons.logout_rounded, + title: 'Sign out', + destructive: true, + showChevron: false, + onTap: () async { + final confirmed = await confirmAction( + context, + title: 'Sign out?', + message: + 'Your messages and encryption keys stay on this device for when you sign back in.', + confirmLabel: 'Sign out', + destructive: true, + ); + if (confirmed && context.mounted) await signOut(context); + }, + ), + ], ), - ), - ], + ], + ), ), ); } diff --git a/mobile/lib/pages/settings/appearance.dart b/mobile/lib/pages/settings/appearance.dart index 94443ce..5dba9b3 100644 --- a/mobile/lib/pages/settings/appearance.dart +++ b/mobile/lib/pages/settings/appearance.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mobile/themes/app_themes.dart'; import 'package:mobile/themes/theme_provider.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; class AppearanceSettings extends StatelessWidget { @@ -8,156 +11,253 @@ class AppearanceSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final themeProvider = context.watch(); + final current = context.watch().currentTheme; return Scaffold( extendBodyBehindAppBar: true, - appBar: AppBar( - title: const Text('Appearance'), - backgroundColor: Theme.of( - context, - ).colorScheme.surface, - 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), + appBar: const GlassAppBar(title: Text('Appearance')), + body: AmbientBackground( + child: ListView( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + kToolbarHeight, + bottom: Insets.xxl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + const SectionLabel('Theme'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: Insets.lg, + crossAxisSpacing: Insets.lg, + childAspectRatio: 0.72, + children: [ + for (final type in ThemeType.values) + _ThemeCard(type: type, selected: type == current), + ], ), - const SizedBox(height: 24), - Expanded( - child: GridView.count( - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.65, - 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, - ), - ], + ), + Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page + 4, + Insets.xl, + Insets.page + 4, + 0, + ), + child: Text( + 'Every theme uses the same frosted-glass surfaces; only the light and colour change.', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - ], - ), + ), + ], ), ), ); } } +/// A miniature of the chat screen painted in [type]'s palette. class _ThemeCard extends StatelessWidget { - final String title; final ThemeType type; - final ThemeData themeData; - final bool isSelected; + final bool selected; - const _ThemeCard({ - required this.title, - required this.type, - required this.themeData, - required this.isSelected, - }); + const _ThemeCard({required this.type, required this.selected}); @override Widget build(BuildContext context) { + final p = AppThemes.paletteOf(type); + final gradient = LinearGradient(colors: [p.accent, p.accentAlt]); + + Widget bubble(String text, {required bool mine, double width = 92}) { + return Align( + alignment: mine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: width, + margin: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + gradient: mine ? gradient : null, + color: mine ? null : p.incomingBubble, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(14), + topRight: const Radius.circular(14), + bottomLeft: Radius.circular(mine ? 14 : 4), + bottomRight: Radius.circular(mine ? 4 : 14), + ), + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + color: mine ? p.onAccent : p.text, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + return GestureDetector( onTap: () { + HapticFeedback.selectionClick(); context.read().setTheme(type); }, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 250), decoration: BoxDecoration( - color: themeData.scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(Radii.xl), border: Border.all( - color: isSelected - ? themeData.colorScheme.primary - : Colors.transparent, - width: 3, + color: selected ? p.accent : context.colors.outline, + width: selected ? 2.5 : 1, ), - boxShadow: isSelected - ? [ - BoxShadow( - color: themeData.colorScheme.primary.withValues(alpha: 0.4), - blurRadius: 12, - ), - ] - : [const BoxShadow(color: Colors.black12, blurRadius: 8)], + boxShadow: [ + BoxShadow( + color: (selected ? p.accent : Colors.black).withValues( + alpha: selected ? 0.35 : 0.08, + ), + blurRadius: selected ? 22 : 12, + offset: const Offset(0, 8), + ), + ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(21), + borderRadius: BorderRadius.circular(Radii.xl - 2), child: Stack( children: [ + Positioned.fill(child: ColoredBox(color: p.background)), Positioned( - right: 16, - top: 60, + top: -40, + left: -30, child: Container( - width: 60, - height: 30, + width: 160, + height: 160, decoration: BoxDecoration( - color: themeData.colorScheme.primary, - borderRadius: BorderRadius.circular(12), + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + p.ambient[0].withValues(alpha: 0.5), + p.ambient[0].withValues(alpha: 0), + ], + ), ), ), ), Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - height: 40, - color: themeData.colorScheme.surface, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, + bottom: -30, + right: -40, child: Container( - height: 40, - color: themeData.colorScheme.surface, + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + p.ambient[1].withValues(alpha: 0.45), + p.ambient[1].withValues(alpha: 0), + ], + ), + ), ), ), - Positioned( - bottom: 50, - left: 0, - right: 0, - child: Center( - child: Text( - title, - style: TextStyle( - color: themeData.colorScheme.onSurface, - fontWeight: FontWeight.bold, + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: gradient, + ), + ), + const SizedBox(width: 6), + Container( + width: 48, + height: 7, + decoration: BoxDecoration( + color: p.text.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + ), + ], ), - ), + // Decorative preview: clip from the top instead of + // overflowing when fonts are large. + Expanded( + child: ClipRect( + child: OverflowBox( + alignment: Alignment.bottomCenter, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + bubble('Hey! πŸ‘‹', mine: false, width: 64), + bubble('Is this encrypted?', mine: true), + bubble('End to end πŸ”’', mine: false, width: 84), + ], + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: p.text, + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + Text( + p.tagline, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: p.muted, + fontSize: 10.5, + ), + ), + ], + ), + ), + AnimatedScale( + scale: selected ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: gradient, + ), + child: Icon( + Icons.check_rounded, + size: 14, + color: p.onAccent, + ), + ), + ), + ], + ), + ], ), ), ], diff --git a/mobile/lib/pages/settings/chats_media.dart b/mobile/lib/pages/settings/chats_media.dart deleted file mode 100644 index 5e92c4b..0000000 --- a/mobile/lib/pages/settings/chats_media.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChatsMediaPage extends StatelessWidget { - const ChatsMediaPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text('Chats and media'))); - } -} diff --git a/mobile/lib/pages/settings/help_about.dart b/mobile/lib/pages/settings/help_about.dart index 1c0071c..538243a 100644 --- a/mobile/lib/pages/settings/help_about.dart +++ b/mobile/lib/pages/settings/help_about.dart @@ -1,279 +1,210 @@ import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:url_launcher/url_launcher.dart'; class HelpAboutPage extends StatelessWidget { const HelpAboutPage({super.key}); - final String githubUrl = "https://github.com/commandlinecoding/elephant"; + static const String appVersion = '0.3.0'; + static const String githubUrl = + 'https://github.com/commandlinecoding/elephant'; - Future _launchGitHub() async { - final Uri url = Uri.parse(githubUrl); - if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + Future _open(String url) async { + final uri = Uri.parse(url); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { debugPrint('Could not launch $url'); } } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - appBar: AppBar( - title: const Text('Help & About'), - backgroundColor: Colors.transparent, - elevation: 0, - centerTitle: true, - ), - body: ListView( - padding: const EdgeInsets.all(16.0), - physics: const BouncingScrollPhysics(), - children: [ - // --- HEADER SECTION --- - const SizedBox(height: 20), - Center( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.lock_person_rounded, - size: 64, - color: theme.colorScheme.onPrimaryContainer, - ), - ), - ), - const SizedBox(height: 16), - Center( - child: Text( - 'Elephant', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - letterSpacing: 1.2, - ), - ), + extendBodyBehindAppBar: true, + appBar: const GlassAppBar(title: Text('Help & about')), + body: AmbientBackground( + intensity: 0.8, + child: ListView( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + kToolbarHeight + Insets.xl, + bottom: Insets.xxl + MediaQuery.paddingOf(context).bottom, ), - Center( - child: Text( - 'Version 0.3.0 - Secure Messaging', - style: TextStyle( - fontSize: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: 32), - - // --- ABOUT & PRIVACY SECTION --- - _buildSectionHeader(context, 'Privacy & Security', Icons.shield), - Card( - elevation: 0, - color: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.4, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFaqItem( - context, - 'Are my messages private?', - 'Yes. 1-on-1 chats use the Signal Protocol for true End-to-End Encryption (E2EE). Your private keys never leave your device, meaning the server cannot read your messages.', - ), - const Divider(), - _buildFaqItem( - context, - 'Why do I see "Sent from another device"?', - 'Because of forward secrecy, messages are locked cryptographically. If you uninstall the app or clear your local database, you lose the local keys needed to unlock old messages, ensuring past conversations remain secure even if your device is compromised.', - ), - ], + children: [ + Center( + child: Container( + width: 96, + height: 96, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.glass.glassStrongFill, + border: Border.all(color: context.glass.glassBorder), + boxShadow: [ + BoxShadow( + color: context.colors.primary.withValues(alpha: 0.3), + blurRadius: 36, + offset: const Offset(0, 12), + ), + ], + ), + child: Image.asset('assets/launcher/elephant.png'), ), ), - ), - const SizedBox(height: 24), - - // --- KNOWN BUGS SECTION --- - _buildSectionHeader( - context, - 'Known Bugs & V0.3.0 Limitations', - Icons.bug_report, - ), - Card( - elevation: 0, - color: theme.colorScheme.errorContainer.withValues(alpha: 0.3), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + const SizedBox(height: Insets.lg), + Text( + 'Elephant', + textAlign: TextAlign.center, + style: context.text.headlineMedium, ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildBugItem( - context, - 'Group Chats are not fully E2EE yet', - 'Currently, 1-on-1 chats are fully encrypted using LibSignal. However, Group Chats currently use transport-level encryption (WSS/TLS) and temporary plaintext payloads. Full Signal "Sender Key" encryption for groups is planned for V2.', - ), - const SizedBox(height: 12), - _buildBugItem( - context, - 'Empty Group Scroll Glitch', - 'Opening a completely empty group chat for the first time may cause a minor visual glitch (RangeError) in the UI until the first message is sent.', - ), - const SizedBox(height: 12), - _buildBugItem( - context, - 'Database Sync Overwrites', - 'While heavy guards are in place, forcing app closes during heavy network syncs might occasionally drop offline read-receipts.', - ), - ], + Text( + 'Version $appVersion Β· Open source, AGPL-3.0', + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - ), - const SizedBox(height: 24), - - // --- GITHUB & SUPPORT SECTION --- - _buildSectionHeader(context, 'Support & Contribution', Icons.code), - Card( - elevation: 0, - color: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.4, + const SectionLabel('Common questions'), + const GlassSection( + children: [ + _Faq( + question: 'Who can read my messages?', + answer: + 'Direct messages are end-to-end encrypted with the Signal Protocol: only you and the ' + 'person you\'re talking to can read them. Group messages are encrypted in transit, but ' + 'the server can read them for now.', + ), + _Faq( + question: + 'Why do some messages say "Sent from another device"?', + answer: + 'Your sent messages are encrypted for the recipient, so only the copy saved on the ' + 'device you sent them from can be read. After reinstalling, older sent messages can\'t ' + 'be recovered. That\'s forward secrecy at work.', + ), + _Faq( + question: 'A message says it can\'t be decrypted. What now?', + answer: + 'Usually the other person reinstalled the app. Open the chat, tap their name, and choose ' + 'Reset secure session. Then verify their new safety number.', + ), + _Faq( + question: 'Can I edit a message?', + answer: + 'Yes. Long-press your message within 15 minutes of sending it and tap Edit. The other ' + 'person sees the change the next time their chat refreshes.', + ), + _Faq( + question: 'Can I use my own server?', + answer: + 'Yes. Sign out, then tap the server chip on the sign-in screen and enter your host and ' + 'port. Setup instructions are in the GitHub repository.', + ), + ], ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + const SectionLabel('Known limitations'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.groups_outlined, + iconColor: context.glass.warning, + title: 'Groups aren\'t end-to-end encrypted yet', + subtitle: 'Sender Key encryption for groups is planned.', + showChevron: false, + ), + ElephantTile( + icon: Icons.devices_outlined, + iconColor: context.glass.warning, + title: 'One device per account', + subtitle: + 'Signing in on a new device creates new keys; contacts will need to re-verify you.', + showChevron: false, + ), + ElephantTile( + icon: Icons.edit_note_rounded, + iconColor: context.glass.warning, + title: 'Edits aren\'t live', + subtitle: + 'Edited messages update for others when their chat next syncs.', + showChevron: false, + ), + ], ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - leading: Icon( - Icons.open_in_new, - color: theme.colorScheme.primary, - ), - title: const Text('Report an Issue on GitHub'), - subtitle: const Text( - 'Found a bug? Help us improve Elephant by opening an issue on our repository.', - ), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: _launchGitHub, + const SectionLabel('Support'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.bug_report_outlined, + title: 'Report an issue', + subtitle: 'Open an issue on GitHub', + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () => _open('$githubUrl/issues'), + ), + ElephantTile( + icon: Icons.code_rounded, + title: 'Source code', + subtitle: 'github.com/commandlinecoding/elephant', + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () => _open(githubUrl), + ), + ElephantTile( + icon: Icons.shield_outlined, + title: 'Report a security issue', + subtitle: 'Privately, through GitHub security advisories', + trailing: const Icon(Icons.open_in_new_rounded, size: 18), + onTap: () => _open('$githubUrl/security/advisories'), + ), + ], ), - ), - const SizedBox(height: 40), - - Center( - child: Text( + const SizedBox(height: Insets.xl), + Text( 'Made with 🩡 by CommandLineCoding', - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, - ), - fontSize: 12, + textAlign: TextAlign.center, + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - ), - const SizedBox(height: 40), - ], + ], + ), ), ); } +} - Widget _buildSectionHeader( - BuildContext context, - String title, - IconData icon, - ) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.only(left: 4, bottom: 12), - child: Row( - children: [ - Icon(icon, size: 20, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - ), - ], - ), - ); - } +class _Faq extends StatelessWidget { + final String question; + final String answer; + + const _Faq({required this.question, required this.answer}); - Widget _buildFaqItem(BuildContext context, String question, String answer) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + @override + Widget build(BuildContext context) { + return Theme( + data: context.theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: Insets.lg), + childrenPadding: const EdgeInsets.fromLTRB( + Insets.lg, + 0, + Insets.lg, + Insets.lg, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + iconColor: context.colors.primary, + title: Text( + question, + style: context.text.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), children: [ - Text( - question, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 15, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 6), Text( answer, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - height: 1.4, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.5, ), ), ], ), ); } - - Widget _buildBugItem(BuildContext context, String title, String description) { - final theme = Theme.of(context); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Icon(Icons.circle, size: 8, color: theme.colorScheme.error), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - description, - style: TextStyle( - fontSize: 13, - color: theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ], - ), - ), - ], - ); - } } diff --git a/mobile/lib/pages/settings/notifications_settings.dart b/mobile/lib/pages/settings/notifications_settings.dart deleted file mode 100644 index e53e623..0000000 --- a/mobile/lib/pages/settings/notifications_settings.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class NotificationsSettingsPage extends StatelessWidget { - const NotificationsSettingsPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text('Notifications'),),); - } -} \ No newline at end of file diff --git a/mobile/lib/pages/settings/privacy_security.dart b/mobile/lib/pages/settings/privacy_security.dart index 9a1ffec..973109c 100644 --- a/mobile/lib/pages/settings/privacy_security.dart +++ b/mobile/lib/pages/settings/privacy_security.dart @@ -1,93 +1,196 @@ import 'package:flutter/material.dart'; +import 'package:mobile/services/api_services.dart'; import 'package:mobile/services/signal_service.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; -class PrivacySecuritySettingsPage extends StatelessWidget { +class PrivacySecuritySettingsPage extends StatefulWidget { const PrivacySecuritySettingsPage({super.key}); @override - Widget build(BuildContext context) { - final theme = Theme.of(context); + State createState() => + _PrivacySecuritySettingsPageState(); +} + +class _PrivacySecuritySettingsPageState + extends State { + bool _busy = false; + + Future _resetSessions() async { + final confirmed = await confirmAction( + context, + title: 'Reset all secure sessions?', + message: + 'Deletes every encrypted session on this device. New sessions are created automatically ' + 'the next time you message each contact. Your chat history stays.', + confirmLabel: 'Reset', + ); + if (!confirmed || !mounted) return; + await SignalService().clearAllSessions(); + if (mounted) showSnack(context, 'All secure sessions were reset'); + } + + Future _resetKeys() async { + final password = await promptText( + context, + title: 'Reset encryption keys', + message: + 'This replaces your identity key on the server and this device. Every contact will see a new ' + 'safety number and needs to verify you again. Enter your password to continue.', + hint: 'Password', + obscure: true, + confirmLabel: 'Reset keys', + ); + if (password == null || password.isEmpty || !mounted) return; + + setState(() => _busy = true); + try { + await SignalService().resetAllKeys(password); + if (mounted) { + showSnack(context, 'New encryption keys created and published'); + } + } catch (e) { + if (mounted) { + showSnack( + context, + ApiService.errorMessage(e, fallback: 'Couldn\'t reset your keys.'), + isError: true, + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + @override + Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Privacy and security')), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(24.0), - child: Column( + extendBodyBehindAppBar: true, + appBar: GlassAppBar( + title: const Text('Privacy & security'), + actions: [ + if (_busy) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + body: AmbientBackground( + intensity: 0.7, + child: ListView( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + kToolbarHeight + Insets.lg, + bottom: Insets.xxl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.page), + child: GlassSurface( + borderRadius: BorderRadius.circular(Radii.xl), + padding: const EdgeInsets.all(Insets.xl), + child: Column( + children: [ + AccentGradientBox( + shape: BoxShape.circle, + padding: const EdgeInsets.all(16), + child: Icon( + Icons.lock_rounded, + size: 34, + color: context.glass.onAccent, + ), + ), + const SizedBox(height: Insets.lg), + Text( + 'Your keys never leave this device', + style: context.text.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: Insets.sm), + Text( + 'Direct messages use the Signal Protocol. The server only stores your public keys ' + 'and encrypted text, so it can\'t read your direct chats.', + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.45, + ), + ), + ], + ), + ), + ), + const SectionLabel('What\'s protected'), + GlassSection( children: [ - Icon( - Icons.lock_person, - size: 64, - color: theme.colorScheme.primary, + ElephantTile( + icon: Icons.lock_rounded, + iconColor: context.glass.success, + title: 'Direct messages', + subtitle: 'End-to-end encrypted', + showChevron: false, ), - const SizedBox(height: 16), - const Text( - "Your privacy is protected", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ElephantTile( + icon: Icons.lock_open_rounded, + iconColor: context.glass.warning, + title: 'Group messages', + subtitle: + 'Encrypted in transit only. End-to-end encryption for groups is coming.', + showChevron: false, ), - const SizedBox(height: 8), - Text( - "Elephant uses the Signal Protocol to secure your messages. Nobody, not even Elephant, can read your messages or listen to your calls.", - textAlign: TextAlign.center, - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ElephantTile( + icon: Icons.storage_rounded, + iconColor: context.glass.success, + title: 'Messages on this device', + subtitle: 'Stored in an encrypted database', + showChevron: false, ), ], ), - ), - const Divider(), - const ListTile( - leading: Icon(Icons.timer), - title: Text('Disappearing messages'), - subtitle: Text('Off'), - trailing: Icon(Icons.chevron_right), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.delete_forever, color: Colors.red), - title: const Text( - 'Reset All Secure Sessions', - style: TextStyle(color: Colors.red), + const SectionLabel('Troubleshooting'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.restart_alt_rounded, + iconColor: context.glass.warning, + title: 'Reset all secure sessions', + subtitle: + 'Fixes messages that won\'t decrypt across many chats.', + onTap: _busy ? null : _resetSessions, + ), + ElephantTile( + icon: Icons.key_off_rounded, + title: 'Reset encryption keys', + subtitle: + 'Create a new identity. Contacts will need to verify you again.', + destructive: true, + onTap: _busy ? null : _resetKeys, + ), + ], ), - subtitle: const Text('Fixes widespread decryption errors.'), - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("Reset All Sessions?"), - content: const Text( - "This will delete all local cryptographic sessions. Your chat history will remain, but you will need to exchange new messages to re-establish secure connections with your contacts.", - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.pop(context); - await SignalService().clearAllSessions(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'All secure sessions have been reset.', - ), - ), - ); - } - }, - child: const Text( - "Reset", - style: TextStyle(color: Colors.red), - ), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page + 4, + Insets.lg, + Insets.page + 4, + 0, + ), + child: Text( + 'To check a contact\'s identity, open their chat, tap their name, then Verify safety number.', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.5, ), - ); - }, - ), - ], + ), + ), + ], + ), ), ); } diff --git a/mobile/lib/pages/settings/server_settings.dart b/mobile/lib/pages/settings/server_settings.dart new file mode 100644 index 0000000..e3957d3 --- /dev/null +++ b/mobile/lib/pages/settings/server_settings.dart @@ -0,0 +1,209 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile/core/constants.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; + +/// Lets the user point the app at a self-hosted Elephant server before +/// signing in. Returns once the sheet closes. +Future showServerSettingsSheet(BuildContext context) { + return showGlassSheet( + context, + title: 'Server', + builder: (_) => const _ServerSettingsForm(), + ); +} + +class _ServerSettingsForm extends StatefulWidget { + const _ServerSettingsForm(); + + @override + State<_ServerSettingsForm> createState() => _ServerSettingsFormState(); +} + +enum _Health { unknown, checking, up, dbDown, unreachable } + +class _ServerSettingsFormState extends State<_ServerSettingsForm> { + late final TextEditingController _host = TextEditingController( + text: Env.host, + ); + late final TextEditingController _port = TextEditingController( + text: Env.port, + ); + _Health _health = _Health.unknown; + + @override + void dispose() { + _host.dispose(); + _port.dispose(); + super.dispose(); + } + + String _baseUrl(String host, String port) { + final secure = port == '443' || host.contains('commandlinecoding.in'); + final suffix = (port == '80' || port == '443') ? '' : ':$port'; + return '${secure ? 'https' : 'http'}://$host$suffix/api'; + } + + Future _test() async { + final host = _host.text.trim().isEmpty + ? Env.defaultHost + : _host.text.trim(); + final port = _port.text.trim().isEmpty + ? Env.defaultPort + : _port.text.trim(); + setState(() => _health = _Health.checking); + try { + final res = await Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), + ), + ).get('${_baseUrl(host, port)}/health'); + final up = res.data is Map && res.data['data']?['postgres'] == 'up'; + setState(() => _health = up ? _Health.up : _Health.dbDown); + } on DioException catch (e) { + setState( + () => _health = e.response?.statusCode == 500 + ? _Health.dbDown + : _Health.unreachable, + ); + } catch (_) { + setState(() => _health = _Health.unreachable); + } + } + + Future _save({bool reset = false}) async { + final navigator = Navigator.of(context); + await Env.updateConfig(reset ? '' : _host.text, reset ? '' : _port.text); + navigator.pop(); + if (mounted) { + showSnack( + context, + reset + ? 'Using Elephant Cloud' + : 'Server set to ${Env.host}:${Env.port}', + ); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(Insets.xl, 0, Insets.xl, Insets.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Connect to Elephant Cloud or your own self-hosted server.', + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Insets.xl), + TextField( + controller: _host, + keyboardType: TextInputType.url, + autocorrect: false, + onChanged: (_) => setState(() => _health = _Health.unknown), + decoration: const InputDecoration( + labelText: 'Host', + hintText: Env.defaultHost, + prefixIcon: Icon(Icons.language_rounded), + ), + ), + const SizedBox(height: Insets.md), + TextField( + controller: _port, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => setState(() => _health = _Health.unknown), + decoration: const InputDecoration( + labelText: 'Port', + hintText: Env.defaultPort, + prefixIcon: Icon(Icons.numbers_rounded), + helperText: + 'Port 443 uses HTTPS/WSS; other ports use plain HTTP/WS.', + ), + ), + const SizedBox(height: Insets.lg), + Row( + children: [ + Expanded(child: _HealthIndicator(health: _health)), + TextButton.icon( + onPressed: _health == _Health.checking ? null : _test, + icon: const Icon(Icons.wifi_tethering_rounded, size: 18), + label: const Text('Test'), + ), + ], + ), + const SizedBox(height: Insets.lg), + GradientButton(label: 'Save', onPressed: _save), + const SizedBox(height: Insets.sm), + TextButton( + onPressed: () => _save(reset: true), + child: const Text('Use Elephant Cloud'), + ), + ], + ), + ); + } +} + +class _HealthIndicator extends StatelessWidget { + final _Health health; + + const _HealthIndicator({required this.health}); + + @override + Widget build(BuildContext context) { + switch (health) { + case _Health.unknown: + return Text( + 'Not tested', + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ); + case _Health.checking: + return const Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + case _Health.up: + return Align( + alignment: Alignment.centerLeft, + child: TagPill( + 'Server is healthy', + color: context.glass.success, + icon: Icons.check_rounded, + ), + ); + case _Health.dbDown: + return Align( + alignment: Alignment.centerLeft, + child: TagPill( + 'Database unreachable', + color: context.glass.warning, + icon: Icons.warning_amber_rounded, + ), + ); + case _Health.unreachable: + return Align( + alignment: Alignment.centerLeft, + child: TagPill( + 'Can\'t reach server', + color: context.colors.error, + icon: Icons.close_rounded, + ), + ); + } + } +} diff --git a/mobile/lib/pages/settings/settings_page.dart b/mobile/lib/pages/settings/settings_page.dart index d03f2c8..2b38610 100644 --- a/mobile/lib/pages/settings/settings_page.dart +++ b/mobile/lib/pages/settings/settings_page.dart @@ -1,341 +1,234 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth_state.dart'; +import 'package:mobile/core/constants.dart'; import 'package:mobile/pages/settings/accounts.dart'; import 'package:mobile/pages/settings/appearance.dart'; -import 'package:mobile/pages/settings/chats_media.dart'; import 'package:mobile/pages/settings/help_about.dart'; -import 'package:mobile/pages/settings/notifications_settings.dart'; import 'package:mobile/pages/settings/privacy_security.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/themes/theme_provider.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; +import 'package:mobile/widgets/ui/feedback.dart'; +import 'package:mobile/widgets/ui/glass.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; +/// Shows the signed-in user's username as a QR code so others can find them. void showUserCard(BuildContext context) { - showDialog( - context: context, - builder: (context) { - final user = context.read().currentUser; - final String qrData = user?.username ?? 'unknown'; - - 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, + final user = context.read().currentUser; + final username = user?.username ?? ''; + showGlassSheet( + context, + builder: (sheetContext) { + final fg = sheetContext.colors.onSurface; + return Padding( + padding: const EdgeInsets.fromLTRB( + Insets.xl, + Insets.sm, + Insets.xl, + Insets.xl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElephantAvatar( + name: user?.displayName ?? '?', + seed: user?.id ?? '', + size: 64, ), - ), - 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), - - 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, - ), - ), - - 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: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - minimumSize: const Size(double.infinity, 45), - ), - child: const Text('Close'), + const SizedBox(height: Insets.md), + Text(user?.displayName ?? '', style: sheetContext.text.titleLarge), + Text( + '@$username', + style: sheetContext.text.bodyMedium?.copyWith( + color: sheetContext.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Insets.xl), + GlassSurface( + borderRadius: BorderRadius.circular(Radii.xl), + padding: const EdgeInsets.all(Insets.xl), + child: QrImageView( + data: username, + size: 200, + eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.circle, color: fg), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.circle, + color: fg, ), - ], + ), ), - ), + const SizedBox(height: Insets.lg), + Text( + 'Friends can search for this username to message you.', + textAlign: TextAlign.center, + style: sheetContext.text.bodySmall?.copyWith( + color: sheetContext.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Insets.xl), + GradientButton( + label: 'Copy username', + icon: Icons.copy_rounded, + onPressed: () { + Clipboard.setData(ClipboardData(text: username)); + Navigator.pop(sheetContext); + showSnack(context, 'Username copied'); + }, + ), + ], ), ); }, ); } +/// The "You" tab: profile card and settings. class SettingsPage extends StatelessWidget { - const SettingsPage({super.key}); + final bool embedded; + + const SettingsPage({super.key, this.embedded = false}); + + void _push(BuildContext context, Widget page) { + Navigator.push(context, MaterialPageRoute(builder: (_) => page)); + } @override Widget build(BuildContext context) { final user = context.watch().currentUser; + final palette = context.watch().palette; - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - body: CustomScrollView( - slivers: [ - SliverAppBar( - scrolledUnderElevation: 0, - pinned: true, - stretch: false, - stretchTriggerOffset: 0.1, - centerTitle: true, - elevation: 0, - backgroundColor: Colors.transparent, - flexibleSpace: ClipRRect( - clipBehavior: Clip.antiAlias, - child: BackdropFilter( - enabled: true, - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container(color: Theme.of(context).colorScheme.surface), - ), + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverAppBar( + pinned: true, + automaticallyImplyLeading: !embedded, + backgroundColor: Colors.transparent, + flexibleSpace: const GlassHeaderBackground(), + title: const Text('You'), + actions: [ + IconButton( + tooltip: 'My QR code', + icon: const Icon(Icons.qr_code_rounded), + onPressed: () => showUserCard(context), ), - actionsPadding: const EdgeInsets.symmetric(horizontal: 15), - title: Text( - 'Settings', - style: TextStyle( - letterSpacing: 1, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - key: const ValueKey('title'), + const SizedBox(width: Insets.sm), + ], + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page, + Insets.md, + Insets.page, + 0, ), - - actions: [ - IconButton( - onPressed: () { - showUserCard(context); - }, - icon: Icon(Icons.qr_code), - ), - ], - ), - SliverList( - delegate: SliverChildListDelegate([ - 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, - ), - ), - 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, + child: GlassSurface( + borderRadius: BorderRadius.circular(Radii.xl), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => _push(context, const AccountsSettings()), + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Row( + children: [ + Hero( + tag: 'me-avatar', + child: ElephantAvatar( + name: user?.displayName ?? '?', + seed: user?.id ?? '', + size: 68, + ), ), - ), - SizedBox(width: 20), - Expanded( - child: 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, - ), - softWrap: true, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - Text( - user != null - ? '@${user.username}' - : 'Could not load profile', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: Insets.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.displayName ?? 'Loading profile…', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.text.titleLarge, + ), + const SizedBox(height: 2), + Text( + user != null ? '@${user.username}' : '', + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), - Spacer(), - ], - ), + ), + ], ), ), - ), - ], + Icon( + Icons.chevron_right_rounded, + color: context.colors.onSurfaceVariant, + ), + ], + ), ), ), ), - - Container( - padding: EdgeInsets.all(20), - margin: EdgeInsets.all(20), - width: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - width: 1, - color: Theme.of(context).colorScheme.outlineVariant, - ), + ), + ), + ), + SliverList.list( + children: [ + const SectionLabel('Settings'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.person_outline_rounded, + title: 'Account', + subtitle: 'Username, server and sign out', + onTap: () => _push(context, const AccountsSettings()), ), - child: Column( - children: [ - _settingsOption( - context: context, - icon: Icons.lock, - settingName: 'Privacy and Security', - whereTo: PrivacySecuritySettingsPage(), - ), - Divider(), - _settingsOption( - context: context, - icon: Icons.notifications, - settingName: 'Notifications', - whereTo: NotificationsSettingsPage(), - trailing: Switch( - value: false, - onChanged: (value) {}, - ), - ), - Divider(), - _settingsOption( - context: context, - icon: Icons.chat, - settingName: 'Chats & Media', - whereTo: ChatsMediaPage(), - ), - Divider(), - _settingsOption( - context: context, - icon: Icons.format_paint, - settingName: 'Appearance', - whereTo: AppearanceSettings(), - ), - Divider(), - _settingsOption( - context: context, - icon: Icons.key, - settingName: 'Account', - whereTo: AccountsSettings(), - ), - Divider(), - _settingsOption( - context: context, - icon: Icons.help, - settingName: 'Help & About', - whereTo: HelpAboutPage(), - ), - ], + ElephantTile( + icon: Icons.shield_outlined, + title: 'Privacy & security', + subtitle: 'Encryption keys and sessions', + onTap: () => + _push(context, const PrivacySecuritySettingsPage()), + ), + ElephantTile( + icon: Icons.palette_outlined, + title: 'Appearance', + subtitle: palette.name, + onTap: () => _push(context, const AppearanceSettings()), + ), + ], + ), + const SectionLabel('About'), + GlassSection( + children: [ + ElephantTile( + icon: Icons.help_outline_rounded, + title: 'Help & about', + onTap: () => _push(context, const HelpAboutPage()), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(Insets.xl), + child: Text( + Env.isDefaultServer + ? 'Connected to Elephant Cloud' + : 'Connected to ${Env.host}:${Env.port}', + textAlign: TextAlign.center, + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - SizedBox(height: 100), - ]), - ), - ], - ), + ), + const SizedBox(height: 120), + ], + ), + ], ); } } - -Widget _settingsOption({ - required BuildContext context, - required IconData icon, - required String settingName, - Widget trailing = const SizedBox.shrink(), - Widget? whereTo, -}) { - return InkWell( - onTap: () { - if (whereTo != null) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => whereTo), - ); - } - }, - child: SizedBox( - width: double.infinity, - height: 60, - child: Row( - children: [ - 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/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index d3dd171..6bd31a5 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -1,133 +1,52 @@ -import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; import 'package:mobile/models/group.dart'; import 'package:mobile/services/api_services.dart'; +/// Creates groups (`POST /api/groups`) and adds the initial members. class GroupController extends ChangeNotifier { - final Map> _selectedContacts = {}; - Map> get selectedContacts => _selectedContacts; - - void setContacts(Map> contacts) { - _selectedContacts.clear(); - _selectedContacts.addAll(contacts); - notifyListeners(); - } - - void toggleContact(String id, Map contactData) { - if (_selectedContacts.containsKey(id)) { - _selectedContacts.remove(id); - } else { - _selectedContacts[id] = contactData; - } - notifyListeners(); - } - - // --- Group Creation --- - final ApiService _api = ApiService(); bool _isLoading = false; bool get isLoading => _isLoading; + String? lastError; + + /// Members that couldn't be added during the last [createGroup] call. + List failedMemberIds = []; + Future createGroup({ required String groupName, required List memberIds, }) async { if (_isLoading) return null; + _isLoading = true; + lastError = null; + failedMemberIds = []; + notifyListeners(); try { - _isLoading = true; - notifyListeners(); - - final createResponse = await _api.post( - '/groups', - data: {"name": groupName.trim()}, + final res = await _api.createGroup(groupName.trim()); + final group = Group.fromJson(ApiService.dataMap(res.data)); + + final results = await Future.wait( + memberIds.map((userId) async { + try { + await _api.addGroupMember(group.id, userId); + return null; + } catch (e) { + debugPrint("Failed to add $userId to ${group.id}: $e"); + return userId; + } + }), ); - - if (createResponse.statusCode == 200 || - createResponse.statusCode == 201) { - final groupData = - createResponse.data['data'] ?? - createResponse.data['group'] ?? - createResponse.data; - - final Group newGroup = Group.fromJson(groupData); - - if (memberIds.isNotEmpty) { - final memberRequests = memberIds.map((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); - } - - return newGroup; - } - - return null; - } on DioException catch (e) { - debugPrint("Dio Error: ${e.message}"); - if (e.response != null) { - debugPrint("Payload: ${e.response?.data}"); - } - return null; + failedMemberIds = results.whereType().toList(); + return group; } catch (e) { - debugPrint("Error: $e"); - return null; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future addMemberToGroup(String groupId, String userId) async { - try { - _isLoading = true; - notifyListeners(); - - final response = await _api.post( - '/groups/$groupId/members', - data: {"user_id": userId}, + lastError = ApiService.errorMessage( + e, + fallback: "Couldn't create the group.", ); - - 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; + return null; } finally { _isLoading = false; notifyListeners(); @@ -135,8 +54,9 @@ class GroupController extends ChangeNotifier { } void clearGroupData() { - _selectedContacts.clear(); _isLoading = false; + lastError = null; + failedMemberIds = []; notifyListeners(); } } diff --git a/mobile/lib/providers/timer_provider.dart b/mobile/lib/providers/timer_provider.dart deleted file mode 100644 index 2e77f2c..0000000 --- a/mobile/lib/providers/timer_provider.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -class OtpProvider extends ChangeNotifier { - int _secondsRemaining = 60; - String _otp = ''; - Timer? _timer; - - int get secondsRemaining => _secondsRemaining; - bool get canResend => _secondsRemaining == 0; - String get otp => _otp; - bool get isOtpComplete => _otp.length == 6; - - void updateOtp(String value) { - _otp = value; - notifyListeners(); - } - - void startTimer() { - _timer?.cancel(); - - _secondsRemaining = 60; - notifyListeners(); - - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_secondsRemaining > 0) { - _secondsRemaining--; - notifyListeners(); - } else { - timer.cancel(); - } - }); - } - - void resetTimer() { - startTimer(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } -} diff --git a/mobile/lib/services/api_services.dart b/mobile/lib/services/api_services.dart index 85b5fef..4515d40 100644 --- a/mobile/lib/services/api_services.dart +++ b/mobile/lib/services/api_services.dart @@ -1,151 +1,198 @@ import 'package:dio/dio.dart'; import '../core/constants.dart'; -import '../controllers/auth_state.dart'; import 'auth_service.dart'; +/// HTTP client for the Elephant REST API (see docs/API_CONTRACTS.md). class ApiService { - final Dio _dio = Dio(); + static final ApiService _instance = ApiService._internal(); + factory ApiService() => _instance; + + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 20), + ), + ); final AuthService _auth = AuthService(); - Future? _refreshFuture; - - ApiService() { + ApiService._internal() { _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { options.baseUrl = Env.httpBaseUrl; - final token = await _auth.getToken(); + final token = await _auth.getValidAccessToken(); if (token != null) { options.headers["Authorization"] = "Bearer $token"; } return handler.next(options); }, - onResponse: (response, handler) { - return handler.next(response); - }, onError: (DioException e, handler) async { - final isRefreshEndpoint = e.requestOptions.path.contains( - "/auth/refresh", - ); - - if (e.response?.statusCode == 401 && !isRefreshEndpoint) { - 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; - } + final alreadyRetried = e.requestOptions.extra['retried'] == true; + if (e.response?.statusCode != 401 || alreadyRetried) { + return handler.next(e); } - if (e.response?.statusCode == 401 && isRefreshEndpoint) { - AuthState.onGlobalUnauthorized?.call(); + try { + await _auth.refreshTokens(); + final retryOptions = e.requestOptions + ..extra['retried'] = true + ..headers["Authorization"] = "Bearer ${await _auth.getToken()}"; + return handler.resolve(await _dio.fetch(retryOptions)); + } on DioException catch (retryError) { + return handler.next(retryError); + } catch (_) { + return handler.next(e); } - - return handler.next(e); }, ), ); } - Future _refreshToken() async { - final refreshToken = await _auth.getRefreshToken(); - - if (refreshToken == null) { - throw Exception("No refresh token"); + /// Human-readable message for a failed request, preferring the server's + /// `error` field. + static String errorMessage( + Object error, { + String fallback = 'Something went wrong', + }) { + if (error is DioException) { + final status = error.response?.statusCode; + if (status == 429) return 'Too many requests. Please slow down.'; + final data = error.response?.data; + if (data is Map && + data['error'] is String && + (data['error'] as String).isNotEmpty) { + final message = data['error'] as String; + return message[0].toUpperCase() + message.substring(1); + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.sendTimeout: + return 'The server took too long to respond.'; + case DioExceptionType.connectionError: + return 'Can\'t reach the server. Check your connection.'; + default: + break; + } } + return fallback; + } - final response = await _auth.refreshAccessToken(refreshToken); - final data = response.data; + /// Unwraps `{"success": true, "data": [...]}` into a list. + static List dataList(dynamic body) { + if (body is List) return body; + if (body is Map && body['data'] is List) return body['data']; + return []; + } - if (data["success"] != true) { - throw Exception("Refresh failed"); + /// Unwraps `{"success": true, "data": {...}}` into a map. + static Map dataMap(dynamic body) { + if (body is Map && body['data'] is Map) { + return Map.from(body['data']); } + if (body is Map) return Map.from(body); + return {}; + } + + // --- Users --- + + Future getMe() => _dio.get("/users/me"); - await _auth.saveTokens( - data["data"]["access_token"], - data["data"]["refresh_token"], + Future getUser(String userId) => _dio.get("/users/$userId"); + + Future searchUsers(String query, {int page = 1}) { + return _dio.get( + "/users/search", + queryParameters: { + "q": query, + "page": page, + "limit": ServerLimits.searchPageSize, + }, ); } - Future getConversations() async { - return await _dio.get("/messages/conversations"); - } + // --- Messages --- + + Future getConversations() => _dio.get("/messages/conversations"); Future getChatHistory( String targetId, { String? before, bool isGroup = false, - }) async { - final Map params = {"limit": 40}; + }) { + final Map params = {"limit": ServerLimits.historyPageSize}; 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); + return _dio.get("/groups/$targetId/messages", queryParameters: params); } + params["with"] = targetId; + return _dio.get("/messages", queryParameters: params); } - Future getGroupMembers(String groupId) async { - try { - return await _dio.get("/groups/$groupId/members"); - } catch (e) { - rethrow; - } + Future editMessage(String messageId, String content) { + return _dio.put("/messages/$messageId", data: {"content": content}); } - Future searchUsers(String query) async { - return await _dio.get( - "/users/search", - queryParameters: { - "q": query, - "query": query, - "search": query, - "keyword": query, - "username": query, - "searchTerm": query, - }, - ); + Future markDirectRead(String senderId) { + return _dio.post("/messages/read", data: {"sender_id": senderId}); } - Future post( - String path, { - dynamic data, - Map? queryParameters, - }) async { - return await _dio.post(path, data: data, queryParameters: queryParameters); + // --- Groups --- + + Future getGroups() => _dio.get("/groups"); + + Future getGroup(String groupId) => _dio.get("/groups/$groupId"); + + Future createGroup(String name) { + return _dio.post("/groups", data: {"name": name}); + } + + Future renameGroup(String groupId, String name) { + return _dio.patch("/groups/$groupId", data: {"name": name}); + } + + Future getGroupMembers(String groupId) { + return _dio.get("/groups/$groupId/members"); + } + + Future addGroupMember(String groupId, String userId) { + return _dio.post("/groups/$groupId/members", data: {"user_id": userId}); + } + + Future removeGroupMember(String groupId, String userId) { + return _dio.delete("/groups/$groupId/members/$userId"); } - Future get( - String path, { - Map? queryParameters, - }) async { - return await _dio.get(path, queryParameters: queryParameters); + Future leaveGroup(String groupId) { + return _dio.post("/groups/$groupId/leave"); } - Future delete( - String path, { - dynamic data, - Map? queryParameters, - }) async { - return await _dio.delete( - path, - data: data, - queryParameters: queryParameters, + // --- E2EE --- + + Future uploadPrekeys(Map bundle) { + return _dio.post("/e2ee/keys", data: bundle); + } + + Future getPrekeyBundle(String userId, {String deviceId = 'main'}) { + return _dio.get( + "/e2ee/bundle/$userId", + queryParameters: {"device_id": deviceId}, + ); + } + + Future getVerification(String userId) { + return _dio.get("/e2ee/verify/$userId"); + } + + Future setVerification(String userId, bool isVerified) { + return _dio.post( + "/e2ee/verify", + data: {"verified_user_id": userId, "is_verified": isVerified}, ); } + + Future resetEncryptionKeys(String password) { + return _dio.post("/e2ee/reset", data: {"password": password}); + } } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index e917dee..124c60b 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -1,21 +1,40 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../core/constants.dart'; +/// Thrown when the server rejects the refresh token, i.e. the session is over. +class SessionExpiredException implements Exception { + @override + String toString() => 'SessionExpiredException'; +} + class AuthService { - final Dio _dio = Dio(); + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 20), + ), + ); final FlutterSecureStorage _storage = const FlutterSecureStorage(); static final AuthService _instance = AuthService._internal(); factory AuthService() => _instance; - AuthService._internal() { - _dio.options.baseUrl = Env.httpBaseUrl; - } + AuthService._internal(); + + /// Called when the refresh token is rejected. Set by [AuthState]. + static VoidCallback? onSessionExpired; String? _cachedAccessToken; String? _cachedRefreshToken; + /// Refresh tokens rotate on every use, so concurrent refreshes would revoke + /// each other. Every caller shares this single in-flight refresh. + Future? _refreshing; + Future initTokens() async { _cachedAccessToken = await _storage.read(key: "access_token"); _cachedRefreshToken = await _storage.read(key: "refresh_token"); @@ -24,6 +43,76 @@ class AuthService { Future getToken() async => _cachedAccessToken; Future getRefreshToken() async => _cachedRefreshToken; + /// Returns an access token that is valid for at least another 30 seconds, + /// refreshing it first if needed. Falls back to the current token when the + /// refresh fails for a network reason. + Future getValidAccessToken() async { + final token = _cachedAccessToken; + if (token == null) return null; + if (!_expiresWithin(token, const Duration(seconds: 30))) return token; + + try { + await refreshTokens(); + } on SessionExpiredException { + return null; + } catch (e) { + debugPrint("Proactive token refresh failed: $e"); + } + return _cachedAccessToken; + } + + Future refreshTokens() { + return _refreshing ??= _performRefresh().whenComplete(() { + _refreshing = null; + }); + } + + Future _performRefresh() async { + final refreshToken = _cachedRefreshToken; + if (refreshToken == null) { + onSessionExpired?.call(); + throw SessionExpiredException(); + } + + try { + final res = await _dio.post( + "${Env.httpBaseUrl}/auth/refresh", + data: {"refresh_token": refreshToken}, + ); + final data = res.data; + if (data is! Map || data["success"] != true) { + throw Exception("Refresh failed"); + } + await saveTokens( + data["data"]["access_token"], + data["data"]["refresh_token"], + ); + } on DioException catch (e) { + final status = e.response?.statusCode; + if (status == 401 || status == 400) { + onSessionExpired?.call(); + throw SessionExpiredException(); + } + rethrow; + } + } + + static bool _expiresWithin(String jwt, Duration margin) { + try { + final parts = jwt.split('.'); + if (parts.length != 3) return false; + final payload = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ); + final exp = payload['exp']; + if (exp is! int) return false; + final expiry = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + return DateTime.now().add(margin).isAfter(expiry); + } catch (_) { + return false; + } + } + Future saveTokens(String accessToken, String refreshToken) async { _cachedAccessToken = accessToken; _cachedRefreshToken = refreshToken; @@ -44,12 +133,12 @@ class AuthService { _cachedRefreshToken = null; await _storage.delete(key: "access_token"); await _storage.delete(key: "refresh_token"); + await _storage.delete(key: "cached_user_profile"); } Future login(String username, String password) async { - _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.post( - "/auth/login", + "${Env.httpBaseUrl}/auth/login", data: {"username": username, "password": password}, ); } @@ -59,9 +148,8 @@ class AuthService { String displayName, String password, ) async { - _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.post( - "/auth/register", + "${Env.httpBaseUrl}/auth/register", data: { "username": username, "display_name": displayName, @@ -70,18 +158,9 @@ class AuthService { ); } - Future refreshAccessToken(String refreshToken) async { - _dio.options.baseUrl = Env.httpBaseUrl; - return await _dio.post( - "/auth/refresh", - data: {"refresh_token": refreshToken}, - ); - } - Future getCurrentUser(String token) async { - _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.get( - '/users/me', + '${Env.httpBaseUrl}/users/me', options: Options(headers: {'Authorization': 'Bearer $token'}), ); } diff --git a/mobile/lib/services/chat/chat_event_handler.dart b/mobile/lib/services/chat/chat_event_handler.dart index 1f2af5b..3717781 100644 --- a/mobile/lib/services/chat/chat_event_handler.dart +++ b/mobile/lib/services/chat/chat_event_handler.dart @@ -1,256 +1,70 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; +import 'package:mobile/core/message_envelope.dart'; import 'package:mobile/services/signal_service.dart'; -import 'package:sqflite_sqlcipher/sqflite.dart'; import 'package:mobile/controllers/chat/active_chat_controller.dart'; import 'package:mobile/controllers/chat/inbox_controller.dart'; import '../../models/message.dart'; import '../db_services.dart'; import '../../services/ws_service.dart'; +/// Applies server β†’ client WebSocket events (see docs/API_CONTRACTS.md) to +/// the local database, the inbox and the open chat. class ChatEventHandler { final InboxController inboxController; final ActiveChatController activeChatController; - final String currentUserId; + final String Function() currentUserIdProvider; final WebSocketService _ws = WebSocketService(); ChatEventHandler({ required this.inboxController, required this.activeChatController, - required this.currentUserId, + required this.currentUserIdProvider, }); - Future handleIncomingEvent(Map data) async { - final String? type = data['type']; - if (type == null) return; + String get currentUserId => currentUserIdProvider(); - final String? cleanCurrentChat = activeChatController.currentChatUserId - ?.trim(); - bool isCurrentChat = false; + /// The server sends `""` instead of omitting unused IDs in some events. + static String? _id(dynamic value) { + final text = value?.toString().trim(); + return (text == null || text.isEmpty) ? null : text.toLowerCase(); + } - if (cleanCurrentChat != null) { - if (activeChatController.isCurrentChatGroup) { - final String? eventGroupId = data['group_id']?.toString().trim(); - isCurrentChat = (eventGroupId == cleanCurrentChat); - } else { - final String? eventSenderId = - data['sender_id']?.toString().trim() ?? - data['sender']?.toString().trim(); - final String? eventReceiverId = data['receiver_id']?.toString().trim(); + /// The conversation an event belongs to: the group, or the other user. + String? _chatIdFor(Map data) { + final groupId = _id(data['group_id']); + if (groupId != null) return groupId; - isCurrentChat = - (eventSenderId == cleanCurrentChat || - eventReceiverId == cleanCurrentChat); - } - } + final senderId = _id(data['sender_id']); + final receiverId = _id(data['receiver_id']); + final myId = currentUserId.trim().toLowerCase(); + return senderId == myId ? receiverId : senderId; + } - switch (type) { - case 'pong': - _ws.registerPong(); - break; - case 'user_status': - case 'status': - final String? eventUserId = (data['user_id'] ?? data['id']) - ?.toString() - .trim() - .toLowerCase(); - final String? safeCurrentChat = cleanCurrentChat?.toLowerCase(); + bool _isOpen(String? chatId) { + final open = activeChatController.currentChatUserId?.trim().toLowerCase(); + return chatId != null && open != null && open == chatId; + } - if (eventUserId == safeCurrentChat && - !activeChatController.isCurrentChatGroup) { - activeChatController.isPeerOnline = - data['online'] == true || data['content'] == 'online'; + Future handleIncomingEvent(Map data) async { + switch (data['type']) { + case 'user_status': + final userId = _id(data['user_id']); + if (!activeChatController.isCurrentChatGroup && _isOpen(userId)) { + activeChatController.isPeerOnline = data['online'] == true; + if (!activeChatController.isPeerOnline) { + activeChatController.isPeerTyping = false; + } activeChatController.refreshUI(); } break; case 'chat': - case 'message': - final String? incomingId = data['id']?.toString(); - final String echoId = - data['message_id']?.toString() ?? - data['messageId']?.toString() ?? - data['client_message_id']?.toString() ?? - incomingId ?? - ''; - - final String cleanSenderId = (data['sender_id'] ?? data['sender'] ?? '') - .toString() - .trim(); - final String cleanReceiverId = (data['receiver_id'] ?? '') - .toString() - .trim(); - final String myId = currentUserId.trim(); - final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); - - final String dbChatId = data['group_id'] != null - ? data['group_id'].toString() - : (isMe ? cleanReceiverId : cleanSenderId); - - try { - final String rawContent = data['content']?.toString() ?? ''; - if (rawContent.startsWith('{') && rawContent.contains('ciphertext')) { - final Map payload = jsonDecode(rawContent); - final String remoteSender = data['sender_id']?.toString() ?? ''; - - final decryptedText = await SignalService().decryptMessage( - remoteSender, - payload['ciphertext'], - payload['type'], - ); - data['content'] = decryptedText; - } - } catch (e) { - debugPrint( - "❌ Decryption failed for message. It may be out of sync: $e", - ); - data['content'] = "πŸ”’ [Message Decryption Failed]"; - } - - try { - final db = await DatabaseHelper.instance.database; - final queuedItems = await db.query( - 'action_queue', - where: 'id = ?', - whereArgs: [echoId], - ); - - final bool isOurMessage = queuedItems.isNotEmpty || isMe; - - if (isOurMessage) { - final String newMsgId = incomingId ?? echoId; - - int index = activeChatController.activeChat.indexWhere( - (m) => m.id == echoId || m.id == newMsgId, - ); - String originalClientId = echoId; - - if (index == -1) { - final content = data['content']?.toString().trim() ?? ''; - index = activeChatController.activeChat.lastIndexWhere( - (m) => m.syncStatus == 'pending' && m.content.trim() == content, - ); - if (index != -1) { - originalClientId = activeChatController.activeChat[index].id; - } - } - - if (index != -1) { - final existingMsg = activeChatController.activeChat[index]; - - final newList = List.from( - activeChatController.activeChat, - ); - newList[index] = Message( - id: newMsgId, - senderId: existingMsg.senderId, - receiverId: existingMsg.receiverId, - content: existingMsg.content, - createdAt: existingMsg.createdAt, - isRead: existingMsg.isRead, - replyToMessageId: existingMsg.replyToMessageId, - quotedMessage: existingMsg.quotedMessage, - syncStatus: 'synced', - ); - - activeChatController.activeChat = newList; - activeChatController.refreshUI(); - - await db.delete( - 'action_queue', - where: 'id = ?', - whereArgs: [originalClientId], - ); - if (originalClientId != newMsgId) { - await db.delete( - 'messages', - where: 'id = ?', - whereArgs: [originalClientId], - ); - } - - await db.insert('messages', { - 'id': newMsgId, - 'chat_id': dbChatId, - 'sender_id': existingMsg.senderId, - 'content': existingMsg.content, - 'created_at': existingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': 1, - 'reply_to_id': existingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - inboxController.updateLocalInboxState( - dbChatId, - existingMsg.content, - existingMsg.createdAt, - false, - senderId: 'me', - syncStatus: 'synced', - isRead: true, - ); - - return; - } - } - - Message incomingMsg; - try { - incomingMsg = Message.fromJson(data); - } catch (e) { - debugPrint("❌ Failed to parse peer WS message: $e"); - return; - } - - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': isCurrentChat ? 1 : 0, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - if (isCurrentChat) { - if (!activeChatController.activeChat.any( - (msg) => msg.id == incomingMsg.id, - )) { - activeChatController.activeChat = [ - ...activeChatController.activeChat, - incomingMsg, - ]; - } - _ws.sendReadReceipt( - receiverId: activeChatController.isCurrentChatGroup - ? null - : activeChatController.currentChatUserId, - groupId: activeChatController.isCurrentChatGroup - ? activeChatController.currentChatUserId - : null, - ); - activeChatController.refreshUI(); - } - - inboxController.updateLocalInboxState( - dbChatId, - incomingMsg.content, - incomingMsg.createdAt, - !isCurrentChat, - senderId: incomingMsg.senderId, - syncStatus: 'synced', - isRead: isCurrentChat, - ); - } catch (e) { - debugPrint("Failed to save incoming message to DB: $e"); - } + await _handleChat(data); break; case 'typing': - if (isCurrentChat) { - final bool nowTyping = + if (_isOpen(_chatIdFor(data))) { + final nowTyping = data['content'] == 'true' || data['content'] == true; if (activeChatController.isPeerTyping != nowTyping) { activeChatController.isPeerTyping = nowTyping; @@ -260,71 +74,72 @@ class ChatEventHandler { break; case 'read_receipt': - final String payloadSender = (data['sender_id'] ?? '').toString(); - final String payloadReceiver = (data['receiver_id'] ?? '').toString(); - final String payloadGroup = (data['group_id'] ?? '').toString(); - final String safeChatId = - (activeChatController.currentChatUserId ?? ''); + await _handleReadReceipt(data); + break; + } + } - bool isRelevantToThisChat = false; - String dbTargetChatId = ""; + Future _handleChat(Map data) async { + final chatId = _chatIdFor(data); + final senderId = data['sender_id']?.toString() ?? ''; + if (chatId == null || senderId.isEmpty) return; - if (safeChatId.isNotEmpty) { - if (activeChatController.isCurrentChatGroup) { - isRelevantToThisChat = (payloadGroup == safeChatId); - dbTargetChatId = payloadGroup; - } else { - isRelevantToThisChat = - (payloadSender == safeChatId || payloadReceiver == safeChatId); - dbTargetChatId = safeChatId; - } - } + final rawContent = data['content']?.toString() ?? ''; + final content = await SignalService().decodeIncoming(senderId, rawContent); - if (dbTargetChatId.isNotEmpty) { - try { - final db = await DatabaseHelper.instance.database; - await db.update( - 'messages', - {'is_read': 1}, - where: 'chat_id = ? COLLATE NOCASE AND sender_id = ?', - whereArgs: [dbTargetChatId, 'me'], - ); - inboxController.markInboxItemAsRead(dbTargetChatId); - } catch (e) { - debugPrint("Failed to update read receipts in DB: $e"); - } - } + final isOpen = _isOpen(chatId); + final message = Message.fromJson( + data, + ).copyWith(content: content, isRead: isOpen); - if (isRelevantToThisChat) { - bool updated = false; + try { + await DatabaseHelper.instance.insertMessage(message.toRow(chatId)); + } catch (e) { + debugPrint("Failed to save incoming message: $e"); + } - activeChatController.activeChat = activeChatController.activeChat.map( - (msg) { - final String msgSenderId = msg.senderId.trim(); - if (!msg.isRead && - (msgSenderId == 'me' || msgSenderId != safeChatId)) { - updated = true; - return Message( - id: msg.id, - senderId: msg.senderId, - receiverId: msg.receiverId, - content: msg.content, - createdAt: msg.createdAt, - isRead: true, - replyToMessageId: msg.replyToMessageId, - quotedMessage: msg.quotedMessage, - syncStatus: msg.syncStatus, - ); - } - return msg; - }, - ).toList(); + if (isOpen) { + activeChatController.receiveIncoming(message); + final isGroup = activeChatController.isCurrentChatGroup; + _ws.sendReadReceipt( + receiverId: isGroup ? null : senderId, + groupId: isGroup ? chatId : null, + ); + } - if (updated) { - activeChatController.refreshUI(); - } - } - break; + inboxController.updateLocalInboxState( + chatId, + MessageEnvelope.isUnreadable(content) ? 'πŸ”’ Encrypted message' : content, + message.createdAt, + !isOpen, + senderId: senderId, + syncStatus: 'synced', + isRead: isOpen, + ); + } + + Future _handleReadReceipt(Map data) async { + final readerId = _id(data['sender_id']); + final myId = currentUserId.trim().toLowerCase(); + if (readerId == null || readerId == myId) return; + + final chatId = _chatIdFor(data); + if (chatId == null) return; + + try { + final db = await DatabaseHelper.instance.database; + await db.update( + 'messages', + {'is_read': 1}, + where: + 'chat_id = ? COLLATE NOCASE AND (sender_id = ? OR sender_id = ?)', + whereArgs: [chatId, 'me', currentUserId], + ); + inboxController.markInboxItemAsRead(chatId); + } catch (e) { + debugPrint("Failed to apply read receipt: $e"); } + + if (_isOpen(chatId)) activeChatController.applyReadReceipt(); } } diff --git a/mobile/lib/services/chat/chat_sync_service.dart b/mobile/lib/services/chat/chat_sync_service.dart index 51db6f2..dbd6d8d 100644 --- a/mobile/lib/services/chat/chat_sync_service.dart +++ b/mobile/lib/services/chat/chat_sync_service.dart @@ -4,10 +4,13 @@ import '../../controllers/chat/active_chat_controller.dart'; import '../../services/ws_service.dart'; import '../db_services.dart'; +/// Replays messages that were written while the socket was down. class ChatSyncService { final WebSocketService _ws = WebSocketService(); bool _isSyncing = false; + static const int _maxRetries = 5; + Future processOfflineQueue( String currentUserId, { ActiveChatController? activeChatController, @@ -17,69 +20,61 @@ class ChatSyncService { try { final db = await DatabaseHelper.instance.database; - final pendingActions = await db.query( 'action_queue', where: 'retry_count < ?', - whereArgs: [5], + whereArgs: [_maxRetries], orderBy: 'created_at ASC', ); - if (pendingActions.isEmpty) return; - - debugPrint("Processing ${pendingActions.length} queued offline actions..."); - - for (var action in pendingActions) { + for (final action in pendingActions) { if (!_ws.isConnected) break; final actionId = action['id'] as String; final type = action['action_type'] as String; final payload = jsonDecode(action['payload'] as String); - try { - if (type == 'send_chat') { - _ws.sendChat( - messageId: payload['messageId'], - receiverId: payload['receiverId'], - content: payload['content'], - replyToMessageId: payload['replyToMessageId'], - ); - } else if (type == 'send_group_chat') { - _ws.sendGroupChat( - messageId: payload['messageId'], - groupId: payload['groupId'], - content: payload['content'], - replyToMessageId: payload['replyToMessageId'], - ); - } - - await Future.delayed(const Duration(milliseconds: 50)); + bool sent = false; + if (type == 'send_chat') { + sent = _ws.sendChat( + messageId: payload['messageId'], + receiverId: payload['receiverId'], + content: payload['content'], + replyToMessageId: payload['replyToMessageId'], + ); + } else if (type == 'send_group_chat') { + sent = _ws.sendGroupChat( + messageId: payload['messageId'], + groupId: payload['groupId'], + content: payload['content'], + replyToMessageId: payload['replyToMessageId'], + ); + } + if (sent) { await db.update( 'messages', {'sync_status': 'synced'}, where: 'id = ?', whereArgs: [actionId], ); - await db.delete( 'action_queue', where: 'id = ?', whereArgs: [actionId], ); - activeChatController?.markMessageAsSynced(actionId); - - } catch (e) { - debugPrint("Failed to flush queue action $actionId: $e"); + await Future.delayed(const Duration(milliseconds: 50)); + } else { await db.rawUpdate( 'UPDATE action_queue SET retry_count = retry_count + 1 WHERE id = ?', [actionId], ); + debugPrint("Queued message $actionId could not be sent"); } } } finally { _isSyncing = false; } } -} \ No newline at end of file +} diff --git a/mobile/lib/services/db_services.dart b/mobile/lib/services/db_services.dart index 20ad8a7..500e98a 100644 --- a/mobile/lib/services/db_services.dart +++ b/mobile/lib/services/db_services.dart @@ -1,15 +1,17 @@ import 'dart:convert'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path/path.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; +import '../core/message_envelope.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); - + static const String _dbName = 'secure_chat.db'; DatabaseHelper._init(); @@ -27,14 +29,17 @@ class DatabaseHelper { try { key = await _secureStorage.read(key: keyName); } on PlatformException catch (e) { - if (e.message?.contains('BAD_DECRYPT') == true || e.code == 'Exception encountered') { - print('CRITICAL: Keystore corrupted. Wiping secure storage and resetting DB.'); - + if (e.message?.contains('BAD_DECRYPT') == true || + e.code == 'Exception encountered') { + debugPrint( + 'CRITICAL: Keystore corrupted. Wiping secure storage and resetting DB.', + ); + await _secureStorage.deleteAll(); - - final dbPath = join(await getDatabasesPath(), _dbName); + + final dbPath = join(await getDatabasesPath(), _dbName); await deleteDatabase(dbPath); - + key = null; } else { rethrow; @@ -45,11 +50,11 @@ class DatabaseHelper { final random = Random.secure(); final secureBytes = List.generate(32, (_) => random.nextInt(256)); final secureKey = base64Url.encode(secureBytes); - + await _secureStorage.write(key: keyName, value: secureKey); key = secureKey; } - + return key; } @@ -60,12 +65,19 @@ class DatabaseHelper { return await openDatabase( path, - version: 1, + version: 2, password: password, onCreate: _createDB, + onUpgrade: _upgradeDB, ); } + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + await db.execute('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); + } + } + Future _createDB(Database db, int version) async { await db.execute(''' CREATE TABLE messages ( @@ -76,7 +88,8 @@ class DatabaseHelper { created_at INTEGER NOT NULL, is_read INTEGER NOT NULL, reply_to_id TEXT, - sync_status TEXT NOT NULL -- 'synced' or 'pending' + sync_status TEXT NOT NULL, -- 'synced' or 'pending' + edited_at INTEGER ) '''); @@ -159,35 +172,66 @@ class DatabaseHelper { '''); } + /// Inserts or replaces a message, never overwriting cached plaintext with + /// ciphertext or a "can't decrypt" placeholder. Future insertMessage(Map row) async { - Database db = await instance.database; - - if (row['content'] != null) { - String newContent = row['content'].toString(); - - if (newContent.contains('ciphertext') || newContent.contains('πŸ”’')) { - List> existing = await db.query( - 'messages', - where: 'id = ?', - whereArgs: [row['id']], - ); + final db = await instance.database; - if (existing.isNotEmpty) { - String oldContent = existing.first['content'].toString(); - if (!oldContent.contains('ciphertext') && !oldContent.contains('πŸ”’')) { - row['content'] = oldContent; - } + final newContent = row['content']?.toString(); + if (newContent != null && MessageEnvelope.isUnreadable(newContent)) { + final existing = await db.query( + 'messages', + columns: ['content'], + where: 'id = ?', + whereArgs: [row['id']], + ); + if (existing.isNotEmpty) { + final oldContent = existing.first['content'].toString(); + if (!MessageEnvelope.isUnreadable(oldContent)) { + row['content'] = oldContent; } } } return await db.insert( - 'messages', - row, + 'messages', + row, conflictAlgorithm: ConflictAlgorithm.replace, ); } - + + /// Removes a conversation and its cached messages, e.g. after leaving a group. + Future deleteChat(String chatId) async { + final db = await instance.database; + await db.delete('messages', where: 'chat_id = ?', whereArgs: [chatId]); + await db.delete('inbox', where: 'id = ?', whereArgs: [chatId]); + await db.delete( + 'group_members', + where: 'group_id = ?', + whereArgs: [chatId], + ); + } + + /// Deletes every cached conversation, message and queued send. + Future wipeChatData() async { + final db = await instance.database; + await db.delete('messages'); + await db.delete('inbox'); + await db.delete('action_queue'); + await db.delete('group_members'); + } + + /// Deletes every local Signal key, session and identity. + Future wipeSignalState() async { + final db = await instance.database; + await db.delete('signal_local_keys'); + await db.delete('signal_identities'); + await db.delete('signal_sessions'); + await db.delete('signal_prekeys'); + await db.delete('signal_signed_prekeys'); + await db.delete('signal_sender_keys'); + } + Future queueAction( String id, String type, @@ -201,4 +245,4 @@ class DatabaseHelper { 'created_at': DateTime.now().millisecondsSinceEpoch, }); } -} \ No newline at end of file +} diff --git a/mobile/lib/services/signal_service.dart b/mobile/lib/services/signal_service.dart index dceb802..2065822 100644 --- a/mobile/lib/services/signal_service.dart +++ b/mobile/lib/services/signal_service.dart @@ -1,12 +1,27 @@ +import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile/core/message_envelope.dart'; import 'package:mobile/services/sqlite_signal_store.dart'; import 'package:mobile/services/db_services.dart'; import 'api_services.dart'; +/// Result of a contact-verification lookup. +class VerificationInfo { + final String safetyNumber; + final bool isVerified; + + const VerificationInfo({ + required this.safetyNumber, + required this.isVerified, + }); +} + +/// Client side of the Signal Protocol. The server (`/api/e2ee/*`) only stores +/// public keys; every private key stays in the local encrypted database. class SignalService { static final SignalService _instance = SignalService._internal(); factory SignalService() => _instance; @@ -16,6 +31,26 @@ class SignalService { late SQLiteSignalStore _store; bool _isInitialized = false; + /// Bundle fetches consume a one-time prekey on the server, so concurrent + /// session setups for the same contact share one request. + final Map> _pendingSessions = {}; + + static const String deviceId = 'main'; + static const int _initialPreKeyCount = 100; + static const int _refillPreKeyCount = 50; + static const int _signedPreKeyId = 0; + static const String _nextPreKeyIdPref = 'signal_next_prekey_id'; + + /// Signal state is a ratchet: every encrypt/decrypt mutates it, so all + /// operations run one at a time. + Future _queue = Future.value(); + + Future _serial(Future Function() task) { + final result = _queue.then((_) => task()); + _queue = result.then((_) {}, onError: (_) {}); + return result; + } + SignalService._internal(); Future initStore() async { @@ -27,197 +62,313 @@ class SignalService { SignalProtocolAddress _getAddress(String userId) => SignalProtocolAddress(userId, 1); - Future initializeAndUploadKeys(String currentUserId) async { + // --- Key lifecycle --- + + /// Makes sure this device has an identity and that the server holds its + /// public keys, topping up one-time prekeys when the server reports < 10. + Future ensureKeysPublished() async { await initStore(); + try { + final generated = await _ensureIdentity(); + final requiresRefill = await _upload(includeLocalPreKeys: generated); + if (requiresRefill) { + await _refillPreKeys(); + } + } catch (e) { + debugPrint("E2EE: failed to publish keys: $e"); + } + } + /// Generates a fresh identity if none exists. Returns true when it did. + Future _ensureIdentity() async { final db = await DatabaseHelper.instance.database; final existingKeys = await db.query('signal_local_keys', where: 'id = 1'); + if (existingKeys.isNotEmpty) return false; - if (existingKeys.isNotEmpty) { - debugPrint("E2EE: Keys already exist locally. Skipping generation."); - return; - } - - debugPrint("E2EE: Generating new local identity keys..."); - + debugPrint("E2EE: generating a new identity"); final identityKeyPair = generateIdentityKeyPair(); final registrationId = generateRegistrationId(false); - await _store.storeLocalData(identityKeyPair, registrationId); - final preKeys = generatePreKeys(0, 100); - final signedPreKey = generateSignedPreKey(identityKeyPair, 0); + final signedPreKey = generateSignedPreKey(identityKeyPair, _signedPreKeyId); + await _store.storeSignedPreKey(signedPreKey.id, signedPreKey); - for (var preKey in preKeys) { + for (final preKey in generatePreKeys(0, _initialPreKeyCount)) { await _store.storePreKey(preKey.id, preKey); } - await _store.storeSignedPreKey(signedPreKey.id, signedPreKey); - final publicPreKeys = preKeys - .map( - (k) => { - 'id': k.id, - 'content': base64Encode(k.getKeyPair().publicKey.serialize()), - }, - ) - .toList(); - - final payload = { - 'device_id': 'main', + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_nextPreKeyIdPref, _initialPreKeyCount); + return true; + } + + /// Uploads identity and signed prekey. One-time prekeys are only sent when + /// [includeLocalPreKeys] or [preKeys] is given, because the server appends + /// them and re-sending would create duplicates. + Future _upload({ + bool includeLocalPreKeys = false, + List? preKeys, + }) async { + final identityKeyPair = await _store.getIdentityKeyPair(); + final signedPreKey = await _store.loadSignedPreKey(_signedPreKeyId); + + List oneTime = preKeys ?? []; + if (includeLocalPreKeys) { + final db = await DatabaseHelper.instance.database; + final rows = await db.query('signal_prekeys'); + oneTime = rows + .map( + (row) => + PreKeyRecord.fromBuffer(base64Decode(row['record'] as String)), + ) + .toList(); + } + + final res = await _api.uploadPrekeys({ + 'device_id': deviceId, 'identity_key': base64Encode(identityKeyPair.getPublicKey().serialize()), 'signed_prekey': base64Encode( signedPreKey.getKeyPair().publicKey.serialize(), ), 'signature': base64Encode(signedPreKey.signature), - 'one_time_prekeys': publicPreKeys, - }; + 'one_time_prekeys': oneTime + .map( + (k) => { + 'id': k.id, + 'content': base64Encode(k.getKeyPair().publicKey.serialize()), + }, + ) + .toList(), + }); + + return ApiService.dataMap(res.data)['requires_refill'] == true; + } - try { - await _api.post("/e2ee/keys", data: payload); - debugPrint("E2EE Keys uploaded successfully"); - } catch (e) { - debugPrint("Failed to upload E2EE keys: $e"); + Future _refillPreKeys() async { + final prefs = await SharedPreferences.getInstance(); + int nextId = prefs.getInt(_nextPreKeyIdPref) ?? 0; + + // Installs from before the counter existed: start past every used id. + if (nextId < _initialPreKeyCount) { + final db = await DatabaseHelper.instance.database; + final maxRow = await db.rawQuery( + 'SELECT MAX(key_id) AS max_id FROM signal_prekeys', + ); + final maxLocal = (maxRow.first['max_id'] as int?) ?? 0; + nextId = maxLocal + 1 > _initialPreKeyCount + ? maxLocal + 1 + : _initialPreKeyCount; } + + final batch = generatePreKeys(nextId, _refillPreKeyCount); + for (final preKey in batch) { + await _store.storePreKey(preKey.id, preKey); + } + await prefs.setInt(_nextPreKeyIdPref, nextId + _refillPreKeyCount); + await _upload(preKeys: batch); + debugPrint("E2EE: uploaded ${batch.length} new one-time prekeys"); } - Future establishSessionIfNeeded(String remoteUserId) async { + /// Wipes this account's keys on the server (password re-auth), then + /// generates and publishes a fresh identity. Existing sessions end. + Future resetAllKeys(String password) async { await initStore(); - final address = _getAddress(remoteUserId); + await _api.resetEncryptionKeys(password); + await DatabaseHelper.instance.wipeSignalState(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_nextPreKeyIdPref); + await ensureKeysPublished(); + } + + /// Deletes local keys, e.g. when a different account signs in on this device. + Future wipeLocalKeys() async { + await DatabaseHelper.instance.wipeSignalState(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_nextPreKeyIdPref); + } + + // --- Sessions --- + Future establishSessionIfNeeded(String remoteUserId) { + return _pendingSessions[remoteUserId] ??= _establishSession(remoteUserId) + .whenComplete(() { + _pendingSessions.remove(remoteUserId); + }); + } + + Future _establishSession(String remoteUserId) async { + await initStore(); + final address = _getAddress(remoteUserId); if (await _store.containsSession(address)) return true; try { - final response = await _api.get( - "/e2ee/bundle/$remoteUserId?device_id=main", + final response = await _api.getPrekeyBundle( + remoteUserId, + deviceId: deviceId, ); - final data = response.data['data'] ?? response.data; + final data = ApiService.dataMap(response.data); final identityKeyStr = data['identity_key']; final signedPreKeyStr = data['signed_prekey']; final signatureStr = data['signature']; - final otpBodyStr = data['one_time_prekey_body']; - final otpId = data['one_time_prekey_id']; - if (identityKeyStr == null || signedPreKeyStr == null || - signatureStr == null || - otpBodyStr == null) { - debugPrint("Receiver bundle is missing required cryptographic keys."); + signatureStr == null) { + debugPrint("E2EE: bundle for $remoteUserId is incomplete"); return false; } - final identityKey = IdentityKey( - Curve.decodePoint(base64Decode(identityKeyStr), 0), - ); - final signedPreKey = Curve.decodePoint(base64Decode(signedPreKeyStr), 0); - final signature = base64Decode(signatureStr); - final preKey = Curve.decodePoint(base64Decode(otpBodyStr), 0); + // One-time prekeys run out; X3DH still works with the signed prekey. + final otpBody = data['one_time_prekey_body'] as String?; + final otpId = data['one_time_prekey_id'] as int?; final bundle = PreKeyBundle( 0, 1, - (otpId as int?) ?? 1, - preKey, - 0, - signedPreKey, - signature, - identityKey, + otpBody != null ? otpId : null, + otpBody != null ? Curve.decodePoint(base64Decode(otpBody), 0) : null, + _signedPreKeyId, + Curve.decodePoint(base64Decode(signedPreKeyStr), 0), + base64Decode(signatureStr), + IdentityKey(Curve.decodePoint(base64Decode(identityKeyStr), 0)), ); - final sessionBuilder = SessionBuilder( + await SessionBuilder( _store, _store, _store, _store, address, - ); - - await sessionBuilder.processPreKeyBundle(bundle); - debugPrint("E2EE Session established perfectly with $remoteUserId!"); + ).processPreKeyBundle(bundle); return true; } on DioException catch (e) { - if (e.response?.statusCode == 404) { - debugPrint("Receiver $remoteUserId has not registered E2EE keys yet."); - } else { - debugPrint( - "Network error fetching bundle for $remoteUserId: ${e.message}", - ); - } + debugPrint( + e.response?.statusCode == 404 + ? "E2EE: $remoteUserId has not published keys yet" + : "E2EE: bundle fetch failed for $remoteUserId: ${e.message}", + ); return false; } catch (e) { - debugPrint("Failed to establish E2EE session with $remoteUserId: $e"); + debugPrint("E2EE: session setup with $remoteUserId failed: $e"); return false; } } - Future> encryptMessage( - String remoteUserId, - String plaintext, - ) async { - debugPrint("SignalService: Encrypting message for $remoteUserId..."); + /// Starts over with a contact: forgets the session and the pinned identity + /// key (so a reinstalled contact is trusted again), then builds a new one. + Future forceResetSession(String remoteUserId) async { + await initStore(); final address = _getAddress(remoteUserId); - final sessionCipher = SessionCipher( + await _store.deleteSession(address); + final db = await DatabaseHelper.instance.database; + await db.delete( + 'signal_identities', + where: 'address = ?', + whereArgs: [address.getName()], + ); + return establishSessionIfNeeded(remoteUserId); + } + + Future clearAllSessions() async { + final db = await DatabaseHelper.instance.database; + await db.delete('signal_sessions'); + } + + // --- Encryption --- + + /// Encrypts [plaintext] for a contact and returns the wire envelope. + Future encryptDirect(String remoteUserId, String plaintext) { + return _serial(() => _encrypt(remoteUserId, plaintext)); + } + + Future _encrypt(String remoteUserId, String plaintext) async { + await initStore(); + final cipher = SessionCipher( _store, _store, _store, _store, - address, + _getAddress(remoteUserId), ); + final message = await cipher.encrypt( + Uint8List.fromList(utf8.encode(plaintext)), + ); + return MessageEnvelope( + message.getType(), + base64Encode(message.serialize()), + ).encode(); + } - final plaintextBytes = Uint8List.fromList(utf8.encode(plaintext)); - final ciphertextMessage = await sessionCipher.encrypt(plaintextBytes); + /// Turns wire content into readable text. Plaintext envelopes (groups) are + /// unwrapped, Signal envelopes are decrypted, and failures become a + /// [MessageEnvelope.locked] placeholder instead of throwing. + Future decodeIncoming(String senderId, String content) async { + final envelope = MessageEnvelope.tryParse(content); + if (envelope == null) return content; + if (envelope.isPlaintext) return envelope.body; - debugPrint( - "SignalService: Encryption successful (Type ${ciphertextMessage.getType()}).", - ); - return { - 'type': ciphertextMessage.getType(), - 'ciphertext': base64Encode(ciphertextMessage.serialize()), - }; + try { + return await _serial(() => _decrypt(senderId, envelope)); + } catch (e) { + debugPrint("E2EE: decrypt failed from $senderId: $e"); + final error = e.toString(); + if (error.contains('UntrustedIdentity')) { + return MessageEnvelope.locked( + 'Safety number changed. Reset the secure session to read new messages.', + ); + } + if (error.contains('DuplicateMessage')) { + return MessageEnvelope.locked( + 'Message already decrypted on this device', + ); + } + if (error.contains('NoSession') || + error.contains('InvalidKeyId') || + error.contains('Bad Mac')) { + return MessageEnvelope.locked('Encrypted for a previous session'); + } + return MessageEnvelope.locked('Unable to decrypt this message'); + } } - Future decryptMessage( - String remoteUserId, - String base64Ciphertext, - int type, - ) async { - debugPrint("SignalService: Decrypting message from $remoteUserId..."); - final address = _getAddress(remoteUserId); - final sessionCipher = SessionCipher( + Future _decrypt(String senderId, MessageEnvelope envelope) async { + await initStore(); + final cipher = SessionCipher( _store, _store, _store, _store, - address, + _getAddress(senderId), ); - final bytes = base64Decode(base64Ciphertext); - - Uint8List plaintextBytes; + final bytes = base64Decode(envelope.body); - if (type == CiphertextMessage.prekeyType) { - plaintextBytes = await sessionCipher.decrypt(PreKeySignalMessage(bytes)); + final Uint8List plaintext; + if (envelope.type == CiphertextMessage.prekeyType) { + plaintext = await cipher.decrypt(PreKeySignalMessage(bytes)); } else { - final signalMsg = SignalMessage.fromSerialized(bytes); - plaintextBytes = await sessionCipher.decryptFromSignal(signalMsg); + plaintext = await cipher.decryptFromSignal( + SignalMessage.fromSerialized(bytes), + ); } - - debugPrint("SignalService: Decryption successful!"); - return utf8.decode(plaintextBytes); + return utf8.decode(plaintext); } - Future getSafetyNumber( + // --- Verification --- + + /// Safety number for this pair of users plus the server-side verified flag. + /// Returns null when no secure session can be established. + Future getVerification( String localUserId, String remoteUserId, ) async { - final localKeyPair = await _store.getIdentityKeyPair(); - final remoteAddress = _getAddress(remoteUserId); - final remoteIdentity = await _store.getIdentity(remoteAddress); + await initStore(); + if (!await establishSessionIfNeeded(remoteUserId)) return null; + final remoteIdentity = await _store.getIdentity(_getAddress(remoteUserId)); if (remoteIdentity == null) return null; - final generator = NumericFingerprintGenerator(5200); - - final fingerprint = generator.createFor( + final localKeyPair = await _store.getIdentityKeyPair(); + final fingerprint = NumericFingerprintGenerator(5200).createFor( 1, Uint8List.fromList(utf8.encode(localUserId)), localKeyPair.getPublicKey(), @@ -225,154 +376,21 @@ class SignalService { remoteIdentity, ); - return fingerprint.displayableFingerprint.localFingerprintNumbers; - } - - Future forceResetSession(String remoteUserId) async { - final address = _getAddress(remoteUserId); - await _store.deleteSession(address); - await establishSessionIfNeeded(remoteUserId); - } - - Future clearAllSessions() async { - final db = await DatabaseHelper.instance.database; - await db.delete('signal_sessions'); - } - - Future ensureIdentityInitialized() async { - await initStore(); - final db = await DatabaseHelper.instance.database; - final existingKeys = await db.query('signal_local_keys', where: 'id = 1'); - - if (existingKeys.isNotEmpty) { - debugPrint("E2EE: Keys already exist. Skipping generation."); - return; - } - - debugPrint("E2EE: Generating new local identity keys..."); - final identityKeyPair = generateIdentityKeyPair(); - final registrationId = generateRegistrationId(false); - await _store.storeLocalData(identityKeyPair, registrationId); - - final preKeys = generatePreKeys(0, 100); - final signedPreKey = generateSignedPreKey(identityKeyPair, 0); - - for (var preKey in preKeys) { - await _store.storePreKey(preKey.id, preKey); - } - await _store.storeSignedPreKey(signedPreKey.id, signedPreKey); - } - - Future uploadPublicKeys() async { - await initStore(); - + bool isVerified = false; try { - final identityKeyPair = await _store.getIdentityKeyPair(); - - final signedPreKey = await _store.loadSignedPreKey(0); - - final db = await DatabaseHelper.instance.database; - final preKeyRows = await db.query('signal_prekeys'); - - final publicPreKeys = preKeyRows.map((row) { - final preKeyId = row['key_id'] as int; - final recordBytes = base64Decode(row['record'] as String); - final preKeyRecord = PreKeyRecord.fromBuffer(recordBytes); - - return { - 'id': preKeyId, - 'content': base64Encode( - preKeyRecord.getKeyPair().publicKey.serialize(), - ), - }; - }).toList(); - - final payload = { - 'device_id': 'main', - 'identity_key': base64Encode( - identityKeyPair.getPublicKey().serialize(), - ), - 'signed_prekey': base64Encode( - signedPreKey.getKeyPair().publicKey.serialize(), - ), - 'signature': base64Encode(signedPreKey.signature), - 'one_time_prekeys': publicPreKeys, - }; - - await _api.post("/e2ee/keys", data: payload); - debugPrint("E2EE: Keys successfully synced to server for this session."); + final res = await _api.getVerification(remoteUserId); + isVerified = ApiService.dataMap(res.data)['is_verified'] == true; } catch (e) { - debugPrint("Failed to sync E2EE keys: $e"); + debugPrint("E2EE: verification lookup failed: $e"); } - } - - Future> encryptGroupMessage( - String groupId, - String currentUserId, - String plaintext, - ) async { - await initStore(); - final senderKeyName = SenderKeyName(groupId, _getAddress(currentUserId)); - - final record = await _store.loadSenderKey(senderKeyName); - bool needsDistribution = false; - - String? distMessageBase64; - - if (record.isEmpty) { - final builder = GroupSessionBuilder(_store); - final distMessage = await builder.create(senderKeyName); - - needsDistribution = true; - distMessageBase64 = base64Encode(distMessage.serialize()); - } - - final groupCipher = GroupCipher(_store, senderKeyName); - final plaintextBytes = Uint8List.fromList(utf8.encode(plaintext)); - final ciphertextBytes = await groupCipher.encrypt(plaintextBytes); - - return { - 'type': 3, - 'ciphertext': base64Encode(ciphertextBytes), - 'needs_distribution': needsDistribution, - 'distribution_message': distMessageBase64, - }; - } - - Future decryptGroupMessage( - String groupId, - String senderUserId, - String base64Ciphertext, - ) async { - await initStore(); - final senderKeyName = SenderKeyName(groupId, _getAddress(senderUserId)); - final groupCipher = GroupCipher(_store, senderKeyName); - - final ciphertextBytes = base64Decode(base64Ciphertext); - final plaintextBytes = await groupCipher.decrypt(ciphertextBytes); - - return utf8.decode(plaintextBytes); - } - - Future processSenderKeyDistribution( - String groupId, - String senderUserId, - String base64DistributionMessage, - ) async { - await initStore(); - final senderKeyName = SenderKeyName(groupId, _getAddress(senderUserId)); - final builder = GroupSessionBuilder(_store); - - final messageBytes = base64Decode(base64DistributionMessage); - - final distMessage = SenderKeyDistributionMessageWrapper.fromSerialized( - messageBytes, + return VerificationInfo( + safetyNumber: fingerprint.displayableFingerprint.getDisplayText(), + isVerified: isVerified, ); + } - await builder.process(senderKeyName, distMessage); - debugPrint( - "E2EE: Processed new SenderKey for Group: $groupId from User: $senderUserId", - ); + Future setVerified(String remoteUserId, bool isVerified) async { + await _api.setVerification(remoteUserId, isVerified); } } diff --git a/mobile/lib/services/ws_service.dart b/mobile/lib/services/ws_service.dart index 3a3355f..b1ff5d3 100644 --- a/mobile/lib/services/ws_service.dart +++ b/mobile/lib/services/ws_service.dart @@ -1,9 +1,16 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../core/constants.dart'; +/// WebSocket link to `/api/ws`. +/// +/// Liveness relies on protocol-level pings: the server pings every 54s and +/// this client pings every 25s. A missed pong closes the stream, and +/// [ChatConnectionController] reconnects. The server has no application-level +/// ping/pong event. class WebSocketService { static final WebSocketService _instance = WebSocketService._internal(); factory WebSocketService() => _instance; @@ -11,104 +18,112 @@ class WebSocketService { WebSocketChannel? _channel; bool _isConnected = false; - Timer? _heartbeatTimer; - Timer? _pongWatchdog; Stream? _broadcastStream; Stream? get stream => _broadcastStream; bool get isConnected => _isConnected; - VoidCallback? onConnectionLost; - Future connect(String token) async { if (_isConnected) return true; try { - final wsUrl = Uri.parse("${Env.wsBaseUrl}?token=$token"); - _channel = WebSocketChannel.connect(wsUrl); + final wsUrl = Uri.parse( + "${Env.wsBaseUrl}?token=${Uri.encodeQueryComponent(token)}", + ); + _channel = IOWebSocketChannel.connect( + wsUrl, + pingInterval: const Duration(seconds: 25), + connectTimeout: const Duration(seconds: 10), + ); await _channel!.ready; _broadcastStream = _channel!.stream.asBroadcastStream(); _isConnected = true; - debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); - - _startHeartbeat(); + debugPrint("WebSocket connected to ${Env.wsBaseUrl}"); return true; } catch (e) { _isConnected = false; - debugPrint("WebSocket connection failure (Handshake rejected): $e"); + _channel = null; + debugPrint("WebSocket connection failure: $e"); return false; } } - void _startHeartbeat() { - _heartbeatTimer?.cancel(); - _pongWatchdog?.cancel(); - - _heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (timer) { - if (!_isConnected) { - timer.cancel(); - return; - } - - emit({"type": "ping"}); - - _pongWatchdog?.cancel(); - _pongWatchdog = Timer(const Duration(seconds: 6), () { - debugPrint("🚨 WS: Watchdog timed out. Half-open socket detected."); - disconnect(); - onConnectionLost?.call(); - }); - }); - } - - void registerPong() { - _pongWatchdog?.cancel(); - } - - void emit(Map payload) { - if (!_isConnected || _channel == null) { - debugPrint("⚠️ WS: Attempted to emit frame while disconnected."); - return; + /// Sends a frame. Returns false when disconnected or when the frame would + /// exceed the server's 4096-byte limit (which would close the socket). + bool emit(Map payload) { + if (!_isConnected || _channel == null) return false; + + payload.removeWhere((_, value) => value == null); + final encoded = jsonEncode(payload); + if (utf8.encode(encoded).length > ServerLimits.maxWsFrameBytes) { + debugPrint( + "WS: frame of type ${payload['type']} exceeds the server limit", + ); + return false; } + try { - final encoded = jsonEncode(payload); - debugPrint("Sending Payload to WS: $encoded"); - _channel?.sink.add(encoded); + _channel!.sink.add(encoded); + return true; } catch (e) { - debugPrint("🚨 WS: Write error on sink (Socket broken): $e"); + debugPrint("WS: write error: $e"); disconnect(); - onConnectionLost?.call(); + return false; } } - void sendChat({ + static int frameSize(Map payload) { + payload.removeWhere((_, value) => value == null); + return utf8.encode(jsonEncode(payload)).length; + } + + static Map chatFrame({ required String messageId, - required String receiverId, + String? receiverId, + String? groupId, required String content, String? replyToMessageId, }) { - emit({ + return { "type": "chat", "message_id": messageId, "receiver_id": receiverId, + "group_id": groupId, "content": content, "reply_to_message_id": replyToMessageId, - }); + }; + } + + bool sendChat({ + required String messageId, + required String receiverId, + required String content, + String? replyToMessageId, + }) { + return emit( + chatFrame( + messageId: messageId, + receiverId: receiverId, + content: content, + replyToMessageId: replyToMessageId, + ), + ); } - void sendGroupChat({ + bool sendGroupChat({ required String messageId, required String groupId, required String content, String? replyToMessageId, }) { - emit({ - "type": "chat", - "message_id": messageId, - "group_id": groupId, - "content": content, - "reply_to_message_id": replyToMessageId, - }); + return emit( + chatFrame( + messageId: messageId, + groupId: groupId, + content: content, + replyToMessageId: replyToMessageId, + ), + ); } void sendTyping({ @@ -118,8 +133,8 @@ class WebSocketService { }) { emit({ "type": "typing", - if (receiverId != null) "receiver_id": receiverId, - if (groupId != null) "group_id": groupId, + "receiver_id": receiverId, + "group_id": groupId, "content": isTyping.toString(), }); } @@ -127,8 +142,8 @@ class WebSocketService { void sendReadReceipt({String? receiverId, String? groupId}) { emit({ "type": "read_receipt", - if (receiverId != null) "receiver_id": receiverId, - if (groupId != null) "group_id": groupId, + "receiver_id": receiverId, + "group_id": groupId, }); } @@ -137,13 +152,11 @@ class WebSocketService { } void disconnect() { - _heartbeatTimer?.cancel(); - _pongWatchdog?.cancel(); try { _channel?.sink.close(); } catch (_) {} _isConnected = false; _channel = null; - debugPrint("WebSocket Pipeline Terminated Cleanly."); + _broadcastStream = null; } -} \ No newline at end of file +} diff --git a/mobile/lib/themes/app_themes.dart b/mobile/lib/themes/app_themes.dart index 95e4515..eef35e3 100644 --- a/mobile/lib/themes/app_themes.dart +++ b/mobile/lib/themes/app_themes.dart @@ -2,61 +2,508 @@ import 'package:flutter/material.dart'; enum ThemeType { frost, aura, onyx, nebula } +/// Raw colors for one Elephant theme. Every screen reads these through +/// [ThemeData.colorScheme] and the [GlassTokens] extension, never directly. +class ElephantPalette { + final String name; + final String tagline; + final Brightness brightness; + final Color background; + final Color surface; + final Color accent; + final Color accentAlt; + final Color onAccent; + final Color text; + final Color muted; + final Color outline; + final Color incomingBubble; + final List ambient; + + const ElephantPalette({ + required this.name, + required this.tagline, + required this.brightness, + required this.background, + required this.surface, + required this.accent, + required this.accentAlt, + required this.onAccent, + required this.text, + required this.muted, + required this.outline, + required this.incomingBubble, + required this.ambient, + }); + + bool get isDark => brightness == Brightness.dark; +} + +/// Theme-specific design tokens that Material's [ColorScheme] can't express: +/// the frosted-glass surfaces, gradient accents and ambient glow behind them. +@immutable +class GlassTokens extends ThemeExtension { + final Color glassFill; + final Color glassBorder; + final Color glassStrongFill; + final LinearGradient accentGradient; + final Color onAccent; + final Color incomingBubble; + final Color incomingText; + final Color readTick; + final Color success; + final Color warning; + final List ambient; + final double blur; + + const GlassTokens({ + required this.glassFill, + required this.glassBorder, + required this.glassStrongFill, + required this.accentGradient, + required this.onAccent, + required this.incomingBubble, + required this.incomingText, + required this.readTick, + required this.success, + required this.warning, + required this.ambient, + this.blur = 18, + }); + + @override + GlassTokens copyWith({double? blur}) { + return GlassTokens( + glassFill: glassFill, + glassBorder: glassBorder, + glassStrongFill: glassStrongFill, + accentGradient: accentGradient, + onAccent: onAccent, + incomingBubble: incomingBubble, + incomingText: incomingText, + readTick: readTick, + success: success, + warning: warning, + ambient: ambient, + blur: blur ?? this.blur, + ); + } + + @override + GlassTokens lerp(ThemeExtension? other, double t) { + if (other is! GlassTokens) return this; + return GlassTokens( + glassFill: Color.lerp(glassFill, other.glassFill, t)!, + glassBorder: Color.lerp(glassBorder, other.glassBorder, t)!, + glassStrongFill: Color.lerp(glassStrongFill, other.glassStrongFill, t)!, + accentGradient: LinearGradient.lerp( + accentGradient, + other.accentGradient, + t, + )!, + onAccent: Color.lerp(onAccent, other.onAccent, t)!, + incomingBubble: Color.lerp(incomingBubble, other.incomingBubble, t)!, + incomingText: Color.lerp(incomingText, other.incomingText, t)!, + readTick: Color.lerp(readTick, other.readTick, t)!, + success: Color.lerp(success, other.success, t)!, + warning: Color.lerp(warning, other.warning, t)!, + ambient: List.generate( + ambient.length, + (i) => + Color.lerp(ambient[i], other.ambient[i % other.ambient.length], t)!, + ), + blur: blur + (other.blur - blur) * t, + ); + } +} + +/// Spacing and corner radii shared by every screen. +class Insets { + static const double xs = 4; + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 24; + static const double xxl = 32; + + /// Horizontal page gutter. + static const double page = 20; +} + +class Radii { + static const double sm = 10; + static const double md = 16; + static const double lg = 22; + static const double xl = 28; + static const double bubble = 20; +} + +extension ElephantThemeX on BuildContext { + ThemeData get theme => Theme.of(this); + ColorScheme get colors => Theme.of(this).colorScheme; + GlassTokens get glass => Theme.of(this).extension()!; + TextTheme get text => Theme.of(this).textTheme; +} + class AppThemes { - static final ThemeData frost = ThemeData( + static const ElephantPalette frostPalette = ElephantPalette( + name: 'Frost', + tagline: 'Crisp, cool and bright', brightness: Brightness.light, - scaffoldBackgroundColor: const Color(0xFFF5F7FA), - colorScheme: const ColorScheme.light( - primary: Color(0xFF007AFF), - surface: Color(0x99FFFFFF), - onSurface: Colors.black87, - ), - useMaterial3: true, + background: Color(0xFFF2F5FB), + surface: Color(0xFFFFFFFF), + accent: Color(0xFF2F6BFF), + accentAlt: Color(0xFF14B8D4), + onAccent: Color(0xFFFFFFFF), + text: Color(0xFF0F172A), + muted: Color(0xFF64748B), + outline: Color(0xFFDCE3EE), + incomingBubble: Color(0xFFFFFFFF), + ambient: [Color(0xFF7FA6FF), Color(0xFF6FE3F0), Color(0xFFC3B5FF)], ); - static final ThemeData aura = ThemeData( + static const ElephantPalette auraPalette = ElephantPalette( + name: 'Aura', + tagline: 'Warm light, soft glow', brightness: Brightness.light, - scaffoldBackgroundColor: const Color(0xFFFFFBF7), - colorScheme: const ColorScheme.light( - primary: Color(0xFFFF8A65), - surface: Color(0x99FFF0E5), - onSurface: Color(0xFF3E2723), - ), - useMaterial3: true, + background: Color(0xFFFFF7F1), + surface: Color(0xFFFFFFFF), + accent: Color(0xFFEF6A45), + accentAlt: Color(0xFFF6A93B), + onAccent: Color(0xFFFFFFFF), + text: Color(0xFF2A1A14), + muted: Color(0xFF8C7166), + outline: Color(0xFFF0DFD5), + incomingBubble: Color(0xFFFFFFFF), + ambient: [Color(0xFFFFB38A), Color(0xFFFFD27A), Color(0xFFF7A1B5)], ); - static final ThemeData onyx = ThemeData( + static const ElephantPalette onyxPalette = ElephantPalette( + name: 'Onyx', + tagline: 'Pure dark, quiet contrast', brightness: Brightness.dark, - scaffoldBackgroundColor: const Color(0xFF121212), - colorScheme: const ColorScheme.dark( - primary: Color(0xFFE0E0E0), - surface: Color(0x991E1E1E), - onSurface: Colors.white, - ), - useMaterial3: true, + background: Color(0xFF0A0A0C), + surface: Color(0xFF16161A), + accent: Color(0xFFF2F2F4), + accentAlt: Color(0xFFA9A9B2), + onAccent: Color(0xFF0A0A0C), + text: Color(0xFFF4F4F6), + muted: Color(0xFF8E8E98), + outline: Color(0xFF2A2A30), + incomingBubble: Color(0xFF1E1E23), + ambient: [Color(0xFF3A3A44), Color(0xFF26262E), Color(0xFF4A4A55)], ); - static final ThemeData nebula = ThemeData( + static const ElephantPalette nebulaPalette = ElephantPalette( + name: 'Nebula', + tagline: 'Deep space, vivid light', brightness: Brightness.dark, - scaffoldBackgroundColor: const Color(0xFF0B0914), - colorScheme: const ColorScheme.dark( - primary: Color(0xFF8A2BE2), - surface: Color(0x99191330), - onSurface: Color(0xFFE6E6FA), - ), - useMaterial3: true, + background: Color(0xFF0B0916), + surface: Color(0xFF17132B), + accent: Color(0xFF8B5CF6), + accentAlt: Color(0xFFEC4899), + onAccent: Color(0xFFFFFFFF), + text: Color(0xFFEFEAFF), + muted: Color(0xFFA49BC8), + outline: Color(0xFF2C2547), + incomingBubble: Color(0xFF1F1A38), + ambient: [Color(0xFF6D3EF0), Color(0xFFD93A8C), Color(0xFF2E5BFF)], ); - static ThemeData getTheme(ThemeType type) { + static ElephantPalette paletteOf(ThemeType type) { switch (type) { case ThemeType.frost: - return frost; + return frostPalette; case ThemeType.aura: - return aura; + return auraPalette; case ThemeType.onyx: - return onyx; + return onyxPalette; case ThemeType.nebula: - return nebula; + return nebulaPalette; } } + + static final Map _cache = {}; + + static ThemeData getTheme(ThemeType type) => + _cache[type] ??= _build(paletteOf(type)); + + static ThemeData _build(ElephantPalette p) { + final scheme = + ColorScheme.fromSeed( + seedColor: p.accent, + brightness: p.brightness, + ).copyWith( + primary: p.accent, + onPrimary: p.onAccent, + secondary: p.accentAlt, + onSecondary: p.onAccent, + tertiary: p.accentAlt, + surface: p.surface, + onSurface: p.text, + onSurfaceVariant: p.muted, + outline: p.outline, + outlineVariant: p.outline, + surfaceContainerLowest: p.background, + surfaceContainerLow: Color.alphaBlend( + p.text.withValues(alpha: 0.02), + p.surface, + ), + surfaceContainer: Color.alphaBlend( + p.text.withValues(alpha: 0.04), + p.surface, + ), + surfaceContainerHigh: Color.alphaBlend( + p.text.withValues(alpha: 0.06), + p.surface, + ), + surfaceContainerHighest: Color.alphaBlend( + p.text.withValues(alpha: 0.08), + p.surface, + ), + error: p.isDark ? const Color(0xFFFF6B7A) : const Color(0xFFD92D3A), + ); + + final glass = GlassTokens( + glassFill: p.surface.withValues(alpha: p.isDark ? 0.58 : 0.68), + glassStrongFill: p.surface.withValues(alpha: p.isDark ? 0.82 : 0.88), + glassBorder: p.isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.white.withValues(alpha: 0.75), + accentGradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [p.accent, p.accentAlt], + ), + onAccent: p.onAccent, + incomingBubble: p.isDark + ? p.incomingBubble.withValues(alpha: 0.86) + : p.incomingBubble.withValues(alpha: 0.9), + incomingText: p.text, + readTick: p.isDark ? const Color(0xFF7DD3FC) : const Color(0xFF0EA5E9), + success: p.isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A), + warning: p.isDark ? const Color(0xFFFBBF24) : const Color(0xFFD97706), + ambient: p.ambient, + ); + + final base = ThemeData( + useMaterial3: true, + brightness: p.brightness, + colorScheme: scheme, + ); + + final textTheme = base.textTheme + .apply(bodyColor: p.text, displayColor: p.text) + .copyWith( + headlineMedium: base.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + color: p.text, + ), + titleLarge: base.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: p.text, + ), + titleMedium: base.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + color: p.text, + ), + labelLarge: base.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + labelSmall: base.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: p.muted, + ), + ); + + final inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.md), + borderSide: BorderSide(color: p.outline.withValues(alpha: 0.0)), + ); + + return base.copyWith( + scaffoldBackgroundColor: p.background, + canvasColor: p.background, + textTheme: textTheme, + extensions: [glass], + splashFactory: InkSparkle.splashFactory, + dividerTheme: DividerThemeData( + color: p.outline.withValues(alpha: p.isDark ? 0.7 : 1), + thickness: 1, + space: 1, + ), + appBarTheme: AppBarTheme( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + foregroundColor: p.text, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: textTheme.titleLarge?.copyWith(fontSize: 20), + iconTheme: IconThemeData(color: p.text), + ), + iconTheme: IconThemeData(color: p.text), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh.withValues( + alpha: p.isDark ? 0.7 : 0.9, + ), + hintStyle: TextStyle(color: p.muted.withValues(alpha: 0.8)), + labelStyle: TextStyle(color: p.muted), + floatingLabelStyle: TextStyle( + color: p.accent, + fontWeight: FontWeight.w600, + ), + prefixIconColor: p.muted, + suffixIconColor: p.muted, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: inputBorder, + enabledBorder: inputBorder, + focusedBorder: inputBorder.copyWith( + borderSide: BorderSide(color: p.accent, width: 1.6), + ), + errorBorder: inputBorder.copyWith( + borderSide: BorderSide(color: scheme.error, width: 1.2), + ), + focusedErrorBorder: inputBorder.copyWith( + borderSide: BorderSide(color: scheme.error, width: 1.6), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: p.accent, + foregroundColor: p.onAccent, + minimumSize: const Size(64, 50), + textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: p.text, + minimumSize: const Size(64, 50), + side: BorderSide(color: p.outline), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: p.isDark && p.accent.computeLuminance() > 0.8 + ? p.text + : p.accent, + textStyle: const TextStyle(fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.sm), + ), + ), + ), + listTileTheme: ListTileThemeData( + iconColor: p.muted, + textColor: p.text, + contentPadding: const EdgeInsets.symmetric(horizontal: Insets.lg), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + titleTextStyle: textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: p.text, + ), + subtitleTextStyle: textTheme.bodyMedium?.copyWith(color: p.muted), + ), + dialogTheme: DialogThemeData( + backgroundColor: p.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.xl), + ), + titleTextStyle: textTheme.titleLarge, + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: p.muted, + height: 1.45, + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + modalBackgroundColor: Colors.transparent, + elevation: 0, + modalElevation: 0, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: p.isDark + ? const Color(0xFFF4F4F6) + : const Color(0xFF14161C), + contentTextStyle: TextStyle( + color: p.isDark ? const Color(0xFF14161C) : Colors.white, + fontWeight: FontWeight.w500, + ), + actionTextColor: p.isDark ? p.accent.withValues(alpha: 1) : p.accentAlt, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + ), + popupMenuTheme: PopupMenuThemeData( + color: p.surface, + surfaceTintColor: Colors.transparent, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + textStyle: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.selected) ? p.onAccent : p.muted, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? p.accent + : scheme.surfaceContainerHighest, + ), + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + ), + chipTheme: ChipThemeData( + backgroundColor: scheme.surfaceContainerHigh, + selectedColor: p.accent.withValues(alpha: 0.16), + side: BorderSide.none, + labelStyle: TextStyle(color: p.text, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.xl), + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: p.accent, + linearTrackColor: p.accent.withValues(alpha: 0.12), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: p.accent, + foregroundColor: p.onAccent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.lg), + ), + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + }, + ), + ); + } } diff --git a/mobile/lib/themes/theme_provider.dart b/mobile/lib/themes/theme_provider.dart index 29b6808..e02adbf 100644 --- a/mobile/lib/themes/theme_provider.dart +++ b/mobile/lib/themes/theme_provider.dart @@ -4,39 +4,27 @@ import 'package:shared_preferences/shared_preferences.dart'; class ThemeProvider extends ChangeNotifier { ThemeType _currentTheme = ThemeType.frost; - bool _isInitialized = false; ThemeType get currentTheme => _currentTheme; ThemeData get themeData => AppThemes.getTheme(_currentTheme); - bool get isInitialized => _isInitialized; + ElephantPalette get palette => AppThemes.paletteOf(_currentTheme); - ThemeProvider() { - _loadTheme(); - } - - Future _loadTheme() async { + Future load() 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.frost; } - - _isInitialized = true; - notifyListeners(); } Future setTheme(ThemeType themeType) async { - if (_currentTheme != themeType) { - _currentTheme = themeType; - notifyListeners(); + if (_currentTheme == themeType) return; + _currentTheme = themeType; + notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('app_theme', themeType.index); - } + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('app_theme', themeType.index); } } diff --git a/mobile/lib/widgets/chat_page_widgets.dart b/mobile/lib/widgets/chat_page_widgets.dart index 8c81aa2..e83e5e8 100644 --- a/mobile/lib/widgets/chat_page_widgets.dart +++ b/mobile/lib/widgets/chat_page_widgets.dart @@ -1,503 +1,858 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:intl/intl.dart'; +import 'package:mobile/core/message_envelope.dart'; import 'package:mobile/models/message.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/home_page_widgets.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; -enum UserStatus { offline, online } - -class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { +/// Chat header: back, avatar, name and a live status line. +class ChatHeader extends StatelessWidget implements PreferredSizeWidget { final String name; - final UserStatus? status; + final String seed; final bool isGroup; - final GestureTapCallback? onTitleTap; + final String subtitle; + final bool? isOnline; + final bool highlightSubtitle; + final VoidCallback? onTitleTap; + final List? actions; - const GlassAppBar({ + const ChatHeader({ super.key, required this.name, - required this.status, + required this.seed, required this.isGroup, + required this.subtitle, + this.isOnline, + this.highlightSubtitle = false, this.onTitleTap, + this.actions, }); @override - Widget build(BuildContext context) { - final theme = Theme.of(context); + Size get preferredSize => const Size.fromHeight(kToolbarHeight + 6); - return ClipRRect( + @override + Widget build(BuildContext context) { + final glass = context.glass; + return ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: AppBar( - backgroundColor: theme.colorScheme.surface, - elevation: 0, - scrolledUnderElevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back, color: theme.iconTheme.color), - onPressed: () => Navigator.pop(context), + filter: ImageFilter.blur(sigmaX: glass.blur, sigmaY: glass.blur), + child: DecoratedBox( + decoration: BoxDecoration( + color: glass.glassStrongFill, + border: Border(bottom: BorderSide(color: glass.glassBorder)), ), - title: GestureDetector( - onTap: onTitleTap, - child: Row( - children: [ - Hero( - tag: 'profile', - child: CircleAvatar( - backgroundColor: theme.colorScheme.primaryContainer, - child: Icon( - Icons.person, - color: theme.colorScheme.onPrimary, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.textTheme.titleLarge?.color, - fontSize: 16, - fontWeight: FontWeight.bold, - ), + child: AppBar( + toolbarHeight: kToolbarHeight + 6, + backgroundColor: Colors.transparent, + titleSpacing: 0, + actions: actions, + title: InkWell( + borderRadius: BorderRadius.circular(Radii.md), + onTap: onTitleTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + children: [ + Hero( + tag: 'avatar-$seed', + child: ElephantAvatar( + name: name, + seed: seed, + isGroup: isGroup, + size: 40, + isOnline: isOnline, ), - status != null - ? Text( - status! == UserStatus.online ? "Online" : "Offline", + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + subtitle, + key: ValueKey(subtitle), + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - color: theme.textTheme.bodySmall?.color, - fontSize: 12, - fontWeight: FontWeight.normal, + fontSize: 12.5, + fontWeight: highlightSubtitle + ? FontWeight.w700 + : FontWeight.w500, + color: highlightSubtitle + ? context.colors.primary + : context.colors.onSurfaceVariant, ), - ) - : SizedBox.shrink(), - ], - ), + ), + ), + ], + ), + ), + ], ), - ], + ), ), ), ), ), ); } - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); } class ChatBubble extends StatelessWidget { - final VoidCallback? onQuoteTap; - final String message; + final Message message; final bool isMe; - final DateTime timestamp; - final bool isRead; - final QuotedMessage? quotedMessage; - final String? senderName; final bool isGroup; - final String syncStatus; + final bool isFirstInRun; + final bool isLastInRun; + final String? senderName; + final String? senderSeed; + final String? quotedAuthor; + final bool highlighted; + final VoidCallback? onQuoteTap; const ChatBubble({ super.key, required this.message, required this.isMe, - required this.timestamp, - required this.isRead, - this.quotedMessage, - this.senderName, this.isGroup = false, + this.isFirstInRun = true, + this.isLastInRun = true, + this.senderName, + this.senderSeed, + this.quotedAuthor, + this.highlighted = false, this.onQuoteTap, - this.syncStatus = 'synced', }); - 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]; - } + @override + Widget build(BuildContext context) { + final glass = context.glass; + final isLocked = message.content.startsWith(MessageEnvelope.lockedPrefix); + final textColor = isMe ? glass.onAccent : glass.incomingText; + final metaColor = textColor.withValues(alpha: isMe ? 0.78 : 0.55); - MarkdownStyleSheet _getMarkdownStyle( - bool isMe, - Color primaryColor, - Color onPrimary, - Color onSurface, - BuildContext context, - ) { - return MarkdownStyleSheet( - p: TextStyle(color: isMe ? onPrimary : onSurface, fontSize: 15), - strong: TextStyle( - color: isMe ? onPrimary : onSurface, - fontWeight: FontWeight.bold, + const big = Radius.circular(Radii.bubble); + const small = Radius.circular(6); + final radius = BorderRadius.only( + topLeft: !isMe && !isFirstInRun ? small : big, + bottomLeft: !isMe && !isLastInRun ? small : (!isMe ? small : big), + topRight: isMe && !isFirstInRun ? small : big, + bottomRight: isMe && !isLastInRun ? small : (isMe ? small : big), + ); + + final showGroupAvatar = isGroup && !isMe; + + final bubble = AnimatedContainer( + duration: const Duration(milliseconds: 300), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.76, + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), + decoration: BoxDecoration( + gradient: isMe ? glass.accentGradient : null, + color: isMe ? null : glass.incomingBubble, + borderRadius: radius, + border: isMe ? null : Border.all(color: glass.glassBorder), + boxShadow: [ + BoxShadow( + color: highlighted + ? context.colors.primary.withValues(alpha: 0.55) + : Colors.black.withValues(alpha: isMe ? 0.10 : 0.05), + blurRadius: highlighted ? 18 : 10, + offset: const Offset(0, 3), + ), + ], + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (isGroup && !isMe && isFirstInRun && senderName != null) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + senderName!, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: ElephantAvatar.colorFor(senderSeed ?? senderName!), + ), + ), + ), + if (message.quotedMessage != null) + _QuoteBlock( + quote: message.quotedMessage!, + author: quotedAuthor, + onMe: isMe, + onTap: onQuoteTap, + ), + if (isLocked) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline_rounded, size: 15, color: metaColor), + const SizedBox(width: 6), + Flexible( + child: Text( + message.content.substring( + MessageEnvelope.lockedPrefix.length, + ), + style: TextStyle( + color: metaColor, + fontStyle: FontStyle.italic, + fontSize: 14, + ), + ), + ), + ], + ) + else + MarkdownBody( + data: message.content, + softLineBreak: true, + styleSheet: _markdownStyle(context, textColor), + ), + const SizedBox(height: 3), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (message.isEdited) ...[ + Text( + 'edited', + style: TextStyle( + color: metaColor, + fontSize: 10.5, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 5), + ], + Text( + DateFormat.jm().format(message.createdAt), + style: TextStyle( + color: metaColor, + fontSize: 10.5, + fontWeight: FontWeight.w500, + ), + ), + if (isMe && !isGroup) ...[ + const SizedBox(width: 4), + DeliveryTick( + isPending: message.isPending, + isRead: message.isRead, + color: metaColor, + readColor: textColor, + size: 15, + ), + ] else if (isMe && message.isPending) ...[ + const SizedBox(width: 4), + Icon(Icons.schedule_rounded, size: 13, color: metaColor), + ], + ], + ), + ), + ], + ), ), - em: TextStyle( - color: isMe ? onPrimary : onSurface, - fontStyle: FontStyle.italic, + ); + + return Padding( + padding: EdgeInsets.only( + top: isFirstInRun ? 6 : 1.5, + bottom: isLastInRun ? 2 : 0, + ), + child: Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showGroupAvatar) ...[ + SizedBox( + width: 30, + child: isLastInRun + ? ElephantAvatar( + name: senderName ?? '?', + seed: senderSeed ?? '', + size: 28, + ) + : null, + ), + const SizedBox(width: 6), + ], + Flexible(child: bubble), + ], ), - del: TextStyle( - color: isMe - ? onPrimary.withValues(alpha: 0.7) - : onSurface.withValues(alpha: 0.5), + ); + } + + MarkdownStyleSheet _markdownStyle(BuildContext context, Color textColor) { + final base = TextStyle(color: textColor, fontSize: 15.5, height: 1.35); + return MarkdownStyleSheet( + p: base, + strong: base.copyWith(fontWeight: FontWeight.w800), + em: base.copyWith(fontStyle: FontStyle.italic), + del: base.copyWith( decoration: TextDecoration.lineThrough, + color: textColor.withValues(alpha: 0.7), + ), + a: base.copyWith( + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, ), code: TextStyle( - backgroundColor: isMe - ? primaryColor.withValues(alpha: 0.8) - : Colors.grey.withValues(alpha: 0.3), - color: isMe ? onPrimary : Theme.of(context).colorScheme.error, + color: textColor, fontFamily: 'monospace', + fontSize: 14, + backgroundColor: textColor.withValues(alpha: 0.12), ), - a: TextStyle( - color: isMe ? onPrimary.withValues(alpha: 0.9) : primaryColor, - decoration: TextDecoration.underline, + codeblockDecoration: BoxDecoration( + color: textColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), ), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide(color: textColor.withValues(alpha: 0.5), width: 3), + ), + ), + listBullet: base, ); } +} + +class _QuoteBlock extends StatelessWidget { + final QuotedMessage quote; + final String? author; + final bool onMe; + final VoidCallback? onTap; + + const _QuoteBlock({ + required this.quote, + this.author, + required this.onMe, + this.onTap, + }); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final String formattedTime = DateFormat.jm().format(timestamp); + final glass = context.glass; + final base = onMe ? glass.onAccent : glass.incomingText; + final accent = onMe ? glass.onAccent : context.colors.primary; - return Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + return GestureDetector( + onTap: onTap, child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.only(top: 10, left: 12, right: 12, bottom: 6), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + margin: const EdgeInsets.only(bottom: 6, top: 2), + padding: const EdgeInsets.fromLTRB(10, 6, 10, 7), + decoration: BoxDecoration( + color: base.withValues(alpha: onMe ? 0.16 : 0.06), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border(left: BorderSide(color: accent, width: 3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (author != null) + Text( + author!, + style: TextStyle( + color: accent, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + Text( + quote.content.replaceAll('\n', ' '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: base.withValues(alpha: 0.8), + fontSize: 13, + ), + ), + ], ), + ), + ); + } +} + +/// "Today" / "Yesterday" / date pill between days. +class DaySeparator extends StatelessWidget { + final DateTime date; + + const DaySeparator({super.key, required this.date}); + + static String label(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final day = DateTime(date.year, date.month, date.day); + final diff = today.difference(day).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return DateFormat.EEEE().format(date); + if (date.year == now.year) return DateFormat.MMMMd().format(date); + return DateFormat.yMMMMd().format(date); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( - color: isMe - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: Radius.circular(isMe ? 16 : 4), - bottomRight: Radius.circular(isMe ? 4 : 16), + color: context.glass.glassStrongFill, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: context.glass.glassBorder), + ), + child: Text( + label(date), + style: context.text.labelSmall?.copyWith( + letterSpacing: 0.3, + fontSize: 11.5, ), ), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - 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!), - ), - ), + ), + ); + } +} + +/// Explains who can read this chat, shown above the first message. +class EncryptionNotice extends StatelessWidget { + final bool isGroup; + + const EncryptionNotice({super.key, required this.isGroup}); + + @override + Widget build(BuildContext context) { + final color = isGroup ? context.glass.warning : context.glass.success; + return Center( + child: Container( + margin: const EdgeInsets.fromLTRB(28, 12, 28, 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isGroup ? Icons.lock_open_rounded : Icons.lock_rounded, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + isGroup + ? 'Group messages are protected in transit but are not end-to-end encrypted yet.' + : 'Messages are end-to-end encrypted. Only you and this contact can read them.', + style: TextStyle( + fontSize: 12.5, + color: color, + fontWeight: FontWeight.w600, + height: 1.35, ), - 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, + ), + ), + ], + ), + ), + ); + } +} + +/// Three bouncing dots in an incoming bubble. +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final glass = context.glass; + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(top: 6, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: glass.incomingBubble, + border: Border.all(color: glass.glassBorder), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(Radii.bubble), + topRight: Radius.circular(Radii.bubble), + bottomRight: Radius.circular(Radii.bubble), + bottomLeft: Radius.circular(6), + ), + ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final t = (_controller.value - i * 0.18) % 1.0; + final lift = t < 0.4 + ? Curves.easeOut.transform(t / 0.4) + : (t < 0.8 + ? 1 - Curves.easeIn.transform((t - 0.4) / 0.4) + : 0.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.5), + child: Transform.translate( + offset: Offset(0, -4 * lift), + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: context.colors.primary.withValues( + alpha: 0.45 + 0.55 * lift, ), + shape: BoxShape.circle, ), ), - 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, - ), - ), - ], - ), + ), + ); + }), + ); + }, + ), + ), + ); + } +} + +/// Banner above the composer for replying to or editing a message. +class ComposerBanner extends StatelessWidget { + final IconData icon; + final String title; + final String body; + final VoidCallback onClose; + + const ComposerBanner({ + super.key, + required this.icon, + required this.title, + required this.body, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(Insets.md, 0, Insets.md, Insets.sm), + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(Radii.md), + border: Border( + left: BorderSide(color: context.colors.primary, width: 3), + ), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: context.colors.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + color: context.colors.primary, + fontWeight: FontWeight.w800, + fontSize: 12.5, ), ), - MarkdownBody( - data: message, - selectable: false, - styleSheet: _getMarkdownStyle( - isMe, - theme.colorScheme.primary, - theme.colorScheme.onPrimary, - theme.colorScheme.onSurface, - context, - ), - ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formattedTime, - style: TextStyle( - color: isMe - ? theme.colorScheme.onPrimary.withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, - fontSize: 10, - ), - ), - if (isMe) ...[ - const SizedBox(width: 4), - Icon( - syncStatus == 'pending' - ? Icons.access_time - : (isRead ? Icons.done_all : Icons.done), - size: 14, - color: syncStatus == 'pending' - ? theme.colorScheme.onPrimary.withValues(alpha: 0.5) - : isRead - ? Colors.lightBlueAccent - : theme.colorScheme.onPrimary.withValues( - alpha: 0.7, - ), - ), - ], - ], + Text( + body.replaceAll('\n', ' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.colors.onSurfaceVariant, + fontSize: 13.5, + ), ), - ), - ], + ], + ), ), - ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: onClose, + ), + ], ), ); } } -class ChatInputArea extends StatefulWidget { - final Function(String) onSendMessage; - final Function(bool)? onTypingChanged; +/// Glass composer: growing text field, byte budget near the limit, and a +/// gradient send button. +class ChatComposer extends StatefulWidget { + final TextEditingController controller; + final FocusNode focusNode; + final Future Function(String) onSend; + final ValueChanged? onTypingChanged; + final int byteLimit; + final Widget? banner; + final bool isEditing; - const ChatInputArea({ + const ChatComposer({ super.key, - required this.onSendMessage, + required this.controller, + required this.focusNode, + required this.onSend, + required this.byteLimit, this.onTypingChanged, + this.banner, + this.isEditing = false, }); @override - State createState() => _ChatInputAreaState(); + State createState() => _ChatComposerState(); } -class _ChatInputAreaState extends State { - final _controller = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - +class _ChatComposerState extends State { bool _isTyping = false; + bool _sending = false; Timer? _typingTimer; - final int _charLimit = 5000; - bool _isOverLimit = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onChanged); + } @override void dispose() { _typingTimer?.cancel(); - if (_isTyping) { - widget.onTypingChanged?.call(false); - } - _controller.dispose(); - _focusNode.dispose(); + widget.controller.removeListener(_onChanged); + if (_isTyping) widget.onTypingChanged?.call(false); super.dispose(); } - void _handleOnChange(String text) { - setState(() { - _isOverLimit = text.length > _charLimit; - }); + int get _bytes => utf8.encode(widget.controller.text.trim()).length; - final hasText = text.trim().isNotEmpty; + void _onChanged() { + setState(() {}); + if (widget.isEditing) return; + final hasText = widget.controller.text.trim().isNotEmpty; if (hasText && !_isTyping) { - setState(() { - _isTyping = true; - }); + _isTyping = true; widget.onTypingChanged?.call(true); } - _typingTimer?.cancel(); if (hasText) { - _typingTimer = Timer(const Duration(milliseconds: 2500), () { - if (mounted && _isTyping) { - setState(() { - _isTyping = false; - }); - widget.onTypingChanged?.call(false); - } - }); - } else if (_isTyping) { - setState(() { - _isTyping = false; - }); + _typingTimer = Timer(const Duration(milliseconds: 2500), _stopTyping); + } else { + _stopTyping(); + } + } + + void _stopTyping() { + if (_isTyping) { + _isTyping = false; widget.onTypingChanged?.call(false); } } - void _submit() { - _isOverLimit = false; - final text = _controller.text.trim(); - if (text.isNotEmpty) { - _typingTimer?.cancel(); - widget.onSendMessage(text); - _controller.clear(); - if (_isTyping) { - setState(() { - _isTyping = false; - }); - widget.onTypingChanged?.call(false); - } + Future _submit() async { + final text = widget.controller.text.trim(); + if (text.isEmpty || _sending || _bytes > widget.byteLimit) return; + setState(() => _sending = true); + _typingTimer?.cancel(); + _stopTyping(); + try { + await widget.onSend(text); + } finally { + if (mounted) setState(() => _sending = false); } } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final bottomPadding = MediaQuery.of(context).padding.bottom; + final glass = context.glass; + final bytes = _bytes; + final over = bytes > widget.byteLimit; + final nearLimit = bytes > widget.byteLimit * 0.8; + final canSend = + widget.controller.text.trim().isNotEmpty && !over && !_sending; - return ClipRRect( + return ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + filter: ImageFilter.blur(sigmaX: glass.blur, sigmaY: glass.blur), child: Container( decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - top: BorderSide( - color: theme.colorScheme.surface.withValues(alpha: 0.8), - width: 1, - ), - ), + color: glass.glassStrongFill, + border: Border(top: BorderSide(color: glass.glassBorder)), ), padding: EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 20, - bottom: 20 + bottomPadding, + top: Insets.sm, + bottom: Insets.sm + MediaQuery.paddingOf(context).bottom, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + mainAxisSize: MainAxisSize.min, 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, - ), - ), - ), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: widget.banner ?? const SizedBox(width: double.infinity), ), - 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, + padding: const EdgeInsets.symmetric(horizontal: Insets.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: widget.controller, + focusNode: widget.focusNode, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: 6, + style: TextStyle( + fontSize: 15.5, + color: over + ? context.colors.error + : context.colors.onSurface, + ), + decoration: InputDecoration( + hintText: widget.isEditing + ? 'Edit message' + : 'Message', + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: over + ? BorderSide(color: context.colors.error) + : BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: BorderSide( + color: over + ? context.colors.error + : context.colors.primary.withValues( + alpha: 0.6, + ), + ), + ), + suffixIcon: nearLimit + ? Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + widthFactor: 1, + child: Text( + '${widget.byteLimit - bytes}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: over + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + ), + ), + ) + : null, + ), + ), + ), + const SizedBox(width: Insets.sm), + AnimatedScale( + scale: canSend ? 1 : 0.88, + duration: const Duration(milliseconds: 180), + child: AnimatedOpacity( + opacity: canSend ? 1 : 0.45, + duration: const Duration(milliseconds: 180), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: Ink( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: glass.accentGradient, + ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: canSend ? _submit : null, + child: SizedBox( + width: 46, + height: 46, + child: _sending + ? Padding( + padding: const EdgeInsets.all(13), + child: CircularProgressIndicator( + strokeWidth: 2, + color: glass.onAccent, + ), + ) + : Icon( + widget.isEditing + ? Icons.check_rounded + : Icons.arrow_upward_rounded, + color: glass.onAccent, + ), + ), + ), + ), + ), + ), + ), + ], ), ), ], diff --git a/mobile/lib/widgets/home_page_widgets.dart b/mobile/lib/widgets/home_page_widgets.dart index abe66b9..8885ce4 100644 --- a/mobile/lib/widgets/home_page_widgets.dart +++ b/mobile/lib/widgets/home_page_widgets.dart @@ -1,50 +1,46 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth_state.dart'; import 'package:mobile/controllers/chat/group_details_controller.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/avatar.dart'; +import 'package:mobile/widgets/ui/components.dart'; import 'package:provider/provider.dart'; import '../models/inbox_item.dart'; -import '../pages/chat/chat_page.dart'; -class CustomChatCard extends StatelessWidget { +String formatInboxTime(DateTime time) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final day = DateTime(time.year, time.month, time.day); + final diff = today.difference(day).inDays; + + if (diff == 0) return DateFormat.jm().format(time); + if (diff == 1) return 'Yesterday'; + if (diff < 7) return DateFormat.E().format(time); + if (time.year == now.year) return DateFormat.MMMd().format(time); + return DateFormat.yMMMd().format(time); +} + +/// One row of the inbox: avatar, name, preview with delivery state, time and +/// unread badge. +class ConversationTile extends StatelessWidget { final InboxItem conversation; - final bool isSelected; - final bool isSelectionMode; - final GestureLongPressCallback? onLongPress; - final Function onTapInSelection; + final VoidCallback onTap; + final VoidCallback? onLongPress; - const CustomChatCard({ + const ConversationTile({ super.key, required this.conversation, - required this.isSelected, + required this.onTap, this.onLongPress, - required this.onTapInSelection, - required this.isSelectionMode, }); - String _formatTimestamp(DateTime time) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - final messageDate = DateTime(time.year, time.month, time.day); - - if (messageDate == today) { - return "${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}"; - } else if (messageDate == yesterday) { - return "Yesterday"; - } else { - return "${time.day}/${time.month}/${time.year}"; - } - } - @override Widget build(BuildContext context) { - final String timeLabel = _formatTimestamp(conversation.timestamp); - final bool hasUnread = conversation.unreadCount > 0; - - final groupDetailsState = context.watch(); + final hasUnread = conversation.unreadCount > 0; + final members = context.watch(); - if (conversation.isGroup && - !groupDetailsState.hasFetchedGroup(conversation.id)) { + if (conversation.isGroup && !members.hasFetchedGroup(conversation.id)) { Future.microtask(() { if (context.mounted) { context.read().preloadGroupMembers( @@ -54,203 +50,167 @@ class CustomChatCard extends StatelessWidget { }); } - final currentUserId = context.read().currentUser?.id; - - final String? safeSenderId = conversation.lastMessageSender - ?.trim() - .toLowerCase(); - final String? safeMyId = currentUserId?.trim().toLowerCase(); + final myId = context.read().currentUser?.id.toLowerCase(); + final sender = conversation.lastMessageSender?.trim().toLowerCase(); + final isMe = sender == 'me' || (myId != null && sender == myId); + final hasMessage = conversation.lastMessage.isNotEmpty; - final bool isMe = - safeSenderId == 'me' || (safeMyId != null && safeSenderId == safeMyId); - - String? senderName; - if (conversation.isGroup && conversation.lastMessageSender != null) { - if (isMe) { - senderName = "You"; - } else { - senderName = - groupDetailsState.userCache[conversation.lastMessageSender!] ?? - "Member"; - } + String? prefix; + if (hasMessage && + conversation.isGroup && + sender != null && + sender.isNotEmpty) { + prefix = isMe ? 'You' : members.nameFor(sender).split(' ').first; } - final String syncStatus = conversation.lastMessageSyncStatus ?? 'synced'; - final bool isRead = conversation.lastMessageIsRead ?? false; + final preview = hasMessage + ? conversation.lastMessage.replaceAll('\n', ' ') + : (conversation.isGroup + ? 'Group created. Say hello!' + : 'No messages yet'); - return Material( - color: isSelected - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - child: InkWell( - onLongPress: onLongPress, - onTap: () async { - if (isSelectionMode) { - onTapInSelection(); - } else { - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: conversation.id, - displayName: conversation.title, - isGroup: conversation.isGroup, - ), - ), - ); - } - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - SizedBox( - width: 52, - height: 52, - child: Stack( - fit: StackFit.expand, - children: [ - CircleAvatar( - radius: 26, - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - child: Icon( - conversation.isGroup ? Icons.group : Icons.person, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - Positioned( - right: -2, - bottom: -2, - child: AnimatedScale( - scale: isSelected ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutBack, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - size: 22, + final mutedColor = context.colors.onSurfaceVariant; + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Insets.page, + vertical: 11, + ), + child: Row( + children: [ + ElephantAvatar( + name: conversation.title, + seed: conversation.id, + isGroup: conversation.isGroup, + size: 54, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + conversation.title.isEmpty + ? (conversation.username ?? 'Unknown') + : conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.titleMedium?.copyWith( + fontWeight: hasUnread + ? FontWeight.w800 + : FontWeight.w700, ), ), ), - ), - ], - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - conversation.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, + const SizedBox(width: 8), + Text( + formatInboxTime(conversation.timestamp), + style: TextStyle( + fontSize: 12, + color: hasUnread + ? context.colors.primary + : mutedColor, + fontWeight: hasUnread + ? FontWeight.w700 + : FontWeight.w500, + ), ), - ), - const SizedBox(height: 4), - Row( - children: [ - if (isMe) ...[ - Icon( - syncStatus == 'pending' - ? Icons.access_time - : (isRead ? Icons.done_all : Icons.done), - size: 16, - color: syncStatus == 'pending' - ? Theme.of(context).colorScheme.onSurfaceVariant - .withValues(alpha: 0.5) - : isRead - ? Colors.lightBlueAccent - : Theme.of(context).colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - ], - Expanded( - child: Text( - senderName != null - ? "*$senderName:* ${conversation.lastMessage.replaceAll('\n', ' ')}" - : conversation.lastMessage.replaceAll( - '\n', - ' ', + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (isMe && hasMessage && !conversation.isGroup) ...[ + DeliveryTick( + isPending: + conversation.lastMessageSyncStatus == 'pending', + isRead: conversation.lastMessageIsRead ?? false, + color: mutedColor, + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text.rich( + TextSpan( + children: [ + if (prefix != null) + TextSpan( + text: '$prefix: ', + style: TextStyle( + fontWeight: FontWeight.w700, + color: hasUnread + ? context.colors.onSurface + : mutedColor, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: hasUnread - ? Theme.of(context).colorScheme.onSurface - : Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 15, - ), + ), + TextSpan(text: preview), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5, + color: hasUnread + ? context.colors.onSurface + : mutedColor, + fontStyle: hasMessage + ? FontStyle.normal + : FontStyle.italic, ), ), + ), + if (hasUnread) ...[ + const SizedBox(width: 8), + UnreadBadge(conversation.unreadCount), ], - ), - ], - ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - timeLabel, - style: TextStyle( - color: hasUnread - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, - fontWeight: hasUnread - ? FontWeight.w600 - : FontWeight.normal, - ), + ], ), - const SizedBox(height: 6), - if (hasUnread) - Container( - constraints: const BoxConstraints( - minWidth: 20, - minHeight: 20, - ), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - alignment: Alignment.center, - child: Text( - conversation.unreadCount > 99 - ? "99+" - : "${conversation.unreadCount}", - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), ], ), - ], - ), + ), + ], ), ), ); } } + +/// Clock while queued, one tick when sent, two coloured ticks when read. +class DeliveryTick extends StatelessWidget { + final bool isPending; + final bool isRead; + final Color color; + final Color? readColor; + final double size; + + const DeliveryTick({ + super.key, + required this.isPending, + required this.isRead, + required this.color, + this.readColor, + this.size = 16, + }); + + @override + Widget build(BuildContext context) { + if (isPending) { + return Icon( + Icons.schedule_rounded, + size: size - 2, + color: color.withValues(alpha: 0.7), + ); + } + return Icon( + isRead ? Icons.done_all_rounded : Icons.done_rounded, + size: size, + color: isRead ? (readColor ?? context.glass.readTick) : color, + ); + } +} diff --git a/mobile/lib/widgets/ui/avatar.dart b/mobile/lib/widgets/ui/avatar.dart new file mode 100644 index 0000000..0152a8b --- /dev/null +++ b/mobile/lib/widgets/ui/avatar.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; + +/// Monogram avatar with a gradient derived from the user or group ID, so the +/// same person always gets the same colours. People are circles; groups are +/// rounded squares, so the two are distinguishable at a glance. +class ElephantAvatar extends StatelessWidget { + final String name; + final String seed; + final double size; + final bool isGroup; + final bool? isOnline; + final bool showSelected; + + const ElephantAvatar({ + super.key, + required this.name, + required this.seed, + this.size = 48, + this.isGroup = false, + this.isOnline, + this.showSelected = false, + }); + + static const List> _gradients = [ + [Color(0xFF6A8DFF), Color(0xFF8E6BFF)], + [Color(0xFF2EC4B6), Color(0xFF3A86FF)], + [Color(0xFFFF7A59), Color(0xFFFFB443)], + [Color(0xFFEC4899), Color(0xFF8B5CF6)], + [Color(0xFF10B981), Color(0xFF84CC16)], + [Color(0xFFF43F5E), Color(0xFFFB923C)], + [Color(0xFF0EA5E9), Color(0xFF22D3EE)], + [Color(0xFFA855F7), Color(0xFFF472B6)], + ]; + + static List gradientFor(String seed) { + int hash = 0; + for (final unit in seed.codeUnits) { + hash = (hash * 31 + unit) & 0x7fffffff; + } + return _gradients[hash % _gradients.length]; + } + + /// Accent colour for a person, e.g. their name in group chats. + static Color colorFor(String seed) => gradientFor(seed).first; + + static String initialsOf(String name) { + final words = name + .trim() + .split(RegExp(r'[\s._-]+')) + .where((w) => w.isNotEmpty) + .toList(); + if (words.isEmpty) return '?'; + if (words.length == 1) { + return words.first.characters.take(2).toString().toUpperCase(); + } + return (words[0].characters.first + words[1].characters.first) + .toUpperCase(); + } + + @override + Widget build(BuildContext context) { + final colors = gradientFor(seed.isEmpty ? name : seed); + final radius = isGroup + ? BorderRadius.circular(size * 0.32) + : BorderRadius.circular(size / 2); + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: radius, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors, + ), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.25), + blurRadius: size * 0.25, + offset: Offset(0, size * 0.08), + ), + ], + ), + alignment: Alignment.center, + child: isGroup && name.trim().isEmpty + ? Icon( + Icons.groups_rounded, + color: Colors.white, + size: size * 0.5, + ) + : Text( + initialsOf(name), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: size * 0.36, + letterSpacing: 0.5, + ), + ), + ), + if (isOnline != null) + Positioned( + right: isGroup ? -2 : 0, + bottom: isGroup ? -2 : 0, + child: AnimatedScale( + scale: isOnline! ? 1 : 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + child: Container( + width: size * 0.28, + height: size * 0.28, + decoration: BoxDecoration( + color: context.glass.success, + shape: BoxShape.circle, + border: Border.all( + color: context.theme.scaffoldBackgroundColor, + width: size * 0.05, + ), + ), + ), + ), + ), + Positioned( + right: -3, + bottom: -3, + child: AnimatedScale( + scale: showSelected ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: context.theme.scaffoldBackgroundColor, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_rounded, + size: size * 0.36, + color: context.colors.primary, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/ui/components.dart b/mobile/lib/widgets/ui/components.dart new file mode 100644 index 0000000..cab56e7 --- /dev/null +++ b/mobile/lib/widgets/ui/components.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/glass.dart'; + +/// Small all-caps label above a group of rows. +class SectionLabel extends StatelessWidget { + final String text; + final Widget? trailing; + + const SectionLabel(this.text, {super.key, this.trailing}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + Insets.page + 4, + Insets.xl, + Insets.page, + Insets.sm, + ), + child: Row( + children: [ + Expanded( + child: Text(text.toUpperCase(), style: context.text.labelSmall), + ), + ?trailing, + ], + ), + ); + } +} + +/// A frosted card holding a column of rows separated by hairlines. +class GlassSection extends StatelessWidget { + final List children; + final EdgeInsetsGeometry margin; + + const GlassSection({ + super.key, + required this.children, + this.margin = const EdgeInsets.symmetric(horizontal: Insets.page), + }); + + @override + Widget build(BuildContext context) { + final rows = []; + for (int i = 0; i < children.length; i++) { + rows.add(children[i]); + if (i < children.length - 1) { + rows.add( + Divider( + indent: 64, + endIndent: 16, + color: context.colors.outline.withValues(alpha: 0.6), + ), + ); + } + } + return Padding( + padding: margin, + child: GlassSurface( + child: Material( + type: MaterialType.transparency, + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ), + ), + ); + } +} + +/// Settings-style row: tinted icon chip, title, optional subtitle and trailing. +class ElephantTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool destructive; + final Color? iconColor; + final bool showChevron; + + const ElephantTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.destructive = false, + this.iconColor, + this.showChevron = true, + }); + + @override + Widget build(BuildContext context) { + final color = destructive + ? context.colors.error + : (iconColor ?? context.colors.primary); + final chevron = showChevron && onTap != null && trailing == null; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Insets.lg, + vertical: 13, + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: color.withValues( + alpha: context.theme.brightness == Brightness.dark + ? 0.18 + : 0.12, + ), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Icon(icon, size: 19, color: color), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.text.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: destructive ? context.colors.error : null, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: context.text.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + if (chevron) + Icon( + Icons.chevron_right_rounded, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +/// Centered illustration-style placeholder for empty lists. +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.message, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 84, + height: 84, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: context.glass.accentGradient, + boxShadow: [ + BoxShadow( + color: context.colors.primary.withValues(alpha: 0.3), + blurRadius: 30, + offset: const Offset(0, 12), + ), + ], + ), + child: Icon(icon, size: 38, color: context.glass.onAccent), + ), + const SizedBox(height: Insets.xl), + Text( + title, + style: context.text.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: Insets.sm), + Text( + message, + textAlign: TextAlign.center, + style: context.text.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1.45, + ), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: Insets.xl), + SizedBox( + width: 220, + child: GradientButton(label: actionLabel!, onPressed: onAction), + ), + ], + ], + ), + ), + ); + } +} + +/// Compact rounded label, e.g. "Admin" or "Verified". +class TagPill extends StatelessWidget { + final String label; + final Color? color; + final IconData? icon; + + const TagPill(this.label, {super.key, this.color, this.icon}); + + @override + Widget build(BuildContext context) { + final c = color ?? context.colors.primary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(99), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: c), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: c, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// Unread counter in the accent gradient. +class UnreadBadge extends StatelessWidget { + final int count; + + const UnreadBadge(this.count, {super.key}); + + @override + Widget build(BuildContext context) { + return AccentGradientBox( + borderRadius: BorderRadius.circular(99), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 10), + child: Text( + count > 99 ? '99+' : '$count', + textAlign: TextAlign.center, + style: TextStyle( + color: context.glass.onAccent, + fontSize: 11.5, + fontWeight: FontWeight.w800, + ), + ), + ), + ); + } +} + +/// Search field styled for glass headers. +class GlassSearchField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final ValueChanged? onChanged; + final bool autofocus; + final FocusNode? focusNode; + + const GlassSearchField({ + super.key, + required this.controller, + required this.hint, + this.onChanged, + this.autofocus = false, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + return TextField( + controller: controller, + focusNode: focusNode, + autofocus: autofocus, + onChanged: onChanged, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: hint, + prefixIcon: const Icon(Icons.search_rounded), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.xl), + borderSide: BorderSide(color: context.colors.primary, width: 1.4), + ), + suffixIcon: value.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () { + controller.clear(); + onChanged?.call(''); + }, + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/ui/feedback.dart b/mobile/lib/widgets/ui/feedback.dart new file mode 100644 index 0000000..7448fdb --- /dev/null +++ b/mobile/lib/widgets/ui/feedback.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/widgets/ui/glass.dart'; + +void showSnack(BuildContext context, String message, {bool isError = false}) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Row( + children: [ + if (isError) ...[ + Icon( + Icons.error_outline_rounded, + color: context.colors.error, + size: 20, + ), + const SizedBox(width: 10), + ], + Expanded(child: Text(message)), + ], + ), + ), + ); +} + +/// Frosted bottom sheet with a grab handle and optional title. +Future showGlassSheet( + BuildContext context, { + required WidgetBuilder builder, + String? title, + bool isScrollControlled = true, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + useSafeArea: true, + builder: (sheetContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: GlassSurface( + strong: true, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.xl), + ), + child: SafeArea( + top: false, + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 10, bottom: 6), + width: 40, + height: 4.5, + decoration: BoxDecoration( + color: sheetContext.colors.onSurfaceVariant.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(99), + ), + ), + ), + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB( + Insets.xl, + Insets.sm, + Insets.xl, + Insets.sm, + ), + child: Text(title, style: sheetContext.text.titleLarge), + ), + Flexible(child: builder(sheetContext)), + ], + ), + ), + ), + ), + ); + }, + ); +} + +/// One entry of [showActionSheet]. +class SheetAction { + final IconData icon; + final String label; + final T value; + final bool destructive; + + const SheetAction({ + required this.icon, + required this.label, + required this.value, + this.destructive = false, + }); +} + +Future showActionSheet( + BuildContext context, { + required List> actions, + Widget? header, +}) { + return showGlassSheet( + context, + builder: (sheetContext) => Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?header, + for (final action in actions) + ListTile( + leading: Icon( + action.icon, + color: action.destructive + ? sheetContext.colors.error + : sheetContext.colors.onSurface, + ), + title: Text( + action.label, + style: TextStyle( + color: action.destructive ? sheetContext.colors.error : null, + fontWeight: FontWeight.w600, + ), + ), + onTap: () => Navigator.pop(sheetContext, action.value), + ), + ], + ), + ), + ); +} + +Future confirmAction( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + bool destructive = false, +}) async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + style: destructive + ? FilledButton.styleFrom( + backgroundColor: dialogContext.colors.error, + foregroundColor: dialogContext.colors.onError, + minimumSize: const Size(88, 44), + ) + : FilledButton.styleFrom(minimumSize: const Size(88, 44)), + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(confirmLabel), + ), + ], + ), + ); + return result == true; +} + +/// Asks for a single line of text. Returns null when cancelled. +Future promptText( + BuildContext context, { + required String title, + String? message, + String initialValue = '', + String hint = '', + String confirmLabel = 'Save', + bool obscure = false, + int? maxLength, +}) { + final controller = TextEditingController(text: initialValue); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (message != null) ...[ + Text(message), + const SizedBox(height: Insets.lg), + ], + TextField( + controller: controller, + autofocus: true, + obscureText: obscure, + maxLength: maxLength, + decoration: InputDecoration(hintText: hint), + onSubmitted: (value) => Navigator.pop(dialogContext, value.trim()), + ), + ], + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(minimumSize: const Size(88, 44)), + onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), + child: Text(confirmLabel), + ), + ], + ), + ); +} diff --git a/mobile/lib/widgets/ui/glass.dart b/mobile/lib/widgets/ui/glass.dart new file mode 100644 index 0000000..5b3023b --- /dev/null +++ b/mobile/lib/widgets/ui/glass.dart @@ -0,0 +1,342 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:mobile/themes/app_themes.dart'; + +/// Frosted panel: blurred backdrop, translucent fill, hairline highlight. +/// The base building block of the Elephant look. +class GlassSurface extends StatelessWidget { + final Widget child; + final BorderRadius borderRadius; + final EdgeInsetsGeometry? padding; + final bool strong; + final bool showBorder; + final Color? tint; + final List? shadows; + + const GlassSurface({ + super.key, + required this.child, + this.borderRadius = const BorderRadius.all(Radius.circular(Radii.lg)), + this.padding, + this.strong = false, + this.showBorder = true, + this.tint, + this.shadows, + }); + + @override + Widget build(BuildContext context) { + final glass = context.glass; + return DecoratedBox( + decoration: BoxDecoration(borderRadius: borderRadius, boxShadow: shadows), + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: glass.blur, sigmaY: glass.blur), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: tint ?? (strong ? glass.glassStrongFill : glass.glassFill), + borderRadius: borderRadius, + border: showBorder + ? Border.all(color: glass.glassBorder, width: 1) + : null, + ), + child: child, + ), + ), + ), + ); + } +} + +/// Soft, blurred colour fields behind glass. Static by default so long +/// lists stay cheap; [animate] slowly drifts them (used on the welcome screen). +class AmbientBackground extends StatefulWidget { + final Widget child; + final bool animate; + final double intensity; + + const AmbientBackground({ + super.key, + required this.child, + this.animate = false, + this.intensity = 1, + }); + + @override + State createState() => _AmbientBackgroundState(); +} + +class _AmbientBackgroundState extends State + with SingleTickerProviderStateMixin { + AnimationController? _controller; + + @override + void initState() { + super.initState(); + if (widget.animate) { + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 14), + )..repeat(reverse: true); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = context.glass.ambient; + final isDark = context.theme.brightness == Brightness.dark; + final strength = (isDark ? 0.42 : 0.5) * widget.intensity; + + Widget blobs(double t) { + return LayoutBuilder( + builder: (context, box) { + final w = box.maxWidth; + final h = box.maxHeight; + return Stack( + children: [ + _blob( + colors[0], + strength, + w * 1.1, + Offset(-w * 0.35 + t * w * 0.25, -h * 0.12 + t * 40), + ), + _blob( + colors[1], + strength * 0.8, + w * 0.95, + Offset(w * 0.45 - t * w * 0.2, h * 0.35 + t * 60), + ), + _blob( + colors[2], + strength * 0.7, + w * 0.9, + Offset(-w * 0.2 + t * w * 0.3, h * 0.75 - t * 50), + ), + ], + ); + }, + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: context.theme.scaffoldBackgroundColor), + RepaintBoundary( + child: _controller == null + ? blobs(0) + : AnimatedBuilder( + animation: _controller!, + builder: (_, _) => + blobs(Curves.easeInOutSine.transform(_controller!.value)), + ), + ), + widget.child, + ], + ); + } + + Widget _blob(Color color, double alpha, double size, Offset offset) { + return Positioned( + left: offset.dx, + top: offset.dy, + child: IgnorePointer( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + color.withValues(alpha: alpha), + color.withValues(alpha: 0), + ], + ), + ), + ), + ), + ); + } +} + +/// Blurred, translucent app bar used by every pushed screen. +class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { + final Widget? title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double height; + final double? titleSpacing; + + const GlassAppBar({ + super.key, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.height = kToolbarHeight, + this.titleSpacing, + }); + + @override + Size get preferredSize => Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + final glass = context.glass; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: glass.blur, sigmaY: glass.blur), + child: DecoratedBox( + decoration: BoxDecoration( + color: glass.glassStrongFill, + border: Border(bottom: BorderSide(color: glass.glassBorder)), + ), + child: AppBar( + title: title, + actions: actions, + leading: leading, + titleSpacing: titleSpacing, + toolbarHeight: height, + automaticallyImplyLeading: automaticallyImplyLeading, + backgroundColor: Colors.transparent, + ), + ), + ), + ); + } +} + +/// Backdrop for a [SliverAppBar.flexibleSpace] that frosts what scrolls under it. +class GlassHeaderBackground extends StatelessWidget { + const GlassHeaderBackground({super.key}); + + @override + Widget build(BuildContext context) { + final glass = context.glass; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: glass.blur, sigmaY: glass.blur), + child: Container(color: glass.glassFill), + ), + ); + } +} + +/// Anything painted with the theme's accent gradient (buttons, badges, icons). +class AccentGradientBox extends StatelessWidget { + final Widget child; + final BorderRadius borderRadius; + final EdgeInsetsGeometry? padding; + final BoxShape shape; + + const AccentGradientBox({ + super.key, + required this.child, + this.borderRadius = const BorderRadius.all(Radius.circular(Radii.md)), + this.padding, + this.shape = BoxShape.rectangle, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + gradient: context.glass.accentGradient, + borderRadius: shape == BoxShape.circle ? null : borderRadius, + shape: shape, + ), + child: child, + ); + } +} + +/// Primary call-to-action: full-width gradient pill with a loading state. +class GradientButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final bool isLoading; + final IconData? icon; + + const GradientButton({ + super.key, + required this.label, + required this.onPressed, + this.isLoading = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null && !isLoading; + final onAccent = context.glass.onAccent; + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: enabled || isLoading ? 1 : 0.5, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: context.glass.accentGradient, + borderRadius: BorderRadius.circular(Radii.md), + boxShadow: [ + BoxShadow( + color: context.colors.primary.withValues( + alpha: enabled ? 0.32 : 0, + ), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.md), + onTap: enabled ? onPressed : null, + child: SizedBox( + height: 52, + child: Center( + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: onAccent, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: onAccent, size: 20), + const SizedBox(width: 8), + ], + Text( + label, + style: TextStyle( + color: onAccent, + fontWeight: FontWeight.w700, + fontSize: 15.5, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4e566b6..031a465 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,5 +1,5 @@ name: mobile -description: "A new Flutter project." +description: "Elephant: a privacy-first, end-to-end encrypted messenger." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: "none" # Remove this line if you wish to publish to pub.dev @@ -87,6 +87,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/animations/ + - assets/launcher/elephant.png # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/mobile/test/frame_budget_test.dart b/mobile/test/frame_budget_test.dart new file mode 100644 index 0000000..e130e31 --- /dev/null +++ b/mobile/test/frame_budget_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; +import 'package:mobile/core/constants.dart'; +import 'package:mobile/core/message_envelope.dart'; +import 'package:mobile/services/ws_service.dart'; + +/// The server closes sockets on frames over 4096 bytes. Check that the +/// plaintext budget for direct messages survives Signal encryption, base64 +/// and double JSON encoding, using the largest (PreKey) message type. +void main() { + test('max-size direct message fits in one WebSocket frame', () async { + final aliceStore = InMemorySignalProtocolStore( + generateIdentityKeyPair(), + generateRegistrationId(false), + ); + final bobIdentity = generateIdentityKeyPair(); + final bobStore = InMemorySignalProtocolStore( + bobIdentity, + generateRegistrationId(false), + ); + + final bobPreKey = generatePreKeys(0, 1).first; + final bobSigned = generateSignedPreKey(bobIdentity, 0); + await bobStore.storePreKey(bobPreKey.id, bobPreKey); + await bobStore.storeSignedPreKey(bobSigned.id, bobSigned); + + final bobAddress = SignalProtocolAddress( + 'bob-0000-0000-0000-000000000000', + 1, + ); + await SessionBuilder.fromSignalStore( + aliceStore, + bobAddress, + ).processPreKeyBundle( + PreKeyBundle( + await bobStore.getLocalRegistrationId(), + 1, + bobPreKey.id, + bobPreKey.getKeyPair().publicKey, + bobSigned.id, + bobSigned.getKeyPair().publicKey, + bobSigned.signature, + bobIdentity.getPublicKey(), + ), + ); + + // Worst realistic case: multi-byte characters filling the byte budget. + final plaintext = '🐘' * (ServerLimits.maxDirectMessageBytes ~/ 4); + expect( + utf8.encode(plaintext).length, + lessThanOrEqualTo(ServerLimits.maxDirectMessageBytes), + ); + + final cipher = SessionCipher.fromStore(aliceStore, bobAddress); + final encrypted = await cipher.encrypt( + Uint8List.fromList(utf8.encode(plaintext)), + ); + expect(encrypted.getType(), CiphertextMessage.prekeyType); + + final frame = WebSocketService.chatFrame( + messageId: '00000000-0000-0000-0000-000000000000', + receiverId: '11111111-1111-1111-1111-111111111111', + content: MessageEnvelope( + encrypted.getType(), + base64Encode(encrypted.serialize()), + ).encode(), + replyToMessageId: '22222222-2222-2222-2222-222222222222', + ); + + final size = WebSocketService.frameSize(frame); + // ignore: avoid_print + print('Largest direct-message frame: $size bytes'); + expect(size, lessThanOrEqualTo(ServerLimits.maxWsFrameBytes)); + }); +} diff --git a/mobile/test/protocol_test.dart b/mobile/test/protocol_test.dart new file mode 100644 index 0000000..56ab847 --- /dev/null +++ b/mobile/test/protocol_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/controllers/auth_state.dart'; +import 'package:mobile/core/message_envelope.dart'; +import 'package:mobile/models/inbox_item.dart'; +import 'package:mobile/models/message.dart'; +import 'package:mobile/services/ws_service.dart'; + +void main() { + group('MessageEnvelope', () { + test('round-trips plaintext group content', () { + final wire = MessageEnvelope.plain('hello "group"\nline 2'); + final parsed = MessageEnvelope.tryParse(wire)!; + expect(parsed.isPlaintext, isTrue); + expect(parsed.body, 'hello "group"\nline 2'); + expect(MessageEnvelope.isUnreadable(wire), isFalse); + expect(MessageEnvelope.preview(wire), 'hello "group"\nline 2'); + }); + + test('treats Signal envelopes and placeholders as unreadable', () { + const wire = '{"type":3,"ciphertext":"AAEC"}'; + expect(MessageEnvelope.tryParse(wire)!.type, 3); + expect(MessageEnvelope.isUnreadable(wire), isTrue); + expect(MessageEnvelope.preview(wire, fallback: 'locked'), 'locked'); + expect( + MessageEnvelope.isUnreadable(MessageEnvelope.locked('nope')), + isTrue, + ); + }); + + test('leaves ordinary text and malformed JSON alone', () { + expect(MessageEnvelope.tryParse('just text'), isNull); + expect(MessageEnvelope.tryParse('{"ciphertext": 5}'), isNull); + expect(MessageEnvelope.tryParse('{broken ciphertext'), isNull); + expect(MessageEnvelope.isUnreadable('{"note":"no envelope"}'), isFalse); + }); + }); + + group('AuthState validation mirrors the server', () { + test('username', () { + expect(AuthState.validateUsername('ab', isRegister: true), isNotNull); + expect(AuthState.validateUsername('a' * 26, isRegister: true), isNotNull); + expect( + AuthState.validateUsername('has.dot', isRegister: true), + isNotNull, + ); + expect(AuthState.validateUsername('Alex42', isRegister: true), isNull); + expect( + AuthState.validateUsername('alex.4821', isRegister: false), + isNull, + ); + expect(AuthState.validateUsername('', isRegister: false), isNotNull); + }); + + test('password', () { + expect( + AuthState.validatePassword('1234567', isRegister: true), + isNotNull, + ); + expect(AuthState.validatePassword('12345678', isRegister: true), isNull); + expect(AuthState.validatePassword('short', isRegister: false), isNull); + }); + }); + + group('Message', () { + test('parses server history rows with edits and quotes', () { + final msg = Message.fromJson({ + 'id': 'c1', + 'sender_id': 'u1', + 'receiver_id': 'u2', + 'content': 'hi', + 'created_at': '2026-10-04T10:00:00Z', + 'is_read': true, + 'edited_at': '2026-10-04T10:05:00Z', + 'reply_to_message_id': 'c0', + 'quoted_message': {'id': 'c0', 'sender_id': 'u2', 'content': 'yo'}, + }); + expect(msg.isEdited, isTrue); + expect(msg.replyToMessageId, 'c0'); + expect(msg.quotedMessage!.content, 'yo'); + expect(msg.isFrom('U1'), isTrue); + }); + + test('treats empty reply ids from WS events as absent', () { + final msg = Message.fromJson({ + 'message_id': 'm1', + 'sender_id': 'u1', + 'group_id': '', + 'content': 'x', + 'reply_to_message_id': '', + 'timestamp': '2026-10-04T10:00:00Z', + }); + expect(msg.id, 'm1'); + expect(msg.replyToMessageId, isNull); + }); + + test('survives a round trip through the local table', () { + final original = Message( + id: 'm1', + senderId: 'me', + receiverId: 'chat', + content: 'hello', + createdAt: DateTime.fromMillisecondsSinceEpoch(1000), + isRead: true, + editedAt: DateTime.fromMillisecondsSinceEpoch(2000), + ); + final restored = Message.fromRow(original.toRow('chat')); + expect(restored.content, 'hello'); + expect(restored.editedAt, original.editedAt); + expect(restored.isFrom(null), isTrue); + }); + + test('can only be edited by its author within 15 minutes', () { + Message at( + Duration age, { + String sender = 'me', + String status = 'synced', + }) => Message( + id: 'm', + senderId: sender, + receiverId: 'r', + content: 'c', + createdAt: DateTime.now().subtract(age), + isRead: false, + syncStatus: status, + ); + expect(at(const Duration(minutes: 5)).canEdit('u1'), isTrue); + expect(at(const Duration(minutes: 16)).canEdit('u1'), isFalse); + expect( + at(const Duration(minutes: 1), sender: 'u2').canEdit('u1'), + isFalse, + ); + expect( + at(const Duration(minutes: 1), status: 'pending').canEdit('u1'), + isFalse, + ); + }); + }); + + test('InboxItem maps both conversation types', () { + final direct = InboxItem.fromConversationJson({ + 'id': 'u2', + 'type': 'direct', + 'name': 'bob.1234', + 'display_name': 'Bob', + 'last_message_time': '2026-10-04T10:00:00Z', + 'sender_id': 'u1', + 'is_read': true, + 'unread_count': 2, + }, lastMessage: 'hey'); + expect(direct.title, 'Bob'); + expect(direct.username, 'bob.1234'); + expect(direct.unreadCount, 2); + expect(direct.lastMessageSender, 'u1'); + + final group = InboxItem.fromConversationJson({ + 'id': 'g1', + 'type': 'group', + 'name': 'Team', + 'display_name': '', + 'unread_count': 5, + }, lastMessage: 'hi all'); + expect(group.isGroup, isTrue); + expect(group.title, 'Team'); + expect(group.unreadCount, 5); + }); + + test('chat frames drop null fields and report their size', () { + final frame = WebSocketService.chatFrame( + messageId: 'm1', + groupId: 'g1', + content: MessageEnvelope.plain('hi'), + ); + final size = WebSocketService.frameSize(frame); + expect(frame.containsKey('receiver_id'), isFalse); + expect(frame.containsKey('reply_to_message_id'), isFalse); + expect(size, lessThan(200)); + }); +} diff --git a/mobile/test/ui_smoke_test.dart b/mobile/test/ui_smoke_test.dart new file mode 100644 index 0000000..d3702df --- /dev/null +++ b/mobile/test/ui_smoke_test.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/controllers/auth_state.dart'; +import 'package:mobile/controllers/chat/active_chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_connection_controller.dart'; +import 'package:mobile/controllers/chat/chat_search_controller.dart'; +import 'package:mobile/controllers/chat/group_details_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; +import 'package:mobile/core/message_envelope.dart'; +import 'package:mobile/models/inbox_item.dart'; +import 'package:mobile/models/message.dart'; +import 'package:mobile/pages/auth/login_page.dart'; +import 'package:mobile/pages/chat/chat_page.dart'; +import 'package:mobile/pages/home/home_page.dart'; +import 'package:mobile/pages/chat/chat_details_page.dart'; +import 'package:mobile/pages/new%20chat/new_chat_page.dart'; +import 'package:mobile/pages/new%20chat/select_contact_page.dart'; +import 'package:mobile/pages/settings/accounts.dart'; +import 'package:mobile/pages/settings/appearance.dart'; +import 'package:mobile/pages/settings/help_about.dart'; +import 'package:mobile/pages/settings/privacy_security.dart'; +import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:mobile/services/chat/chat_event_handler.dart'; +import 'package:mobile/themes/app_themes.dart'; +import 'package:mobile/themes/theme_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Renders the main screens in every theme at a regular and a small phone +/// size. Any exception, including RenderFlex overflows, fails the test. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + SharedPreferences.setMockInitialValues({}); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final name in [ + 'com.llfbandit.app_links/messages', + 'com.llfbandit.app_links/events', + 'dev.fluttercommunity.plus/connectivity', + 'dev.fluttercommunity.plus/connectivity_status', + 'plugins.it_nomads.com/flutter_secure_storage', + ]) { + messenger.setMockMethodCallHandler(MethodChannel(name), (call) async { + if (call.method == 'check') return 'wifi'; + return null; + }); + } + }); + + const sizes = {'regular': Size(390, 844), 'small': Size(320, 600)}; + + Future render( + WidgetTester tester, + ThemeType theme, + Size size, + Widget page, + ) async { + tester.view.devicePixelRatio = 3; + tester.view.physicalSize = size * 3; + addTearDown(tester.view.reset); + + final auth = AuthState(); + final inbox = InboxController() + ..hasLoadedOnce = true + ..inbox = [ + InboxItem( + id: 'u2', + title: 'Bob Builder With A Rather Long Display Name', + username: 'bob.1234', + lastMessage: + 'Can we fix it? Yes we can, and this preview is long enough to need truncating.', + timestamp: DateTime.now(), + isGroup: false, + unreadCount: 3, + lastMessageSender: 'u2', + ), + InboxItem( + id: 'u3', + title: 'Ada', + username: 'ada.1815', + lastMessage: 'See you!', + timestamp: DateTime.now().subtract(const Duration(days: 3)), + isGroup: false, + lastMessageSender: 'me', + lastMessageIsRead: true, + ), + ]; + final chat = ActiveChatController(); + final themeProvider = ThemeProvider(); + await themeProvider.setTheme(theme); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: themeProvider), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: inbox), + ChangeNotifierProvider.value(value: chat), + ChangeNotifierProvider(create: (_) => GroupController()), + ChangeNotifierProvider(create: (_) => ChatSearchController()), + ChangeNotifierProvider(create: (_) => GroupDetailsController()), + ChangeNotifierProvider( + create: (_) => ChatConnectionController( + eventHandler: ChatEventHandler( + inboxController: inbox, + activeChatController: chat, + currentUserIdProvider: () => 'me-id', + ), + ), + ), + ], + child: MaterialApp(theme: AppThemes.getTheme(theme), home: page), + ), + ); + await tester.pump(const Duration(seconds: 2)); + } + + for (final theme in ThemeType.values) { + for (final entry in sizes.entries) { + final label = '${theme.name}/${entry.key}'; + + testWidgets('login renders: $label', (tester) async { + await render(tester, theme, entry.value, const LoginPage()); + await tester.tap(find.text('Register')); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Create account'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('home renders: $label', (tester) async { + await render(tester, theme, entry.value, const HomePage()); + expect(find.text('Chats'), findsWidgets); + await tester.tap(find.text('Groups').last); + await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.text('You').last); + await tester.pump(const Duration(milliseconds: 600)); + expect(tester.takeException(), isNull); + }); + + testWidgets('chat renders: $label', (tester) async { + final now = DateTime.now(); + Message msg( + String id, + String sender, + String text, { + Duration ago = Duration.zero, + String? reply, + DateTime? edited, + }) { + return Message( + id: id, + senderId: sender, + receiverId: 'u2', + content: text, + createdAt: now.subtract(ago), + isRead: sender == 'me', + replyToMessageId: reply, + editedAt: edited, + quotedMessage: reply == null + ? null + : QuotedMessage( + id: reply, + senderId: 'u2', + senderDisplayName: '', + content: 'Original **message**', + ), + ); + } + + final page = ChatPage( + chatUserId: 'u2', + displayName: 'Bob Builder', + username: 'bob.1234', + ); + await render(tester, theme, entry.value, page); + final chat = Provider.of( + tester.element(find.byType(ChatPage)), + listen: false, + ); + chat + ..currentChatUserId = 'u2' + ..activeChat = [ + msg('1', 'u2', 'Hello there πŸ‘‹', ago: const Duration(days: 2)), + msg( + '2', + 'me', + 'Hi! *How* are you? `code` and a [link](https://x.y)', + ago: const Duration(days: 1), + ), + msg( + '3', + 'u2', + MessageEnvelope.locked('Encrypted for a previous session'), + ago: const Duration(minutes: 30), + ), + msg( + '4', + 'me', + 'A reply ' * 40, + ago: const Duration(minutes: 3), + reply: '1', + edited: now, + ), + msg('5', 'me', 'quick follow-up', ago: const Duration(minutes: 2)), + ] + ..isPeerTyping = true + ..isPeerOnline = true + ..refreshUI(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('typing…'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('secondary pages render: $label', (tester) async { + for (final page in const [ + AccountsSettings(), + NewChatPage(), + SelectContactPage(), + ChatDetailsPage( + isGroup: false, + chatName: 'Bob Builder', + chatId: 'u2', + username: 'bob.1234', + ), + ChatDetailsPage( + isGroup: true, + chatName: 'Weekend hiking crew', + chatId: 'g1', + ), + ]) { + await render(tester, theme, entry.value, page); + expect( + tester.takeException(), + isNull, + reason: page.runtimeType.toString(), + ); + } + }); + + testWidgets('server sheet renders: $label', (tester) async { + await render(tester, theme, entry.value, const LoginPage()); + await tester.ensureVisible(find.text('Elephant Cloud')); + await tester.tap(find.text('Elephant Cloud')); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Use Elephant Cloud'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('settings pages render: $label', (tester) async { + for (final page in const [ + AppearanceSettings(), + PrivacySecuritySettingsPage(), + HelpAboutPage(), + ]) { + await render(tester, theme, entry.value, page); + expect(tester.takeException(), isNull); + } + }); + } + } +}