Merge remote-tracking branch 'origin/feature/FullStack' into fix/tab-switch-freeze

# Conflicts:
#	lib/frontend/screens/chats/chat_list_screen.dart
#	lib/frontend/screens/chats/chat_screen.dart
#	lib/frontend/widgets/message_bubble.dart
This commit is contained in:
klockky
2026-05-13 19:12:21 +03:00
34 changed files with 2177 additions and 238 deletions
@@ -34,13 +34,14 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
bool _keyboardScheduled = false;
Animation<double>? _routeAnimation;
AnimationStatusListener? _routeAnimationListener;
@override
void initState() {
super.initState();
_startTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
_shakeController = AnimationController(
vsync: this,
@@ -56,8 +57,17 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleKeyboardOpen();
}
@override
void dispose() {
if (_routeAnimationListener != null) {
_routeAnimation?.removeStatusListener(_routeAnimationListener!);
}
_timer?.cancel();
_errorTimer?.cancel();
_shakeController.dispose();
@@ -66,6 +76,36 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
super.dispose();
}
void _scheduleKeyboardOpen() {
if (_keyboardScheduled) return;
_keyboardScheduled = true;
final animation = ModalRoute.of(context)?.animation;
if (animation == null || animation.status == AnimationStatus.completed) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openKeyboard();
});
return;
}
_routeAnimation = animation;
_routeAnimationListener = (status) {
if (status == AnimationStatus.completed) {
animation.removeStatusListener(_routeAnimationListener!);
_routeAnimationListener = null;
if (mounted) _openKeyboard();
}
};
animation.addStatusListener(_routeAnimationListener!);
}
void _openKeyboard() {
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
}
void _startTimer() {
_timer?.cancel();
_timerSeconds = 30;
@@ -215,7 +255,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
),
),
GestureDetector(
onTap: () => _focusNode.requestFocus(),
onTap: _openKeyboard,
child: FittedBox(
child: Row(
children: List.generate(6, (index) {
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
@@ -53,7 +55,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
await prefs.setString(ServerConfig.prefHostKey, host);
await prefs.setInt(ServerConfig.prefPortKey, port);
await api.disconnect();
api.connect();
unawaited(api.connect());
final online = await api.stateStream
.firstWhere((s) =>
s == SessionState.online || s == SessionState.disconnected)
+7 -1
View File
@@ -31,7 +31,13 @@ class _CallsTabState extends State<CallsTab> {
}
final callsModule = CallsModule(api);
final calls = await callsModule.fetchHistory(p.id, p.id);
List<CallLogEntry> calls;
try {
calls = await callsModule.fetchHistory(p.id, p.id);
} catch (e) {
if (mounted) setState(() => _isLoading = false);
return;
}
final List<CallLogEntry> grouped = [];
for (final call in calls) {
@@ -0,0 +1,384 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' as main;
import '../../widgets/custom_notification.dart';
class ChatInfoScreen extends StatefulWidget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ChatInfoScreen({
super.key,
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
@override
State<ChatInfoScreen> createState() => _ChatInfoScreenState();
}
class _ChatInfoScreenState extends State<ChatInfoScreen>
with TickerProviderStateMixin {
bool _isLoading = true;
Map<String, dynamic>? _chatData;
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_loadChatData();
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
Future<void> _loadChatData() async {
try {
final packet = await main.api.sendRequest(Opcode.chatInfo, {
'chatIds': [widget.chatId],
});
final payload = packet.payload as Map?;
if (payload == null) {
if (mounted) setState(() => _isLoading = false);
return;
}
final errorField = payload['error'];
if (errorField != null) {
String errorMsg = 'Error';
if (errorField is Map) {
errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString();
} else if (errorField is String) {
errorMsg = errorField;
}
if (mounted) showCustomNotification(context, errorMsg);
setState(() => _isLoading = false);
return;
}
final chats = payload['chats'] as List?;
if (chats != null && chats.isNotEmpty) {
_chatData = Map<String, dynamic>.from(chats.first as Map);
} else if (chats != null && chats.isEmpty) {
if (mounted) showCustomNotification(context, 'No data found');
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Error: $e');
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
title: Text(
l10n?.chatInfoTitle ?? 'Info',
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
),
),
body: _isLoading
? _buildShimmer(cs)
: _chatData == null
? Center(
child: Text(
'No data',
style: TextStyle(color: cs.onSurfaceVariant),
),
)
: _buildContent(cs, l10n),
);
}
Widget _buildShimmer(ColorScheme cs) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
shape: BoxShape.circle,
),
),
),
const SizedBox(height: 12),
Center(
child: Container(
width: 120,
height: 20,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
),
),
),
const SizedBox(height: 24),
...List.generate(
10,
(_) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
height: 48,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
);
}
Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) {
final chat = _chatData!;
final type = chat['type'] as String? ?? '';
return ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primaryContainer,
),
child: widget.imageUrl.isNotEmpty
? ClipOval(
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
)
: Center(
child: Text(
widget.name.isNotEmpty
? widget.name[0].toUpperCase()
: '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 12),
Center(
child: Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 24),
if (type == 'CHANNEL') ...[
_buildSectionTitle('Channel', cs),
_buildRow(
l10n?.chatInfoSubscribers ?? 'subscribers:',
(chat['participantsCount'] as int?)?.toString() ?? '-',
cs,
),
if ((chat['link'] as String?)?.isNotEmpty ?? false)
_buildRow(
l10n?.chatInfoLink ?? 'link:',
chat['link'] as String,
cs,
),
_buildRow(
l10n?.chatInfoOfficial ?? 'official:',
(chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-',
cs,
),
_buildRow(
l10n?.chatInfoComments ?? 'comments:',
(chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-',
cs,
),
_buildRow(
l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:',
(chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-',
cs,
),
_buildRow(
l10n?.chatInfoSignAdmin ?? 'admin signature:',
(chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-',
cs,
),
if ((chat['modified'] as int?) != null)
_buildRow(
l10n?.chatInfoLastChanged ?? 'last changed:',
_formatTs(chat['modified'] as int),
cs,
),
if ((chat['created'] as int?) != null)
_buildRow(
l10n?.chatInfoCreated ?? 'created:',
_formatTs(chat['created'] as int),
cs,
),
],
if (type == 'CHAT') ...[
_buildSectionTitle('Chat', cs),
_buildRow(
l10n?.chatInfoMembers ?? 'members:',
(chat['participantsCount'] as int?)?.toString() ?? '-',
cs,
),
if ((chat['hasBots'] as bool?) ?? false)
_buildRow(
l10n?.chatInfoHasBots ?? 'has bots:',
'true',
cs,
),
if ((chat['blockedParticipantsCount'] as int?) != null &&
chat['blockedParticipantsCount'] > 0)
_buildRow(
l10n?.chatInfoBlockedCount ?? 'blocked in group:',
(chat['blockedParticipantsCount'] as int).toString(),
cs,
),
_buildRow(
l10n?.chatInfoOfficialStatus ?? 'official status:',
(chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-',
cs,
),
if ((chat['modified'] as int?) != null)
_buildRow(
l10n?.chatInfoLastChanged ?? 'last changed:',
_formatTs(chat['modified'] as int),
cs,
),
if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1)
_buildRow(
l10n?.chatInfoJoined ?? 'joined:',
_formatTs(chat['joinTime'] as int),
cs,
),
if ((chat['created'] as int?) != null)
_buildRow(
l10n?.chatInfoGroupCreated ?? 'group created:',
_formatTs(chat['created'] as int),
cs,
),
if ((chat['owner'] as int?) != null)
_buildRow(
l10n?.chatInfoGroupOwner ?? 'group owner:',
(chat['owner'] as int).toString(),
cs,
),
],
if (type == 'DIALOG') ...[
if ((chat['created'] as int?) != null &&
chat['created'] != 0 &&
chat['created'] != 1)
_buildRow(
l10n?.chatInfoDialogStarted ?? 'dialog started:',
_formatTs(chat['created'] as int),
cs,
),
],
const SizedBox(height: 120),
],
);
}
Widget _buildSectionTitle(String title, ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
Widget _buildRow(String label, String value, ColorScheme cs) {
return Container(
margin: const EdgeInsets.only(bottom: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
String _formatTs(int ts) {
if (ts < 1000000000000) return ts.toString();
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
}
@@ -665,6 +665,7 @@ class _ChatListScreenState extends State<ChatListScreen>
..removeListener(_onStoriesRevealTick)
..removeStatusListener(_onStoriesRevealStatus)
..dispose();
_shimmerController.dispose();
_folderPageController.dispose();
while (_folderChatScrollControllers.isNotEmpty) {
final c = _folderChatScrollControllers.removeLast();
@@ -1070,6 +1071,7 @@ class _ChatListScreenState extends State<ChatListScreen>
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
chatType: "DIALOG",
);
} else {
final name = chat.lastMsgSenderId != null
@@ -1101,6 +1103,7 @@ class _ChatListScreenState extends State<ChatListScreen>
isOnline: chat.isOnline,
unreadCount: chat.unreadCount,
isMuted: chat.dontDisturbUntil > 0,
chatType: chat.type,
);
}
}, childCount: _isInitialLoading ? 10 : chats.length),
@@ -1282,6 +1285,7 @@ class _ChatListScreenState extends State<ChatListScreen>
});
},
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
AnimatedPositioned(
duration: _navDragging
@@ -1714,6 +1718,7 @@ class _ChatListScreenState extends State<ChatListScreen>
bool isRead = false,
int unreadCount = 0,
bool isMuted = false,
String chatType = "CHAT",
}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id);
@@ -1723,16 +1728,17 @@ class _ChatListScreenState extends State<ChatListScreen>
if (_isSelectionMode) {
_toggleSelection(id);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: int.parse(id),
name: name,
imageUrl: imageUrl,
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: int.parse(id),
name: name,
imageUrl: imageUrl,
chatType: chatType,
),
),
),
);
);
}
},
onLongPress: () => _toggleSelection(id),
+117 -64
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/api.dart';
@@ -15,12 +16,14 @@ class ChatScreen extends StatefulWidget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ChatScreen({
super.key,
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
@override
@@ -255,71 +258,84 @@ class _ChatScreenState extends State<ChatScreen>
String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently";
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton(
icon: const Icon(Symbols.arrow_back, weight: 400),
onPressed: () => Navigator.pop(context),
),
titleSpacing: 0,
title: Row(
children: [
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
)
else
CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
Text(
status ?? "",
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType)
)
),
child: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton(
icon: const Icon(Symbols.arrow_back, weight: 400),
onPressed: () => Navigator.pop(context),
),
],
),
actions: [
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: () {},
titleSpacing: 0,
title: Row(
children: [
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
)
else
CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
Text(
status ?? "",
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: () {},
),
IconButton(
icon: const Icon(Symbols.more_vert, weight: 400),
onPressed: () {},
),
],
),
IconButton(
icon: const Icon(Symbols.more_vert, weight: 400),
onPressed: () {},
),
],
),
)),
body: Column(
children: [
Expanded(
@@ -366,7 +382,7 @@ class _ChatScreenState extends State<ChatScreen>
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat!.type,
chatType: chat?.type ?? 'CHAT',
);
},
);
@@ -470,6 +486,43 @@ class _ChatScreenState extends State<ChatScreen>
Widget _buildInputArea(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
if (widget.chatType == "CHANNEL") {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: GestureDetector(
onTap: () {},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
),
child: Center(
child: Text(
'Отключить уведомления',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
@@ -1,11 +1,55 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
import '../../../core/utils/logger.dart';
import '../../../main.dart';
class DebugMenuScreen extends StatelessWidget {
class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key});
@override
State<DebugMenuScreen> createState() => _DebugMenuScreenState();
}
class _DebugMenuScreenState extends State<DebugMenuScreen> {
final _idController = TextEditingController();
String? _searchResult;
bool _isSearching = false;
@override
void dispose() {
_idController.dispose();
super.dispose();
}
Future<void> _search() async {
final id = int.tryParse(_idController.text);
if (id == null) return;
setState(() {
_isSearching = true;
_searchResult = null;
});
try {
final result = await ChatsModule.searchById(api, id);
logger.i('searchById result: $result');
if (!mounted) return;
if (result is Map && result.containsKey('error')) {
final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error';
setState(() => _searchResult = 'Error: $errorMsg');
} else if (result is Map) {
setState(() => _searchResult = result.toString());
} else {
setState(() => _searchResult = result?.toString() ?? 'null');
}
} catch (e) {
if (mounted) {
setState(() => _searchResult = 'Exception: $e');
}
} finally {
if (mounted) setState(() => _isSearching = false);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -115,10 +159,92 @@ class DebugMenuScreen extends StatelessWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Поиск по ID (opcode 60)',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _idController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'Введите user ID',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onSubmitted: (_) => _search(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _isSearching ? null : _search,
child: _isSearching
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Symbols.search, size: 20),
),
],
),
if (_searchResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxHeight: 400),
child: SingleChildScrollView(
child: Text(
_searchResult!,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
),
],
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 120)),
],
),
),
);
}
}
}
@@ -271,8 +271,9 @@ class _DevicesScreenState extends State<DevicesScreen>
setState(() => _loadingIps.add(id));
}
HttpClient? client;
try {
final client = HttpClient();
client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(
Uri.parse(
@@ -296,6 +297,8 @@ class _DevicesScreenState extends State<DevicesScreen>
setState(() => _loadingIps.remove(id));
showCustomNotification(context, 'Ошибка IP: $e');
}
} finally {
client?.close();
}
}
@@ -0,0 +1,286 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart';
class InfoScreen extends StatefulWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> {
bool _isLoading = true;
Map<String, dynamic>? _info;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
if (mounted) setState(() => _isLoading = false);
return;
}
final jsonStr = await AppDatabase.getLoginInfo(accountId);
if (jsonStr != null) {
setState(() => _info = jsonDecode(jsonStr) as Map<String, dynamic>);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Error: $e');
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
title: Text(
l10n?.infoTitle ?? 'Info',
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _info == null
? Center(
child: Text(
'No data',
style: TextStyle(color: cs.onSurfaceVariant),
),
)
: _buildContent(cs, l10n!),
);
}
Widget _buildContent(ColorScheme cs, AppLocalizations l10n) {
final info = _info!;
final server = info['server'] as Map<String, dynamic>?;
final user = info['user'] as Map<String, dynamic>?;
final yMap = server?['y-map'] as Map<String, dynamic>?;
final accountKeys = <String, String>{
'registrationTime': l10n.infoRegistrationTime,
'country': l10n.infoCountry,
'videoChatHistory': l10n.infoVideoChatHistory,
'updateTime': l10n.infoUpdateTime,
'id': l10n.infoId,
'chatMarker': l10n.infoChatMarker,
};
final serverKeys = <String, String>{
'account-removal-enabled': l10n.infoAccountRemovalEnabled,
'image-size': l10n.infoImageSize,
'gce': l10n.infoGce,
'gcce': l10n.infoGcce,
'max-msg-length': l10n.infoMaxMsgLength,
'quotes-enabled': l10n.infoQuotesEnabled,
'calls-endpoint': l10n.infoCallsEndpoint,
'send-location-enabled': l10n.infoSendLocationEnabled,
'lgce': l10n.infoLgce,
'wud': l10n.infoWud,
'video-msg-enabled': l10n.infoVideoMsgEnabled,
'grse': l10n.infoGrse,
'edit-timeout': l10n.infoEditTimeout,
'image-quality': l10n.infoImageQuality,
'unsafe-files-alert': l10n.infoUnsafeFilesAlert,
'account-nickname-enabled': l10n.infoAccountNicknameEnabled,
'mentions_entity_names_limit': l10n.infoMentionsEntityNamesLimit,
'reactions-enabled': l10n.infoReactionsEnabled,
};
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSectionTitle(l10n.infoAccountSection, cs),
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)),
const SizedBox(height: 16),
_buildSectionTitle(l10n.infoServerSection, cs),
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoYMapSection, cs),
_buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs),
_buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs),
_buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoFileUploadTypes, cs),
_buildListRow(server?['file-upload-unsupported-types'] as List?, cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoWhiteListLinks, cs),
_buildListRow(server?['white-list-links'] as List?, cs),
const SizedBox(height: 8),
_buildSectionTitle(l10n.infoUserSection, cs),
if (user != null)
...user.entries
.where((e) => e.value != null)
.map((e) => _buildRow(e.key, e.key, e.value.toString(), cs)),
const SizedBox(height: 120),
],
);
}
Widget _buildSectionTitle(String title, ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
Widget _buildRow(String key, String label, String value, ColorScheme cs) {
return Container(
margin: const EdgeInsets.only(bottom: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
Widget _buildListRow(List? items, ColorScheme cs) {
if (items == null || items.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Text('-', style: TextStyle(color: cs.onSurfaceVariant)),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: items
.map(
(item) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.toString(),
style: TextStyle(fontSize: 13, color: cs.onSurface),
),
),
)
.toList(),
),
);
}
String _formatValue(dynamic value) {
if (value == null) return '-';
if (value is Map && value.containsKey('chatMarker')) {
final ts = value['chatMarker'] as int?;
if (ts != null) {
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
return '-';
}
if (value is int && value > 1000000000000) {
final dt = DateTime.fromMillisecondsSinceEpoch(value);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
if (value is int && value > 86400) {
final weeks = value ~/ 604800;
final days = (value % 604800) ~/ 86400;
if (weeks > 0) {
return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim();
}
final h = value ~/ 3600;
final m = (value % 3600) ~/ 60;
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
return value.toString();
}
String _w(int n) {
final m = n % 10;
if (m == 1 && n != 11) return 'нед';
if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'нед';
return 'нед';
}
String _d(int n) {
final m = n % 10;
if (m == 1 && n != 11) return 'дн';
if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн';
return 'дн';
}
}
@@ -222,6 +222,7 @@ class _SecurityScreenState extends State<SecurityScreen>
case 'CONTACTS':
return 'Мои контакты';
case 'NONE':
case 'NOBODY':
return 'Никто';
default:
return value;
@@ -481,9 +482,31 @@ class _SecurityScreenState extends State<SecurityScreen>
icon: Icons.visibility_off_outlined,
label: 'Видеть статус «в сети»',
value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты',
isLast: true,
isLast: false,
onTap: () => _showHiddenStatusSheet(context, cs),
),
_buildOptionRow(
cs,
icon: Symbols.contact_page,
label: 'Видеть мой номер',
value: _getPrivacyLabel(
_privacyConfig?.phoneNumberPrivacy ?? 'ALL',
),
isLast: true,
onTap: () => _showOptionSheet(
context,
cs,
title: 'Видеть мой номер',
currentValue: _privacyConfig?.phoneNumberPrivacy ?? 'ALL',
options: const [
('ALL', 'Все'),
('CONTACTS', 'Мои контакты'),
('NOBODY', 'Никто'),
],
onSelect: (value) =>
_updateSetting('PHONE_NUMBER_PRIVACY', value),
),
),
],
],
),
+17 -4
View File
@@ -9,6 +9,7 @@ import '../../../l10n/app_localizations.dart';
import '../auth/proxy_settings_sheet.dart';
import 'debug_menu_screen.dart';
import 'devices_screen.dart';
import 'info_screen.dart';
import 'security_screen.dart';
import 'spoof_screen.dart';
@@ -97,15 +98,27 @@ class _SettingsTabState extends State<SettingsTab> {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _buildSection(
child: _buildSection(
context,
cs,
items: const [
_SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
_SettingsItem(
items: [
const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
const _SettingsItem(
icon: Symbols.language,
label: 'Войти в Сферум',
),
_SettingsItem(
icon: Symbols.info,
label: 'Info',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InfoScreen(),
),
);
},
),
],
),
),
+60 -59
View File
@@ -290,66 +290,70 @@ class MessageBubble extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
// TODO: Нормальное кеширование контактов
final ss = messagesModule.searchContactById(message.senderId);
String? senderAvatar = ContactCache.getAvatar(message.senderId);
String? displaySender = ContactCache.get(message.senderId);
return Padding(
padding: EdgeInsets.only(
left: isMe ? 60 : 12,
right: isMe ? 12 : 60,
top: topMargin,
bottom: bottomMargin,
),
child: Align(
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
spacing: 8.0,
children: [
if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG"
&& nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId)
CircleAvatar(
radius: 15,
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else if (displaySender != null && !isMe && chatType != "DIALOG"
&& nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId)
CircleAvatar(
radius: 15,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender!.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
return GestureDetector(
// TODO: действия с сообщением
onTap: () => print("test"),
child: Padding(
padding: EdgeInsets.only(
left: isMe ? 12 : 12,
right: isMe ? 12 : 12,
top: topMargin,
bottom: bottomMargin,
),
child: Align(
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId)
...(senderAvatar != null && senderAvatar.isNotEmpty)
? [
CircleAvatar(
radius: 15,
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
]
: [
CircleAvatar(
radius: 15,
backgroundColor: cs.primaryContainer,
child: Text(
displaySender != null && displaySender.isNotEmpty
? displaySender[0].toUpperCase()
: '?',
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
),
)
]
else if (!isMe && chatType != "CHAT")
SizedBox(width: 0)
else if (!isMe)
CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
)
// Заглушка для паддинга
else
CircleAvatar(
radius: 15,
backgroundColor: Color(0x00000000)
decoration: BoxDecoration(
color: isMe
? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF))
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFE9E9EB)),
borderRadius: _borderRadius,
),
padding: padding,
child: _buildContent(context),
),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe
? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF))
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFE9E9EB)),
borderRadius: _borderRadius,
),
padding: padding,
child: _buildContent(context),
),
],
)
),
],
)
),
)
);
}
@@ -383,18 +387,15 @@ class MessageBubble extends StatelessWidget {
final forwarded = _getForwardedAttachment();
final isForwarded = forwarded != null && !isForwardedContact;
// TODO: Нормальное кеширование контактов
final ss = messagesModule.searchContactById(message.senderId);
String? displaySender = ContactCache.get(message.senderId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId)
if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT")
Text(
displaySender ?? "",
textAlign: TextAlign.left,
// TODO: Получение цветов по хешу ника
style: TextStyle(color: cs.onPrimaryContainer)
),
Row(